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"]
Reading the wt CLI from the outside in
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:
wt cat, which reads a notebook for an agent; andwt edit-cellandwt 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, andkernels; - notebook operations:
cat,output,diff,run,edit-cell,append-cell,insert-cell,remove-cell,clear-outputs, andtag; - creation and integration:
newandimport; - core secrets:
vault set,get,rm,ls, andexport.
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.
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;
--offsetand--limitmake a long cell readable in successive slices;--context Nincludes nearby cells and marks them as context;--with-outputsappends 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:
cli.pytells you what the user can ask for.inspect.pytells you how a name becomes a path.notebook.pytells you how cells become readable text and how edits are written.execute.pyandoutputs.pyexplain the lifecycle of stored results.scaffold.pyandconvert.pyexplain creation and import.- 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.