Follow this skill: --- 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. 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. ## How to think about it Every convention here is a first bet, not a rule. What decides is what this project's CLI helps its user do, and what keeps everything easy to reach. A convention that does not serve that project gets changed, and the plan 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 help is a map, not an index. Every command has the form ` … `, each noun one screen deeper, and `help` may follow the last one: ` … 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 a part of a breadcrumb. ` help` is the case with no noun: the table of contents, naming the few "things" the project has so that it does not grow into an unwieldy 50-item list however large the project gets. It has no path to show, so it opens with one plain-English sentence and then the table. Every deeper `… help` opens with the breadcrumb of its nouns, then one sentence, then the menu, so that deep in the tree you still know where you are: ``` codebot › guests › guest1 › functions The functions of codebot's guest1. ``` The user, 2026-10-26: "In this situation I might want to be presented with a content of menu, and also a plain english explanation what i'm looking at". A bare noun (`codebot guests`, no `help`) prints that thing's screen. Each row of a help menu usually has three columns: | Column | What goes in it | | --- | --- | | Name | the thing, in the user's words | | Its technical form | what it is on the machine, 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 | one plain-English line that starts with the verb for what running or clicking it does, then the noun it acts on. `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." | The verb tells the user what happens before they type: does it start a server, print something, open a file? It is the verb of that exact command, so a group's own row says what its bare noun does (usually prints its screen), never what one of its actions does. Take it from the verbs the software industry already uses: opens, prints, lists, starts, stops, restarts, tails, runs, deploys, edits. When none fits, use the plainest one that does, and the plan says why. Take the noun from the project's own words (its README, its code), never a word you invented. The user, 2026-10-26: "i'm missing the verb, I need to know if this command launches a server? prints? and so on". The sentence under a breadcrumb names a place, so it is a noun phrase and needs no verb. Add, drop or rename columns when the project calls for it (opencode's profile table has a column per profile). Every screen (every bare noun command that prints) opens the same way: where you are and what you are looking at, a breadcrumb line, then one plain-English sentence, then its table. Screens can nest, so the breadcrumb shows the whole path, and each part is the command that opens that screen (`help` is never a part of it): ``` codebot › qa The throwaway CodeBot on port 3506, where every change is tried before it reaches you. ``` 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." Piped, the same two lines lead the markdown. When a list gets reasonably too long, put it behind an earlier screen with fewer options. Deciding where to cut is abstract, high-stakes reasoning, so avoid it unless you really have to, and when you do, ask the user before you split. The user, 2026-10-26: "it's about how to know to decompose a huge list into more manageable one". ## 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. **Write the real-world scenario first** (`conceptual-modeling`): who reaches for this CLI, at what moment, to do what (deploy, find a log, open a config, see state). Label it reasoning from experience. 3. **Inventory every thing** a user would want to reach: commands and scripts, files and folders, URLs and dashboards, services and ports, live state (current profile, running jobs). Each with its technical form and where it is defined. Commands that already exist count; never write a second one for the same job (the duplicate-function rule: grep `~/.zsh.d`, the project, and `whence -a ` in zsh and bash). 4. **Group and name.** Group by what the user is trying to do, not by file type. 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. 5. **Write the plan** in the project's `docs/plans/-cli/plan.md` and open it with `review-open`: the scenario, the inventory, 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. 6. **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, 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. 7. **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`. 8. **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. 9. **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 6 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 9 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 6). ## Before you say it is done - ` 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; every row says what it is for in plain English, starting with the verb for what it does. - Every screen starts with its breadcrumb and its one-sentence "what you are looking at". - `help` after every noun prints its menu; `help` is never a 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 5: what the two examples do, where, and why | | `references/cli-best-practices.md` | step 4 and step 7: 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" ```