Reading the wt CLI from the outside in

meta
dev
Published

August 22, 2026

wt is the command-line interface used to navigate the watchtower (⛫) workspace. That workspace is also the source for this entire website: its post and course notebooks are the canonical content, while wt provides the commands for reading, editing, and running them. Make invokes standalone scripts for résumé, site, and PDF builds.

General workflows

wt is easiest to understand as a path from a shell command to a notebook cell. Most commands follow the same small loop:

parse command -> find a file -> read a notebook -> inspect or change cells

The interesting details are in the edges of that loop: how names resolve, how notebook JSON becomes agent-readable text, how a cell is changed without discarding its outputs, and how course exercise prompts remain ordinary notebook cells.

This walkthrough follows two representative journeys:

  1. wt cat, which reads a notebook for an agent; and
  2. wt edit-cell and wt run, which write notebook state back.

Import and notebook scaffolding complete the wt workflow. Repository tasks use Make: make docs rebuilds the résumé artifacts and starts the site preview, make resume rebuilds those artifacts on its own, and project tasks have their own Make targets. wt vault remains a core tool for secrets. The Python package also provides ML plotting helpers for notebooks.

1. Start at cli.py: the command map

The CLI is built with Typer. The top-level app owns the command names, while new groups the notebook and course scaffolding commands:

app = typer.Typer(
    name="wt",
    help="Notebook workflows and core tools.",
    no_args_is_help=True,
)

new_app = typer.Typer(name="new", help="Scaffold notebooks and courses.")
app.add_typer(new_app)

The command functions mostly translate CLI arguments into calls to a module function. For example, cat imports notebook inside its body, selects the effective read limit, calls cat_notebook, and prints the result. Imports are kept close to the command that needs them, so wt map does not need to load the notebook execution stack.

The current command map includes:

  • repository navigation: map, ls, find, count, and kernels;
  • notebook operations: cat, output, diff, run, edit-cell, append-cell, insert-cell, remove-cell, clear-outputs, and tag;
  • creation and integration: new and import;
  • core secrets: vault set, get, rm, ls, and export.

Post creation is unified under wt new post; the remaining content types use their own scaffold commands. The two console scripts in pyproject.toml, wt and watchtower, both point at watchtower.cli:main. main() is also the final error boundary: selected file and argument errors become a readable message and exit status 1.

The Makefile calls standalone scripts under scripts/ for site preview, résumé builds, and project setup. Those scripts do not import watchtower. wt --help covers notebook work and the core vault commands.

2. From a name to a notebook

The first real question for a notebook command is not “what cell?” It is “which file does this name mean?” That logic lives in inspect.resolve_ipynb().

Its resolution ladder is short:

def resolve_ipynb(name: str) -> Path:
    maybe = Path(name)
    if maybe.exists() and maybe.suffix == ".ipynb":
        return maybe.resolve()

    parts = Path(name).parts
    if parts and parts[0] in {"posts", "courses"}:
        path = NB_DIR.joinpath(*parts)
        if path.suffix != ".ipynb":
            path = path.with_suffix(".ipynb")
        if path.exists():
            return path.resolve()
    if parts and parts[0] == NB_DIR.name and len(parts) > 1:
        path = Path(*parts)
        if path.suffix != ".ipynb":
            path = path.with_suffix(".ipynb")
        if path.exists():
            return path.resolve()

    for base in CONTENT_DIRS:
        for path in base.rglob(f"{name}.ipynb"):
            if ".ipynb_checkpoints" not in path.parts:
                return path

    raise FileNotFoundError(...)

That gives the CLI three useful forms:

nb/posts/005-wt-src-walkthrough.ipynb   explicit path
nb/posts/005-wt-src-walkthrough        tier plus stem
005-wt-src-walkthrough                 bare stem

paths.py supplies the content-directory constants and the absolute ROOT_PATH used for repository-anchored generated artifacts. The content directories themselves are relative paths, which is why the normal workflow starts in the repository root and why the tests change into temporary repo directories.

The rest of inspect.py builds on the same filesystem view. wt map returns the repository structure as JSON, ls lists notebooks while excluding index and checkpoint files, and find searches notebook sources. find uses rg as a quick candidate filter when it is available, then parses matching notebooks to report the cell index and matching line.

3. notebook.py: the shared cell layer

The core notebook path can be pictured like this:

flowchart LR
    name["user-supplied name"] --> resolve["resolve_ipynb"]
    resolve --> read["nbformat.read"]
    read --> inspect["render cells"]
    read --> mutate["change cells"]
    mutate --> write["nbformat.write"]
    inspect --> stdout["print Markdown-like text"]

There is no long-lived Notebook object. Each CLI process resolves a path, reads a NotebookNode, performs one operation, and exits. That makes the state easy to find: it is in the .ipynb file.

cat_notebook() is the read side. It can render every cell, or select cells by:

  • an index, including Python-style ranges such as 0:3 and negative indices;
  • a Jupyter tag; or
  • a leading Quarto #| label: pragma.

_render_cell() adds a small header such as > cell 4 [code], renders code cells inside a Python fence, and leaves Markdown sources readable as Markdown. The same renderer is reused by wt diff, which is why notebook diffs show content instead of a wall of JSON.

The write functions are deliberately small: edit_cell, append_cell, insert_cell, remove_cell, clear_outputs, and tag_cell. They validate locators, enforce the 20,000-character source limit, mutate the in-memory notebook, and write it back through nbformat. edit_cell changes the selected cell’s source, so its existing outputs and metadata remain attached to that cell. remove_cell deletes matching indices in reverse order so the remaining positions do not shift under the loop.

4. Why cat is shaped for agent reads

The normal notebook representation is excellent for Jupyter and awkward for an agent: cell sources, metadata, execution counts, and output payloads are all nested in JSON. cat creates a compact text view instead.

For example, a selected code cell looks conceptually like this:

> cell 4 [code] tags:example

```{python}
print("hello")
```

The command has a few features that make long notebooks manageable:

  • source reads default to 4,096 characters per cell;
  • --offset and --limit make a long cell readable in successive slices;
  • --context N includes nearby cells and marks them as context;
  • --with-outputs appends stored outputs with their own headers; and
  • stored cell source is shown as-is, including encoded legacy solution cells.

Output rendering is intentionally conservative. Text and errors are shown; PNG, JPEG, and SVG payloads are summarized rather than expanded into base64. When an agent needs the actual image, outputs.py provides a structured CellOutput interface and wt output decodes image bytes into .tmp.

The same plain representation powers wt diff. diff_notebook() reads the base version with git show and renders both versions through the notebook renderer. It compares stored cell source; the public course solutions page collects the readable answers from older solution cells.

5. The write path and the execution path

An edit is a simple notebook round trip:

path = resolve_ipynb(name)
nb = read_notebook(path)
cell_index = _resolve_unique_cell(nb, ...)
nb["cells"][cell_index]["source"] = source
nbformat.write(nb, path)

The important part is that the operation changes a loaded notebook object, not a hand-edited JSON string. Existing metadata and outputs survive because they are still present when nbformat.write() serializes the object. The file is still the source of truth, so wt clear-outputs and wt run visibly change the stored notebook state.

wt run takes the next step by using nbclient:

read notebook -> start kernel -> execute code cells -> store outputs -> write notebook

A normal run executes all code cells in one kernel. Errors are stored as inline error outputs and execution continues. wt run --index N executes only that cell in a fresh kernel, which is useful for testing a self-contained cell and intentionally does not provide state from earlier cells. wt kernels lists the installed kernels that can be passed with --kernel.

The repository is configured so Quarto renders stored inline outputs without re-executing code. That makes wt run the explicit refresh operation: a render can faithfully show outputs that were produced earlier, including outputs that are now stale.

6. Course exercises remain notebook content

Exercise prompts live in chapter notebooks as Markdown cells, optionally followed by starter code. Agents use the same wt cat, insert-cell, edit-cell, and run commands used elsewhere in the knowledge base. They can assess a learner’s response when requested without storing a prewritten solution for every prompt.

Older chapters may still contain hidden solution code cells. The cells use Quarto options to keep their content off each chapter page and ROT18 in their stored source. Their readable answers are collected on the public course solutions page. New exercise prompts do not depend on that format.

7. The supporting modules

The notebook layer is the center of the walkthrough, but the package has several useful neighbors.

Area Modules Main job
Create content scaffold.py Build post, course, chapter, and section stubs
Import content convert.py Copy external notebooks, preserve outputs, normalize kernelspecs, and register course chapters
Store secrets vault.py Use the OS keyring with a local index of available key names
ML notebook tools core/ Provide plotting primitives and a Python/NumPy/PyTorch seed helper
Discover kernels kernels.py List installed Jupyter kernels for wt run --kernel

scaffold.py is where content creation meets the site configuration. It uses ruamel.yaml to register courses and chapters in _quarto.yml, preserving the YAML document while adding sidebar entries. convert.py reuses that registration path when importing a chapter.

Repository tasks live outside the package. scripts/resume.py turns YAML and Jinja templates into site pages and a PDF, scripts/docs.py starts Quarto preview, scripts/render.py renders notebook PDFs, and scripts/project.py creates projects. Make calls these scripts directly. wt ls projects lists the existing project directories without reading the full notebook catalog.

The notebook package uses the OS keyring for secrets. Quarto handles PDF rendering through the standalone script.

8. Read the tests alongside the source

The tests are the best way to see which details are meant to stay stable. They use temporary repositories for filesystem operations, so notebook edits and sidebar changes can be tested without touching the real knowledge base.

The most useful reading pairs are:

Source Tests What the pair teaches
notebook.py test_notebook.py Cell selectors, slices, writes, output clearing, and readable diffs
inspect.py test_inspect.py Repository listing, resolution, and source search
scaffold.py test_scaffold.py Post stubs and sidebar registration
convert.py test_convert.py Import normalization and duplicate-title handling
execute.py test_execute.py Real-kernel execution and persisted outputs
outputs.py test_outputs.py Text/image normalization and image extraction
cli.py test_cli.py The command boundary through Typer’s test runner

The suite covers notebook and course scaffolding directly, and the execution tests launch a real Python kernel. Quarto rendering, PDF generation, and the plotting helpers sit at different external boundaries, so they are better understood by reading their small wrappers and trying them in the local environment.

For the normal development loop, the repository’s own tools make the source easy to inspect:

.venv/bin/wt cat nb/posts/005-wt-src-walkthrough --index 3 --context 1
.venv/bin/wt diff nb/posts/005-wt-src-walkthrough
.venv/bin/pytest -q

9. The compact mental model

The package becomes much easier to hold in your head if you group commands by the kind of state they move through:

flowchart TB
    command["wt command"] --> notebook_flow["resolve -> read -> render or mutate -> write"]
    command --> kernel_flow["start kernel -> execute -> persist outputs"]
    command --> vault_flow["read or write OS keyring"]

From that model, the source-reading order is natural:

  1. cli.py tells you what the user can ask for.
  2. inspect.py tells you how a name becomes a path.
  3. notebook.py tells you how cells become readable text and how edits are written.
  4. execute.py and outputs.py explain the lifecycle of stored results.
  5. scaffold.py and convert.py explain creation and import.
  6. Standalone scripts under scripts/ handle site and PDF builds.

The recurring design choice is simple: keep the CLI close to the shell, keep the notebook as the canonical artifact, and put conventions in small modules that can be tested with temporary files. Once that is clear, the source is no longer a list of unrelated commands. It is a set of short, inspectable paths through the same filesystem-backed knowledge base.

Back to top