Follow these skills: --- name: screen-decomposition description: Organize everything a project has into layered screens - list every thing its user would want to reach, group it by what they are trying to do, name it in the project's own words, and nest it into screens that each say where you are and what you are looking at. Produces the map that a CLI (`cli-engineer`) or a UI (`minimal-ux-flow`) is then built from. Use when mapping a project's commands, menus, navigation, help or settings; when deciding what goes on which screen or menu; when a list has grown too long; or when anything a user needs has no single place to be found. metadata: usage-tier: mid-level --- # Screen decomposition You turn everything a project has into a map: a tree of screens, each listing the things a user can reach from there. The map doesn't depend on the medium. `cli-engineer` draws it as ` help` screens in a terminal; `minimal-ux-flow` draws it as an app's navigation, titles and menus. This skill decides what the screens are and what is on them. How a screen looks is the other skill's job. ## How to think about it Every principle here is a first bet, not a rule. What decides is what this project's screens help its user do, and what keeps everything easy to reach. A principle that doesn't serve the project gets changed, and the map says why. The user, 2026-10-26: "the 3 columns are just a suggestion. each case need to be considered based on the project and what the cli helps to facilitate." The map is a map, not an index. The user moves through it one noun at a time, and on every screen they can tell where they are, what they're looking at, and what each thing does before they use it. ## The principles 1. **Write the real-world scenario first** (`conceptual-modeling`, Exercise): who reaches for this, at what moment, to do what (deploy, find a log, open a config, see a job's state). Label it reasoning from experience. Every later choice, the grouping above all, is judged against it. 2. **List every thing** the user would want to reach: commands and scripts, files and folders, URLs and dashboards, services and ports, logs, docs, live state (current profile, running jobs), and for an app its screens and records. Note each thing's technical form and where it is defined. Look where things hide: in a Makefile target, a compose file, a service unit, a nested docs folder, an unlisted script. What already exists counts, and is never written a second time. 3. **Group by what the user is trying to do**, not by file type, data type or folder. A group's name says the job or the thing it is about, in the user's words. 4. **Name things in the project's own words**, taken from its README, code and screens, never a word you invented. One word per thing: once chosen, it stays the same on every screen. 5. **Nest the groups into a tree of nouns.** Each noun is one screen deeper: `codebot › guests › guest1 › functions`. A screen lists what can follow its noun. 6. **Every screen says where you are and what you're looking at.** It opens with its path of nouns, then one plain sentence naming what it shows (a noun phrase, no verb needed). The top screen has no path, so it opens with only the sentence. A menu of choices is not a place, so it is never part of a path. The user, 2026-10-26: "to every screen a plain english sentence about what screen I'm seeing. Uses a breadcrumbs pattern because some screens can be nested." How the path and sentence are drawn is up to the medium. 7. **Every item has three parts:** its name in the user's words; its technical form (a path, URL, port, command or screen) so the user can reach it; and what it's for, in one plain line that starts with the verb of what using it does (opens, prints, lists, starts, stops, restarts, tails, runs, deploys, edits), then the project's noun. The verb belongs to that exact item: a group's own entry says what opening the group does (usually shows its screen), never what one of its actions does. The user, 2026-10-26: "i'm missing the verb, I need to know if this command launches a server? prints? and so on". 8. **A thing appears on every screen where the user reaches for it, worded for that screen.** A runbook can sit under both "live" and "docs", with each line saying what it gives the user in that place. Two entries with the same wording are a repeat: cut one. 9. **The top screen stays short, however large the project grows:** a few groups, never a 50-item list. When a list gets too long, it goes behind an earlier screen with fewer options. Deciding where to cut is abstract, high-stakes reasoning, so avoid it unless you have to, and never split on your own: put the split to the user as a question, with your recommendation. The user, 2026-10-26: "it's about how to know to decompose a huge list into more manageable one". 10. **One source.** The map is generated from the same source the code uses (a README table, the dispatch table, the route list, live state), never typed out a second time. A test fails when a thing exists with no entry, so the map stays complete as the project grows. ## What you hand over The map, written out in full: - **The scenario** from principle 1. - **The inventory:** every thing, its technical form, and where it is defined. - **The screens:** for each one, its path, its sentence, and its items (name, technical form, what it's for). - **The questions** that are the user's to decide (a split, a name they'll type), each with your recommendation. - **Your judgment calls,** each with its reason. `cli-engineer` or `minimal-ux-flow` builds from this map. When a later change to the project adds a thing, it goes into an existing group where it fits. A new group, or a split behind a new screen, is the user's call. ## Before you hand it over - Every thing in the inventory has an entry on at least one screen. - No word on any screen is one you made up; every group and item name is the project's own. - Every screen has its path (none on the top screen) and its sentence; no menu appears in a path. - Every "what it's for" line starts with the verb of what using that exact item does. - A thing on two screens is worded for each. - The top screen is short, and every split you'd make is a question to the user, not a done deal. --- --- name: cli-engineer description: Plan, map and build the full command line of a project or a project idea - its ` help` screens, its commands and aliases, and their wiring into the user's shell (~/.zsh.d, ~/.bashrc) - to CLI best practice and the user's own conventions (`opencode help`, `workstation help`). Use when asked to "map out the CLI", "make a help command", "add help", "wire this into my zshrc", "give this project commands", "what commands should this have", or when a project's commands, scripts and files have no single place to find them. metadata: usage-tier: mid-level --- # CLI engineer You design and build the command line a person uses to reach everything a project has: one facade command, ` help` as the map, aliases, and thin commands. Started as the `cli-engineer` persona (`~/projects/my-skills/agents/cli-engineer/claude.md`), or loaded by any agent that needs this. What goes on which screen is decided by `screen-decomposition`: load it first. It produces the map (the scenario, the inventory, the screens with their paths, sentences and items, and the questions for the user). This skill draws that map in a terminal and builds the command line around it. The bar: every CLI you make is at least as good as `opencode help` and `workstation help` are today. Read both before you plan anything (paths in `references/house-conventions.md`); they are the floor, not a template. ## The map in a terminal Every screen of the map is a command of the form ` … `, each noun one screen deeper. A bare noun (`codebot guests`) prints that thing's screen. `help` may follow the last noun: ` … help`. `help` always means "what can follow the noun just before me", so it can only come last, and it works after every noun at every depth: `codebot guests guest1 functions help` lists what can follow `functions`. `codebot help guests` is not a command: it asks two things at once, and exits 1 with the right form (`codebot guests help`). The user, 2026-10-26: "the pattern is \" help\" where \"help\" always refers to the n-th noun." `help` is a menu, not a screen and not a place, so it is never part of a breadcrumb. ` help` is the top screen of the map, the table of contents: it opens with the map's sentence for it and then the table. Every screen, and every deeper `… help`, opens with the breadcrumb of its nouns, then its sentence, then its table: ``` codebot › guests › guest1 › functions The functions of codebot's guest1. ``` Piped, the same two lines lead the markdown. Each item of the map is a row of three columns, a first bet like everything else (opencode's profile table has a column per profile): | Column | What goes in it | | --- | --- | | Name | the item's name from the map | | Its technical form | clickable: a file or folder (OSC 8 link that opens it), a URL, the file and line of a function, or the command that opens the thing's screen (`codebot guests`, plain text: a link cannot run a command) | | What it's for | the item's line from the map, starting with the verb of that exact command. `codebot qa start`: "Starts the throwaway CodeBot on port 3506, where every change is tried before it reaches you." `codebot qa`, which only prints its screen: "Prints the QA screen: the throwaway CodeBot on port 3506 and what you can do with it." | ## Steps 1. **Orient.** Run `rules` in the project folder. Read the project end to end: README, scripts, `package.json`, `Makefile`, `pyproject.toml`, docs, its `.idd/` and `.bdd/` if present, and every `~/.zsh.d/*.zsh` that already touches it. Read `~/.zsh.d/workstation.zsh`, `~/.zsh.d/opencode-profiles.zsh` and the scripts they call. 2. **Decompose** by `screen-decomposition`: the scenario, the inventory, the screens and the questions. For the inventory, commands that already exist count; never write a second one for the same job (grep `~/.zsh.d`, the project, and `whence -a ` in zsh and bash). 3. **Name the commands.** Pick the facade name, a short alias, and `-help` forms (`ws`, `ws-help`, `workstation-help`; the user likes many aliases). Check every name for clashes: `zsh -ic 'whence -a '`, `bash -ic 'type -a '`, every `~/.zsh.d` file, the PATH. 4. **Write the plan** in the project's `docs/plans/-cli/plan.md` and open it with `review-open`: the map from step 2, the help screens drawn as markdown tables exactly as they will print, the command tree with aliases, the wiring, what you build yourself and what goes to a worker, the clashes found, and every judgment call you made with its reason. 5. **Short grill.** Ask only what is the user's to decide, one question at a time, each with your recommendation: the names they will type, any split of a list behind a new screen (the map's questions), any clash with an existing command. Usually 1 to 3 questions. Every other call you make yourself, and it is already in the plan. Then ask "build this?". Nothing is written before the yes. 6. **Build the CLI layer** (after the yes): - the script (Python, run with `python3 -I`, a docstring listing its commands, `fail()` writing `: ` to stderr and exiting 1 with the valid choices); - `help` drawn on a terminal, plain markdown when piped, reusing `~/projects/workstation/workstation-cli/terminal_links.py` (`draw_table`, `path_text`, `markdown_cell`) rather than new drawing code; - thin commands that only reach what exists (run a script, open a file with `review-open`, tail a log); - `zsh.d/.zsh` in the project repo: a header comment (what it is, that zsh and bash source it, where the help is), the facade intercepting `help` before anything else, the aliases; - the wiring: copy it to `~/.zsh.d/` (back up any file there first with `cp`), add one `[ -f "$HOME/.zsh.d/.zsh" ] && . "$HOME/.zsh.d/.zsh"` line to `~/.bashrc` (backup first); never edit `~/.zshrc`, its loop already sources `~/.zsh.d/*.zsh`. A command that needs new logic is not yours to write: write it up as a task for a worker (what, why, the intent row) and list it in your report. Write the script by `coding-guidelines`. 7. **Prove it** (`tank--bulletproof`): an intent at `.idd/modules/-cli/INTENT.md` with constraints and a Given / When / Then table naming each test; tests written first and seen failing, then passing. At least: every alias prints the table of contents, opening with its sentence and no breadcrumb; `help` after every noun, at every depth, prints that noun's menu, opening with the breadcrumb of its nouns and a sentence; `help` anywhere but last exits 1 and names the right form; every screen starts with its breadcrumb and sentence; every "what it's for" cell starts with a verb; piped output is plain markdown; on a terminal every path is a link to something that exists; an unknown command or category exits 1 and lists the valid ones; every thing in the inventory has a row (a new script, folder or command fails the test until it gets one). Then run the real thing in `zsh -ic` and `bash -ic` and paste what it printed. 8. **Record and ship.** A dated entry in `~/projects/workstation/README.md`, "System-wide changes" (what was wired, the backups, red and green). Commit and push as the git policy in the rules says, and give the hashes. For a project that is only an idea: steps 1 to 5 only. The plan is the map and the help as they will be; it is built once the project exists. When the project grows later, re-run steps 1 to 8 against the existing CLI: new things go into an existing group where they fit. A new group, or a split behind a new screen, is the user's call (step 5). ## Before you say it is done - `screen-decomposition`'s own checks pass on the map. - ` help` on a terminal and piped, side by side with `workstation help`: is it at least as clear? - Each name you added: `whence -a` shows only yours. - Each function you added: grep its noun again across `~/.zsh.d` and the project (the duplicate check, second run). - Every path on screen opens. - `help` after every noun prints its menu; `help` is never part of a breadcrumb; ` help` opens with only its sentence; `help` before a noun is an error that names the right form. ## References | File | Read it when | | --- | --- | | `references/house-conventions.md` | before step 4: what the two examples do, where, and why | | `references/cli-best-practices.md` | step 3 and step 6: naming, help, errors, output, exit codes | You are given a project (below, as a dump of its files; you cannot read anything else or run commands). Task: organize everything this project's user would want to reach into screens, the map a command line's help screens will be built from. Do the planning only: build nothing, and don't wait for answers. Where a decision is the user's to make, put it in "questions" with your recommendation. End your answer with the map as ONE fenced ```json block, in exactly this shape: {"screens": [{"path": ["noun", "..."], "sentence": "...", "rows": [{"name": "...", "form": "...", "what": "..."}]}], "questions": ["..."]} - "path": the nouns that lead to the screen, [] for the top screen. - "sentence": what the screen shows, in plain words. - "rows": what can be reached from that screen: "name" in plain words, "form" its technical form (a path, URL, port, command, or the screen it opens), "what" what it is for. # Project dump: burrow Repository snapshot of `burrow`, a small tool for looking after the machines in a home lab. Below is the file tree, then the full text of the files that matter. ## File tree ``` burrow/ ├── README.md ├── LICENSE ├── Makefile ├── pyproject.toml ├── docker-compose.yml ├── .env.example ├── config/ │ ├── burrow.toml │ └── rigs.toml ├── deploy/ │ ├── burrow-porch.service │ └── burrow.crontab ├── bin/ │ └── rig-wake ├── burrow/ │ ├── __init__.py │ ├── cli.py │ ├── config.py │ ├── stash.py │ ├── chores.py │ ├── rigs.py │ └── porch/ │ ├── __init__.py │ ├── app.py │ └── templates/ │ ├── index.html │ ├── chores.html │ └── stashes.html ├── pulse/ │ ├── Dockerfile │ └── pulse.py ├── scripts/ │ ├── battery-report.sh │ ├── cert-renew.sh │ ├── check-disks.sh │ ├── dns-flush.sh │ ├── du-stashes.sh │ ├── export-chores.sh │ ├── fan-curve.sh │ ├── gc-docker.sh │ ├── import-rigs.sh │ ├── list-ports.sh │ ├── mount-nas.sh │ ├── net-speed.sh │ ├── pihole-update.sh │ ├── reboot-rig.sh │ ├── rotate-logs.sh │ ├── seed-dev.sh │ ├── smart-report.sh │ ├── ssh-keys-push.sh │ ├── sync-clock.sh │ ├── temp-check.sh │ ├── unmount-nas.sh │ ├── update-rigs.sh │ ├── ups-status.sh │ ├── verify-stash.sh │ └── zfs-scrub.sh ├── docs/ │ ├── setup.md │ ├── adding-a-rig.md │ ├── chores.md │ └── internals/ │ └── stash/ │ └── format/ │ └── restore-by-hand.md └── tests/ ├── test_stash.py ├── test_chores.py └── test_rigs.py ``` --- ## README.md ```markdown # burrow burrow keeps an eye on the handful of machines in my home lab. I call each machine a **rig**. burrow knows which rigs exist, takes **stashes** (snapshots of a rig's important folders, pulled over rsync), runs **chores** (recurring jobs such as nightly stashes and disk checks) and shows everything on the **porch**, a small web dashboard. It is a personal project. It works on my three rigs (`attic`, `cellar`, `garden`) and should work on yours with a bit of config. ## Install git clone https://github.com/example/burrow /opt/burrow cd /opt/burrow make install `make install` creates a virtualenv in `.venv/` and installs the `burrow` command into it. Copy `.env.example` to `.env` and fill in the values. ## Configure All settings live in `config/burrow.toml`. Your rigs are listed in `config/rigs.toml`; see [docs/adding-a-rig.md](docs/adding-a-rig.md) to add one. Stashes are written under `/srv/burrow/stashes`, one folder per rig, one dated folder per stash. ## Use See which rigs burrow knows and whether they answer: burrow rigs Take a stash of one rig right now: burrow stash attic or of every rig at once with `make stash-all`. Put a stash back: burrow restore attic --from 2026-10-20 The porch runs as a systemd service (`burrow-porch.service`) and listens on port 8470. Open http://127.0.0.1:8470 from any machine on the LAN (use the rig's IP instead of 127.0.0.1). ## Chores The nightly stash of every rig is a chore. Install the crontab from `deploy/burrow.crontab`; its output goes to `/var/log/burrow/chores.log`. More in [docs/chores.md](docs/chores.md). ## Metrics `make up` starts the containers in `docker-compose.yml`, which include Grafana on http://127.0.0.1:3000 for graphs of rig temperatures and disk use. ## Helpers `scripts/` holds small shell helpers I use now and then. Read the top of each one before running it. ## Development make test ## License MIT ``` --- ## Makefile ```makefile PREFIX ?= /opt/burrow VENV := $(PREFIX)/.venv PY := $(VENV)/bin/python BURROW := $(VENV)/bin/burrow .PHONY: install up down test lint stash-all prune-stashes install-prune-cron install-unit clean install: python3 -m venv $(VENV) $(VENV)/bin/pip install -e . mkdir -p /srv/burrow/stashes /srv/burrow/state /var/log/burrow install-unit: sudo cp deploy/burrow-porch.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable --now burrow-porch.service up: docker compose up -d down: docker compose down test: $(PY) -m pytest -q tests lint: $(VENV)/bin/ruff check burrow tests stash-all: $(BURROW) stash --all prune-stashes: $(BURROW) stash prune --keep 8 # Weekly prune so /srv/burrow/stashes does not fill the disk. install-prune-cron: ( crontab -l 2>/dev/null | grep -v prune-stashes ; \ echo "40 4 * * 0 cd /opt/burrow && make prune-stashes" ) | crontab - clean: rm -rf $(VENV) .pytest_cache build *.egg-info ``` --- ## pyproject.toml ```toml [project] name = "burrow" version = "0.6.2" description = "Look after the rigs in a home lab: stashes, chores and a porch." requires-python = ">=3.11" dependencies = [ "click>=8.1", "flask>=3.0", "tomli-w>=1.0", "paramiko>=3.4", ] [project.optional-dependencies] dev = ["pytest>=8", "ruff>=0.6"] [project.scripts] burrow = "burrow.cli:main" [tool.setuptools.packages.find] include = ["burrow*"] [tool.ruff] line-length = 100 ``` --- ## docker-compose.yml ```yaml services: pulse: build: ./pulse restart: unless-stopped network_mode: bridge ports: - "9188:9188" environment: PULSE_PORT: "9188" PULSE_RIGS_FILE: /config/rigs.toml PULSE_INTERVAL: "30" volumes: - ./config:/config:ro prometheus: image: prom/prometheus:v2.54.1 restart: unless-stopped command: - --config.file=/etc/prometheus/prometheus.yml - --storage.tsdb.retention.time=30d volumes: - prom-data:/prometheus depends_on: - pulse grafana: image: grafana/grafana:11.2.0 restart: unless-stopped ports: - "3000:3000" environment: GF_AUTH_ANONYMOUS_ENABLED: "true" volumes: - grafana-data:/var/lib/grafana depends_on: - prometheus volumes: prom-data: grafana-data: ``` --- ## .env.example ```dotenv # Copy to .env and fill in. BURROW_SSH_KEY=/home/me/.ssh/burrow_ed25519 BURROW_PORCH_SECRET=change-me BURROW_NTFY_TOPIC=burrow-alerts ``` --- ## config/burrow.toml ```toml [stash] root = "/srv/burrow/stashes" rsync_flags = ["-aHAX", "--delete", "--numeric-ids"] keep = 8 [chores] state_file = "/srv/burrow/state/last-run.json" notify = "ntfy" [porch] bind = "0.0.0.0:8470" title = "burrow porch" refresh_seconds = 20 [ssh] user = "burrow" key_env = "BURROW_SSH_KEY" ``` --- ## config/rigs.toml ```toml [[rig]] name = "attic" address = "192.168.1.20" mac = "a8:a1:59:3c:10:02" paths = ["/etc", "/home", "/var/lib/docker/volumes"] [[rig]] name = "cellar" address = "192.168.1.21" mac = "a8:a1:59:3c:10:7f" paths = ["/etc", "/tank/photos"] [[rig]] name = "garden" address = "192.168.1.22" mac = "d8:3a:dd:41:0e:9b" paths = ["/etc", "/opt/homeassistant"] ``` --- ## deploy/burrow-porch.service ```ini [Unit] Description=burrow porch (dashboard for the rigs) After=network-online.target Wants=network-online.target [Service] Type=simple User=burrow WorkingDirectory=/opt/burrow EnvironmentFile=/opt/burrow/.env ExecStart=/opt/burrow/.venv/bin/burrow porch --bind 0.0.0.0:8470 Restart=on-failure RestartSec=5 StandardOutput=append:/var/log/burrow/porch.log StandardError=append:/var/log/burrow/porch.log [Install] WantedBy=multi-user.target ``` --- ## deploy/burrow.crontab ``` # burrow chores. Install with: crontab deploy/burrow.crontab SHELL=/bin/bash 15 2 * * * /opt/burrow/.venv/bin/burrow chores run nightly-stash >> /var/log/burrow/chores.log 2>&1 0 */6 * * * /opt/burrow/.venv/bin/burrow chores run disk-check >> /var/log/burrow/chores.log 2>&1 ``` --- ## bin/rig-wake ```bash #!/usr/bin/env bash # Wake a sleeping rig with a magic packet, using the MAC from config/rigs.toml. # usage: bin/rig-wake set -euo pipefail here="$(cd "$(dirname "$0")/.." && pwd)" rig="${1:?usage: rig-wake }" mac="$(python3 - "$here/config/rigs.toml" "$rig" <<'PY' import sys, tomllib cfg = tomllib.load(open(sys.argv[1], "rb")) for r in cfg.get("rig", []): if r["name"] == sys.argv[2]: print(r["mac"]); break else: sys.exit(f"no rig named {sys.argv[2]}") PY )" wakeonlan "$mac" echo "sent wake packet to $rig ($mac)" ``` --- ## burrow/cli.py ```python """The burrow command.""" import click from burrow import chores, config, rigs, stash @click.group() @click.option("--config", "config_path", default="config/burrow.toml", show_default=True) @click.pass_context def main(ctx, config_path): ctx.obj = config.load(config_path) @main.command("rigs") @click.pass_obj def rigs_cmd(cfg): """List rigs and whether each one answers.""" for rig in rigs.load(cfg): state = "up" if rigs.ping(rig) else "down" click.echo(f"{rig.name:10} {rig.address:15} {state}") @main.group("stash", invoke_without_command=True) @click.argument("rig", required=False) @click.option("--all", "all_rigs", is_flag=True) @click.pass_context def stash_cmd(ctx, rig, all_rigs): """Take a stash of one rig, or of every rig with --all.""" if ctx.invoked_subcommand: return targets = rigs.load(ctx.obj) if all_rigs else [rigs.get(ctx.obj, rig)] for r in targets: path = stash.take(ctx.obj, r) click.echo(f"stashed {r.name} -> {path}") @stash_cmd.command("list") @click.argument("rig") @click.pass_obj def stash_list(cfg, rig): for s in stash.list_for(cfg, rig): click.echo(s) @stash_cmd.command("prune") @click.option("--keep", default=8, show_default=True) @click.pass_obj def stash_prune(cfg, keep): removed = stash.prune(cfg, keep) click.echo(f"removed {len(removed)} old stashes") @main.command("restore") @click.argument("rig") @click.option("--from", "when", required=True, help="Date of the stash, YYYY-MM-DD.") @click.option("--dry-run", is_flag=True) @click.pass_obj def restore_cmd(cfg, rig, when, dry_run): """Copy a stash back onto its rig.""" stash.restore(cfg, rigs.get(cfg, rig), when, dry_run=dry_run) @main.group("chores") def chores_cmd(): """Recurring jobs.""" @chores_cmd.command("list") def chores_list(): for name in chores.REGISTRY: click.echo(name) @chores_cmd.command("run") @click.argument("name") @click.pass_obj def chores_run(cfg, name): chores.run(cfg, name) @chores_cmd.command("status") @click.pass_obj def chores_status(cfg): for name, info in chores.last_runs(cfg).items(): click.echo(f"{name:15} {info['finished']} {info['result']}") @main.command("porch") @click.option("--bind", default=None) @click.pass_obj def porch_cmd(cfg, bind): """Run the porch dashboard.""" from burrow.porch.app import create_app addr, port = (bind or cfg["porch"]["bind"]).rsplit(":", 1) create_app(cfg).run(addr, int(port)) ``` --- ## burrow/chores.py ```python """Chores: named recurring jobs, run from cron.""" import json import time from pathlib import Path from burrow import rigs, stash REGISTRY = {} def chore(name): def wrap(fn): REGISTRY[name] = fn return fn return wrap @chore("nightly-stash") def nightly_stash(cfg): for r in rigs.load(cfg): stash.take(cfg, r) @chore("disk-check") def disk_check(cfg): for r in rigs.load(cfg): rigs.check_disk(r, warn_at=0.85) def run(cfg, name): started = time.time() try: REGISTRY[name](cfg) result = "ok" except Exception as exc: # noqa: BLE001 result = f"failed: {exc}" _record(cfg, name, started, result) def last_runs(cfg): path = Path(cfg["chores"]["state_file"]) return json.loads(path.read_text()) if path.exists() else {} def _record(cfg, name, started, result): path = Path(cfg["chores"]["state_file"]) data = last_runs(cfg) data[name] = {"started": started, "finished": time.time(), "result": result} path.write_text(json.dumps(data, indent=2)) ``` --- ## burrow/porch/app.py ```python """The porch: a small Flask dashboard.""" from flask import Flask, render_template from burrow import chores, rigs, stash def create_app(cfg): app = Flask(__name__) @app.get("/") def index(): return render_template("index.html", rigs=rigs.load(cfg), cfg=cfg) @app.get("/chores") def chores_page(): return render_template("chores.html", runs=chores.last_runs(cfg)) @app.get("/stashes/") def stashes_page(rig): return render_template("stashes.html", rig=rig, items=stash.list_for(cfg, rig)) @app.get("/healthz") def healthz(): return {"ok": True} return app ``` --- ## pulse/pulse.py ```python """pulse: exposes rig temperatures and disk use for Prometheus.""" import os import time import tomllib import threading from flask import Flask, Response from prometheus_client import CONTENT_TYPE_LATEST, Gauge, generate_latest TEMP = Gauge("burrow_rig_temp_celsius", "CPU temperature", ["rig"]) DISK = Gauge("burrow_rig_disk_used_ratio", "Root disk used", ["rig"]) app = Flask(__name__) @app.get("/metrics") def metrics(): return Response(generate_latest(), mimetype=CONTENT_TYPE_LATEST) def poll(rigs): while True: for r in rigs: TEMP.labels(r["name"]).set(read_temp(r)) DISK.labels(r["name"]).set(read_disk(r)) time.sleep(int(os.environ.get("PULSE_INTERVAL", "30"))) def main(): rigs = tomllib.load(open(os.environ["PULSE_RIGS_FILE"], "rb"))["rig"] threading.Thread(target=poll, args=(rigs,), daemon=True).start() app.run("0.0.0.0", int(os.environ["PULSE_PORT"])) def read_temp(r): return 0.0 # filled in over ssh in the real thing def read_disk(r): return 0.0 if __name__ == "__main__": main() ``` --- ## pulse/Dockerfile ```dockerfile FROM python:3.12-slim RUN pip install --no-cache-dir prometheus-client==0.21.0 flask==3.0.3 COPY pulse.py /app/pulse.py CMD ["python", "/app/pulse.py"] ``` --- ## scripts/check-disks.sh ```bash #!/usr/bin/env bash # Show disk use on every rig in config/rigs.toml. set -euo pipefail for addr in $(grep address config/rigs.toml | cut -d'"' -f2); do echo "== $addr"; ssh "burrow@$addr" df -h -x tmpfs -x overlay done ``` ## scripts/verify-stash.sh ```bash #!/usr/bin/env bash # Check that the newest stash of a rig is readable and not empty. # usage: scripts/verify-stash.sh set -euo pipefail rig="${1:?rig}" latest="$(ls -1d /srv/burrow/stashes/"$rig"/* | tail -n1)" find "$latest" -type f | head -n 5 du -sh "$latest" ``` ## scripts/reboot-rig.sh ```bash #!/usr/bin/env bash # Reboot one rig after a 10 second countdown. set -euo pipefail addr="${1:?address}" for i in $(seq 10 -1 1); do printf '%s ' "$i"; sleep 1; done; echo ssh "burrow@$addr" sudo systemctl reboot ``` (The other 22 scripts in `scripts/` are similar one-screen shell helpers.) --- ## docs/setup.md ```markdown # Setting up burrow 1. Make a `burrow` user on every rig and put the public half of `BURROW_SSH_KEY` in its `~/.ssh/authorized_keys`. 2. On the rig that will run burrow, `make install`, then `make install-unit` to start the porch under systemd. 3. Install the chores crontab: `crontab deploy/burrow.crontab`. 4. Optional: `make up` for the metrics containers. Check it worked: `burrow rigs` should list every rig as `up`, and http://127.0.0.1:8470/healthz should return `{"ok": true}`. ``` --- ## docs/adding-a-rig.md ```markdown # Adding a rig Add a `[[rig]]` block to `config/rigs.toml`: [[rig]] name = "shed" address = "192.168.1.23" mac = "aa:bb:cc:dd:ee:ff" paths = ["/etc"] Then run `burrow rigs` to see that it answers, and `burrow stash shed` to take its first stash. The porch picks it up on its next refresh. ``` --- ## docs/chores.md ```markdown # Chores A chore is a named job registered in `burrow/chores.py`. Two ship with burrow: - `nightly-stash`: a stash of every rig, at 02:15. - `disk-check`: warns when any rig's root disk is over 85%, every 6 hours. See them with `burrow chores list`. Run one by hand with `burrow chores run disk-check`. See when each last ran, and whether it worked, with `burrow chores status`, or on the porch at http://127.0.0.1:8470/chores. The last-run record is kept in `/srv/burrow/state/last-run.json`. ``` --- ## docs/internals/stash/format/restore-by-hand.md ```markdown # Restoring a stash by hand If burrow itself is broken, a stash is still just a folder: /srv/burrow/stashes/// Copy it back with rsync, as root on the rig: rsync -aHAX --numeric-ids /srv/burrow/stashes/attic/2026-10-20/etc/ /etc/ The `.burrow-manifest` file at the top of each stash lists the paths and the rsync exit code of the run that made it. A non-zero code means the stash is partial. ``` --- ## tests/test_chores.py ```python from burrow import chores def test_registry_has_builtin_chores(): assert {"nightly-stash", "disk-check"} <= set(chores.REGISTRY) def test_record_writes_state(tmp_path): cfg = {"chores": {"state_file": str(tmp_path / "last-run.json")}} chores._record(cfg, "disk-check", 0, "ok") assert chores.last_runs(cfg)["disk-check"]["result"] == "ok" ```