Metadata-Version: 2.5
Name: tnkr
Version: 0.1.1
Summary: Get tnkr software onto machines: Tnkr Studio on your computer, robot runtimes on your robot.
License: Proprietary
Classifier: Environment :: Console
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.12
Requires-Dist: rich>=13
Requires-Dist: typer>=0.12
Description-Content-Type: text/markdown

# tnkr

One command-line tool for getting tnkr software onto machines: Tnkr Studio on your
computer, robot runtimes on your robot.

You do not install this. It arrives with Tnkr Studio, which is the only door:

```bash
curl -fsSL https://studio.tnkr.ai/install.sh | sh   # Studio + this CLI, one line

tnkr install openduck-mini                   # the Open Duck Mini runtime, on your Pi
tnkr upgrade                                 # everything installed, to latest
tnkr status                                  # what's installed, what version, what's reachable
tnkr doctor                                  # why isn't it working
```

> **Status: not built yet.** This README is the spec. Nothing in this directory is
> implemented.

---

## What this is for

Today a user installing the Open Duck Mini runtime has to flash an SD card, find the
Pi's IP address (`ifconfig`, on the robot), SSH in, paste a URL to a 986-line bash
script, and — when something fails — SSH back in to read `~/.tnkr-setup/setup.log`.
The dashboard's setup wizard exists largely to walk people through that.

The installers themselves are good. Getting *to* them is the problem, and the problem
is that the two things you install live on two different machines:

| Thing | Installs with | Runs on |
|---|---|---|
| Tnkr Studio | `curl -fsSL https://studio.tnkr.ai/install.sh \| sh` → `uv tool install --python 3.12 tnkr-studio` | the operator's computer |
| Open Duck Mini runtime | `curl -sSL .../Open_Duck_Mini_Runtime/v2/scripts/setup.sh \| bash` | the Pi, over SSH |

`tnkr` runs on the operator's computer and reaches across to the robot for them. It
opens the SSH connection, runs the existing installer, and streams its progress back to
the terminal you're already looking at.

That's the whole value. `tnkr install studio` being nicer than `uv tool install
tnkr-studio` is a bonus, not the point.

---

## Scope: what belongs here, and what doesn't

**The rule:**

> If a command needs a **live robot session**, it belongs in Studio.
> If it needs a **machine to be provisioned**, it belongs here.

| In scope | Out of scope (this is Studio's job) |
|---|---|
| `install`, `upgrade`, `uninstall` | connect, calibrate, teleop |
| `status`, `doctor`, `logs` | walk, record, replay |
| service start / stop / restart | upload, datasets, data pools |
| reaching a named robot over SSH | accounts, sign-in |

This boundary is deliberate and it is the thing most likely to erode. Every one of the
right-hand commands would be easy to add — the endpoints are sitting right there in
`Open_Duck_Mini_Runtime/scripts/tnkr_server.py`. Each one added is a reason for a user
to never open Studio. Studio is the product; this is plumbing that gets people to it.

**Successful commands hand off.** The last line of `tnkr install openduck-mini` is
"Open Studio to connect your duck", not a menu of more CLI commands.

The one command that looks like it crosses the line but doesn't: `tnkr doctor`. Pulling
`setup.log` and systemd state back over SSH is precisely what Studio *can't* do, because
Studio needs a working robot to be useful and `doctor` exists for when there isn't one.

### No `tnkr login`

There is no sign-in here, and this is not a gap to be filled later.

The install path is already credential-free. `scripts/setup.sh` carries exactly one
secret, a write-only PostHog ingestion key, and no session token or Supabase
credentials. Identity reaches the robot afterwards, from the signed-in side:
`POST /api/walk/start` in `tnkr_server.py` accepts `sessionToken`, `supabaseUrl` and
`supabaseKey`. The robot is anonymous until a logged-in surface hands it a session.

Adding a login here would not unlock a missing capability. It would create a second
identity path competing with one that already works, and give people a reason to stay
in the terminal instead of opening Studio.

---

## Command surface

Verb first, flat targets. One shape, never bent:

```
tnkr <verb> <target>
```

```bash
tnkr install openduck-mini                       # asks for the host, once, then remembers
tnkr install openduck-mini --host duck.local     # the hostname you set in Pi Imager
tnkr install openduck-mini --host 192.168.1.42   # or an address, where mDNS won't resolve
tnkr install openduck-mini --clean               # pristine reinstall; DELETES duck_config.json — confirms first
tnkr install openduck-mini --clean --yes         # same, without the confirmation, for scripts
tnkr install openduck-mini --setup-ref v2.3.1    # pin the installer; defaults to the registry's ref
tnkr install studio                              # repair/reinstall only; Studio's installer is the door
tnkr install studio --version 0.4.0              # version is a flag, never part of the name

tnkr upgrade                    # everything installed
tnkr upgrade studio

tnkr status                     # installed targets + versions, robot reachable?, service up?
tnkr doctor                     # diagnose, pull remote logs back here
tnkr logs openduck-mini         # tail the tnkr-robot systemd unit over SSH
tnkr restart openduck-mini

tnkr studio                     # launch Studio
tnkr uninstall studio
```

Naming rules that hold everywhere:

- **`--host` names the robot, and it is optional because we ask.** There is no network
  discovery — no mDNS browse, no ARP scan. The Pi answers to whatever hostname was set
  when the card was flashed, so there is no constant to hardcode. Resolution order:

  1. `--host` if given — always wins
  2. otherwise the remembered host (see below), so repeat commands need no argument
  3. otherwise **prompt with an empty field and an example beside it**
  4. non-interactive with nothing remembered → fail asking for `--host`, never guess

  **The ladder has two arities, because the commands do.** `install`, `logs`, `restart` and
  `doctor` act on one robot, so an ambiguous state is an error and step 3 asks which.
  `status` and `upgrade` act on *everything installed* — that is what lines 105 and 108 above
  promise — so for them two entries is the normal case, not a question. One resolver, two
  entry points: `resolve_one` prompts on ambiguity, `resolve_all` returns the set, narrowed
  by a target or `--host` when either is given. Without this split the two most casual
  commands in the surface either interrogate you or guess, and the second is forbidden.

  **Unparsed is not unchecked.** The destination goes to `ssh` verbatim so aliases keep
  working, but `ssh` reads its own argv: a value starting with `-` is an *option*, and
  `-oProxyCommand=…` executes on the laptop. Reject destinations that begin with `-` or
  contain control characters or whitespace, and reject them on the way *out* of
  `robots.json` too — that file is an input the operator never typed.

  **One string cannot be both an SSH destination and an HTTP authority.** `pi@duck.local`,
  a bare IPv6 address and a `~/.ssh/config` alias are all valid destinations and none of
  them is a usable URL host. `ssh -G <dest>` prints what `ssh` itself resolved — `hostname`,
  `port`, `user` — with no network I/O, so resolution records those alongside the raw
  destination. The health URL and the `doctor` ladder's DNS and TCP rungs use the resolved
  values; `ssh` keeps getting the original.

  ```
  Which host is your duck on?
    The hostname you set in Raspberry Pi Imager. An untouched
    image answers to raspberrypi.local.

    host: _
  ```

  **The field starts empty on purpose.** Studio settled this already and enforces it:
  `duckSetup.ts:12` — *"Never assume a hostname: it is whatever they chose when flashing"* —
  and `VerifyStep.test.tsx:367` asserts the input renders empty. A pre-filled value that
  is wrong looks authoritative, so the operator debugs the hostname they actually chose
  correctly. An example teaches the same idea without asserting one, and it is asked once.

  > Three values disagree across one product today: this prompt's example
  > (`raspberrypi.local`), `robots/openduck_mini/driver.py:39`
  > (`DEFAULT_HOSTNAME = "openduck.local"`), and Studio's UI (refuses to prefill).
  > Reconciling `driver.py:39` is Studio's call, not this repo's, but it should not
  > stay a third answer.
- **The target is the robot, not the software.** `openduck-mini`, not
  `open-duck-v2-runtime`. Nobody owns a runtime; they own a duck. Installing a robot
  target means putting its runtime on the Pi.
- **No version in the name.** `--version 2`, never `-v2` baked into a target, or you
  ship `openduck-mini-v3` as a separate target forever.
- **Don't stutter.** The tool is `tnkr`, so the target is `studio`, not `tnkr-studio`.

### The robot registry: one table, not one hardcoded robot

**Nothing in this CLI hardcodes the Open Duck.** Studio already ships five robots
(`trlc_studio/robots/`: `dk1`, `dk1_bi`, `openduck_mini`, `so100`, `so101`) and will ship
more. Every robot-facing value below is a registry lookup keyed on the target.

The split that matters is not which robots exist, it is **which robots have a machine to
provision** — the same rule that defines this whole tool:

| Target | Provisioning model | This CLI |
|---|---|---|
| `openduck-mini` | Onboard Pi, its own runtime + systemd unit, reached over the network | installs it |
| `dk1`, `dk1_bi`, `so100`, `so101` | USB-attached, driven directly by Studio | **nothing to install** |

That is visible in Studio's own layout: `openduck_mini/` is the only robot with an
`agent_client.py` and a `sim_agent.py`, because it is the only one that is a *host on the
network*. The others are `driver.py` + `manifest.py` — a cable and a protocol.

So `tnkr install so101` must not fail with an unknown-target error. It should answer the
question the operator actually asked:

```
SO-101 connects over USB — there is nothing to install on it.
Open Studio and plug it in.
```

A registry entry is what makes a robot installable. Today one robot has one; the second
is a data entry, not a refactor:

```yaml
openduck-mini:
  setup_repo:   tnkrai/Open_Duck_Mini_Runtime
  setup_ref:    v2                         # a BRANCH — see the note below
  setup_path:   scripts/setup.sh
  service:      tnkr-robot                 # setup.sh:26
  install_dir:  ~/Open_Duck_Mini_Runtime   # setup.sh:24, on the robot
  state_dir:    ~/.tnkr-setup              # setup.sh:28, on the robot
  log_file:     ~/.tnkr-setup/setup.log    # setup.sh:29, on the robot
  health:       http://{host}:8000/api/health   # port tnkr_server.py:52, route :481
  consent_file: ~/.tnkr-telemetry.json     # see Telemetry
```

Every value is **transcribed** from an existing repo — none is invented. A second robot
supplies its own entry and no code changes.

> **Transcribed, not read, and that is a coupling worth naming.** `service`,
> `install_dir`, `state_dir` and `log_file` are shell constants at `setup.sh:24-29` in a
> *different repo* on a *mutable branch*. Rename `SERVICE_NAME` there and `logs`,
> `restart` and `doctor` break on already-installed CLIs with no release in between.
> Argv-level tests structurally cannot catch this, because they assert the same string
> that drifted. **A contract test must fetch `setup.sh` at the pinned ref and assert the
> registry still agrees with it.** That test is the only thing standing between a rename
> and a silent break.

> **`setup_ref` is a branch, deliberately, with instruments.** Tracking tip-of-`v2` means
> a breaking change reaches every installed CLI with no release gate. That is on purpose:
> the published `curl | bash` one-liner already behaves this way, and diverging would give
> one robot two different installers. To make it survivable rather than merely consistent:
>
> - resolve `setup_ref` to a **commit SHA** and record it, plus a **sha256 of the fetched
>   script**, into `robots.json` and the install telemetry event
> - `--setup-ref <tag|sha>` overrides per invocation, which is the rollback lever
>
> Without the recorded SHA, "step 7 fails on Pi Zero 2W" is unanswerable, because two
> reports of "step 7" may be two different scripts.

### What each command actually runs

`$HOST` resolves per the rules above; every other capitalised value is a registry lookup.

| Command | What it does | What it runs |
|---|---|---|
| `tnkr install <robot>` | Provisions the robot's own machine | `ssh -t $HOST 'f=$(mktemp); curl -fsSL $SETUP_URL -o "$f" && bash "$f" --from-cli; rc=$?; rm -f "$f"; exit $rc'` |
| `tnkr install <robot> --clean` | Pristine reinstall | same, with `--clean` **first** in the script's arguments |
| `tnkr upgrade <robot>` | Updates the runtime in place | identical to install — `setup.sh` self-detects and takes its update path |
| `tnkr install studio` | Repair/reinstall Studio locally | `uv tool install --python 3.12 --force tnkr-studio` |
| `tnkr install studio --version X` | Pins a version | `uv tool install --python 3.12 --force tnkr-studio==X` |
| `tnkr upgrade studio` | Latest Studio | `uv tool upgrade tnkr-studio` |
| `tnkr uninstall studio` | Removes Studio | `uv tool uninstall tnkr-studio` |
| `tnkr studio` | Launches Studio | `exec tnkr-studio` |
| `tnkr status` | What's installed, what's reachable | local: `uv tool list`; robot: `GET $HEALTH` |
| `tnkr doctor` | Diagnose a robot that won't come up | the connectivity ladder below, then `cat $LOG_FILE`, `systemctl status $SERVICE --no-pager`, `journalctl -u $SERVICE -n 200 --no-pager` |
| `tnkr logs <robot>` | Tail the unit from the laptop | `ssh -t $HOST 'journalctl -u $SERVICE -f'` |
| `tnkr restart <robot>` | Bounce the unit | `ssh -t $HOST 'sudo systemctl restart $SERVICE'` |

> **It is not `curl … | bash`, and that is deliberate.** A pipeline exits with the status of
> its *last* stage, so a `curl` that 404s hands `bash` an empty stdin, `bash` succeeds at
> running nothing, and the CLI records a successful install of a script that never executed.
> A mistyped `--setup-ref` is enough to trigger it. Fetching to a file and running it only on
> a successful download makes the failure real, gives T8 somewhere to verify the sha256
> *before* execution rather than after, and puts `--clean` in `$1` where `setup.sh:787` looks
> for it — one change that closes three separate holes.

> **`uv tool list` is not the only way Studio gets installed.** `tnkr-studio` also ships
> its own root `install.sh`, which does `uv pip install -e .` into `server/.venv` — the
> path contributors and anyone who cloned the repo actually use. Detecting only
> `uv tool list` reports "Studio not installed" to the people most likely to run
> `tnkr status`. Check both.

**Exactly one HTTP call per robot, and it is a health check.** The Open Duck's
`tnkr_server.py` exposes 31 endpoints. The CLI uses `GET /api/health` and nothing else:

```json
{"status":"ok","is_pi":true,"platform":"aarch64",
 "walking":false,"paused":false,"walkExitCode":null}
```

That single endpoint answers everything `status` needs — process alive, real hardware or
not, currently busy or not — with no auth and no state change. The other 30 (`/api/walk/*`,
`/api/calibration/*`, `/api/rehome/*`, `/api/stance/*`, `/api/head/*`, `/api/imu/*`,
`/api/motors/check`, `/api/config`, `/api/voltage`, `/api/commands`, `/api/state`) are
Studio's, every one of them a live-robot-session call.

**The rule generalises: one health URL per robot, and it is the only URL in the entry.**
If a registry entry ever grows a second endpoint, the scope rule has been broken.

**But that tripwire only watches one door.** Erosion is at least as likely to arrive as
`ssh $HOST 'python scripts/check_motors.py'`, which needs no registry change and trips
nothing. The second tripwire: **the CLI never runs a script from `INSTALL_DIR`.** It runs
`setup.sh`, `systemctl`, `journalctl`, and `cat` on a log. Anything reaching into the
runtime's own scripts is a live-robot-session command wearing a disguise.

**And the sharpest version of the rule is not ours.**
`tnkr-studio/docs/designs/wired-physical-agents-plan.md:171` states it better than the
mechanical checks above:

> the CLI may restore a robot to a working state; it may never choose what the robot does

`tnkr rollback openduck-mini` passes. `tnkr install walk-v2` does not, because choosing
which policy a robot runs is a catalogue decision and catalogue decisions are Studio's.
Use this as the test when a new verb is proposed; the two tripwires above are how you
check it mechanically.

### `tnkr doctor`: a ladder, not a login

`doctor` exists for a robot that is not working, and Studio routes people here
specifically when it *cannot reach* one (README's discovery section). So `doctor` must
not open with SSH, because the three most common causes — Pi powered off, wrong network,
wrong hostname — all fail at the transport and produce a raw `ssh:` error that teaches
nothing. Run cheap local checks first, stop at the first rung that fails, and give each
rung its own next action:

```
1. host known?          no  -> "No robot registered. Run: tnkr install openduck-mini"
2. resolves?            no  -> "duck.local won't resolve. Same network? Try --host <ip>."
3. tcp 22 open?         no  -> "Host is up, SSH is shut. Enable it in raspi-config."
4. host key matches?    no  -> "Host key changed — expected after re-flashing the card.
                                Fix: ssh-keygen -R duck.local, then re-run."
5. ssh auth ok?         no  -> "Reached it, key rejected. Try: ssh-copy-id <user>@<host>"
6. GET $HEALTH ok?      no  -> "Logged in, service is down." + journalctl tail
7. all green                -> pull setup.log, systemctl status, journalctl
```

**Rung 4 is not hypothetical.** Re-flashing the SD card is the most common recovery
action in this whole workflow, and it regenerates the Pi's host key. Every later command
then hard-fails with `REMOTE HOST IDENTIFICATION HAS CHANGED`, and `doctor` is the command
most likely to be run at that exact moment. Without this rung, the tool's diagnostic
command is broken by the tool's most common fix.

### One SSH runner, and an exit-code contract

Five commands reach the robot (`install`, `upgrade`, `doctor`, `logs`, `restart`). **All
five build their command line in exactly one module.** That is not tidiness: every trap in
this document lives in those flags — `-t` must always be present, `TNKR_TELEMETRY` must be
sent only on a first install, `--from-cli` and `--setup-ref` have to be threaded through —
and the test strategy below asserts on "the argv the CLI produces," which requires exactly
one thing to be producing it.

```
exit 0    success
exit 64   usage error, unknown target, bad flag      (sysexits EX_USAGE)
exit 65   the installer did not match its digest     (sysexits EX_DATAERR)
          <- raised ON THE ROBOT, before execution
exit 69   could not reach the robot                  (sysexits EX_UNAVAILABLE)
          <- decided by the ladder, never a bare ssh 255
exit 70   reached it; the runtime is not serving     (sysexits EX_SOFTWARE)
          <- doctor rung 6: a different remedy to 69
exit N    the remote command's own exit code, propagated unchanged
```

> **The obvious codes were 1 and 2, and they cannot work.** Click — under Typer — exits
> **2** on any usage error, so `tnkr --bogus` reported "could not reach the robot" before a
> single line of robot code existed. And propagating the remote code unchanged means
> `setup.sh` exiting 1 or 2 lands on the same two numbers, which makes the contract
> unusable by the scripted callers it exists for. The CLI's own codes therefore live in the
> `sysexits.h` range, where nothing else in this stack emits: 64 for our usage errors, 69
> for unreachable, everything else the robot's. Click's own exit code is overridden to 64
> to match. These are a breaking surface per `CHANGELOG.md`, so they are settled now rather
> than after v1.

`ssh` returns 255 both for its own failures and for a remote command that happened to
exit 255, so the code alone cannot tell "could not connect" from "the installer failed
oddly." The ladder resolves that before the code is interpreted. Every call sets a connect
timeout; the long install itself gets no wall-clock cap. On a non-zero install exit, print
the thing the operator needs and does not know: **`setup.sh` is resumable, re-running
continues from where it stopped** (`setup.sh:375`).

### The CLI has to remember the host

This is the one thing dropping discovery costs, and it needs building rather than
assuming. `install` gets `--host` from the operator. `status`, `doctor`, `upgrade` and
`logs` are all documented as working with no argument — and with nothing scanning the
network, there is no way to answer "which robot?" unless the last successful install
wrote it down.

So: on a successful `install`, persist the host to `~/.tnkr/robots.json`, and let the
no-argument forms read it. `--host` always overrides. `tnkr status` with an empty file
says so plainly and names the install command, rather than failing at a resolver.

```json
{
  "version": 1,
  "cli": { "run_count": 3, "last_seen_version": "0.1.0" },
  "robots": {
    "pi@duck.local": {
      "target": "openduck-mini",
      "destination": "pi@duck.local",
      "ssh_hostname": "duck.local",
      "ssh_port": 22,
      "setup_ref": "v2",
      "setup_sha": "a1b2c3d",
      "script_sha256": "9f86d081…",
      "installed_at": "2026-08-16T09:14:22Z",
      "ssh_copy_id_declined": false
    }
  }
}
```

**Keyed by destination, not by target.** The first draft of this example nested a list of
entries under each target, and that shape is what makes the upsert rule a rule you can
break: with a list, "reinstall the duck at `duck.local`" is an append unless something
remembers not to, and the reward for forgetting is that `resolve_one` asks the operator to
choose between a robot and itself. Keyed by destination, appending is not expressible. The
target moves onto the entry, which is where it was always being read from — "every robot of
this target" is a filter over one flat map, and `resolve_all` wants exactly that.

The key is the destination normalised: trimmed, with the **host part** lowercased and the
user left alone. Hostnames are case-insensitive and usernames are not, so `pi@duck.local`
and `Pi@duck.local` are two accounts on one machine and must stay two entries, while
`Duck.local` and `duck.local` are one robot and must not.

**Everything promised gets a slot before version 1 ships.** `cli` holds laptop-side state
that belongs to no robot — the banner's run counter, and `last_seen_version` for T24 —
which keeps `robots` a clean map a fleet feature could iterate. `ssh_copy_id_declined` is
per destination, so it lives on the entry that names one; the offer is made once, a decline
is never re-asked, and a reinstall carries the answer forward rather than resetting it.
`ssh_hostname` and `ssh_port` are what `ssh -G` resolved, cached so `status` need not
re-derive them. Adding any of these later is a version bump, and the rule below makes a
version bump expensive on purpose.

Rules, because five commands read this and two write it:

- **Atomic writes, and a lock that survives them.** Write a temp file **in `~/.tnkr/`** and
  `os.replace` it — the same directory, or the rename is not atomic across filesystems. Hold
  an exclusive `fcntl.flock` on a **sidecar `robots.lock`** across the whole read-modify-write.
  Locking `robots.json` itself does nothing, because `os.replace` swaps the inode the lock is
  held on and two processes end up locking different files by the same name. Atomic replace
  protects *readers*; the lock is what protects *writers*, and the README tells operators to
  run this tool in a second terminal while Studio downloads, so two `tnkr` processes at once
  is a designed flow rather than an edge case.
- **Corrupt is not fatal, and it is not overwritten either.** Only an *absent* file means
  empty. An unparseable one is renamed to `robots.json.corrupt.<timestamp>`, warned about
  once by name, and the CLI continues empty — nothing is destroyed and nobody is dead-ended.
  This matters more now that the run counter lives here: without it, one `tnkr status` on a
  truncated file would permanently replace every registered robot with nothing. A permission
  error cannot be renamed, so that case refuses writes instead. Never a traceback; "delete
  this file you have never heard of" is not a recovery path.
- **A newer file has an owner; refuse to write it.** A `version` above 1 warns once, reads as
  empty, and blocks every write with "written by a newer tnkr — upgrade: `uv tool upgrade
  tnkr`". Treating it as corruption would silently destroy the data of a CLI that is still in
  use, and with `T16` (self-upgrade) deferred, two versions on one machine is an accepted
  state of the world.
- **A list per target, and installs upsert into it.** Two entries means bare *single-robot*
  commands ask which rather than picking one; `status` and `upgrade` fan out instead. Match
  on the destination with the hostname portion trimmed and lowercased: a match refreshes
  `setup_sha`, `script_sha256` and `installed_at` in place. Appending instead would mean
  reinstalling your one duck manufactures the ambiguity prompt that exists for owning two.
- **Mode 0600, set explicitly** on the temp file before the replace. `mkstemp` gives 0600 and
  `write_text` gives 0644, so without a `chmod` the mode depends on which write path ran last.
  The file maps hostnames and a login name on your network; Studio already treats its own
  state this way.
- **`$TNKR_HOME` overrides the directory**, defaulting to `~/.tnkr`. It is the only seam that
  survives `run_cli`'s subprocess boundary, which is why `tests/conftest.py`'s `tnkr_home`
  fixture already sets it.
- **`setup_sha` and `script_sha256`** come from the resolution described in the registry
  note above. They are what make a failure report reproducible.

This is a small file, but it is load-bearing for five of the eleven commands, and it is
the piece most likely to be discovered late.

### The SSH model, and where "inherit the operator's setup" runs out

Using the system `ssh` binary means `~/.ssh/config`, agents and keys work with no
configuration. That is right, and it is not the whole story, because a brand-new duck
owner has none of those things.

- **Which user?** Raspberry Pi OS has had no default `pi` account since the Imager started
  forcing account creation, so there is no safe constant. `--host` accepts `[user@]host`
  and, absent a user, hands the bare hostname to `ssh` so `~/.ssh/config` decides.
- **No key, so a password every time.** Pi Imager users get password auth. `install`
  prompts once (fine, with `-t`), and then `status`, `doctor`, `logs` and `restart` each
  prompt again. Four commands documented as zero-argument conveniences that cost a
  password each is not a convenience. **After the first successful install, offer to
  `ssh-copy-id`** — one prompt, once, and every later command is silent. Decline is
  remembered and never re-asked.
- **Two connections per install, so two password prompts.** The telemetry rule below
  requires reading the robot's consent file *before* the install connection. Without a
  key, that is two prompts before anything visible happens. Either reuse one connection
  (`ControlMaster`/`ControlPersist` for the duration of the command) or fold the probe
  into the install connection.
- **Host key churn is a designed-in failure.** Re-flashing the card regenerates the Pi's
  host key, and re-flashing is the standard recovery step. Handled as rung 4 of the
  `doctor` ladder; the SSH runner must surface it as that specific case, not as a generic
  auth failure.
- **First-connect TOFU.** The unexplained fingerprint question appears mid-install. Say
  what it is before `ssh` asks, or the operator's first experience of the tool is a
  security prompt they cannot evaluate.
- **`restart` needs `sudo`.** `setup.sh` already assumes working sudo, so this is not new,
  but the password prompt only works with `-t`. Another reason it is not optional.

---

## How you get it

**There is one door, and it is Studio's.** `curl -fsSL https://studio.tnkr.ai/install.sh
| sh` installs Studio and this CLI together. Nobody is ever asked to install a CLI, and
there is no bootstrap step in front of the product.

This holds even for someone who bought a duck and has no arm. They want the duck
connected to Studio in the end, and they need `tnkr` to get the runtime onto the Pi, so
they need both either way. Two doors would only make them choose between things they
both need.

`tnkr-app-dashboard/public/install.sh` grows one line to do it. Three details in that
file decide where the line goes and what it says:

```sh
# ... existing uv bootstrap ...
export PATH="$HOME/.local/bin:$PATH"     # ALREADY HERE. The new line goes AFTER it.

uv tool install --python 3.12 tnkr || \
  echo "tnkr CLI unavailable, continuing with Studio." >&2   # never abort Studio

uv tool install --python 3.12 --force tnkr-studio
exec tnkr-studio
```

- **After the uv bootstrap and after the PATH export, not first in the file.** `uv` is
  installed by the script itself, and `export PATH="$HOME/.local/bin:$PATH"` comes after
  that block. A line above either one runs before `uv` exists or leaves the `tnkr` shim
  off PATH, which is exactly what the banner below advertises.
- **`--python 3.12`, for the same reason Studio needs it.** `uv tool install` resolves
  against the first interpreter it discovers, which on macOS is the system 3.9.
  `tnkr-studio/docs/DISTRIBUTION.md` calls this flag load-bearing; there is no reason the
  CLI is exempt.
- **Never abort Studio.** The script is `set -eu`. Unguarded, a PyPI hiccup or a yanked
  release of the newest, least-proven package in the stack takes down the Studio install
  for everyone. `|| echo` degrades to a warning; Studio is the product and must survive
  the CLI being unavailable.

**The order is still load-bearing.** Installing `tnkr` first (of the two tools) means it
is on PATH within seconds, while Studio's ~1.5 GB is still resolving. A duck owner can
open a second terminal and start `tnkr install openduck-mini` immediately instead of
watching a progress bar. The installer should say so at that moment, rather than leaving
the wait dead:

```
[ok] tnkr CLI ready.
     Setting up a robot? You can start now, in another terminal:
       tnkr install openduck-mini
     Studio is still downloading (~1.5 GB, one time).
```

`tnkr install studio` still exists as a command, for repair and reinstall. It is just
not how anybody arrives.

**There is no second installer URL to build.** `middleware.ts` excludes `install.sh`
from its matcher, so `public/install.sh` already answers on every host — apex
`tnkr.ai/install.sh`, `studio.tnkr.ai/install.sh`, and preview hosts are the same file.
Only the studio host gets marketed.

### A CLI-only install: deferred, not rejected

A standalone `tnkr` install with no Studio is a reasonable thing to want, and we may add
it. We are not adding it now because there is nobody to add it for: every path to the
CLI today runs through someone who wants Studio anyway, so a CLI-only route would be a
second door serving zero people while doubling what we have to explain and support.

Revisit it when a real case shows up:

- **Fleet provisioning** — a workshop or kit assembler setting up ducks in batches, who
  wants `tnkr install openduck-mini` in a loop and never opens a GUI
- **CI or automated provisioning** — a scripted robot bring-up with no operator present
- **A headless or constrained machine** that cannot run Studio but can reach a Pi
- **A robot that doesn't need Studio** to be useful

Deferring costs nothing, and that is the point of the shape above. `tnkr` is its own
lightweight package either way, the installer script already answers on every host, and
nothing here depends on Studio at runtime. Adding a CLI-only route later means
publishing one more small script — not unpicking a decision.

### How people find out `tnkr` exists

They are not told up front. Studio names the command at the moment it is the answer:

- User goes to set up a robot → Studio shows `tnkr install openduck-mini`
- Studio cannot reach a robot on the network → "Run `tnkr doctor` to see what the Pi says"

The CLI surfaces when it is useful and stays invisible otherwise. That is the correct
shape for plumbing, and it is what keeps the terminal from becoming the first tnkr
experience.

**This is a dependency, not an aspiration.** The place Studio says it is
`SetupDuckWizard.tsx`, whose steps 3 and 4 currently instruct the operator to SSH in by
hand and paste `SETUP_COMMAND`. Those two steps collapse into one line when this ships.
Until that change lands in `tnkr-studio`, nothing else tells anyone the CLI exists, and
this document explicitly rules out every other channel. **If the Studio change slips, the
CLI ships to an audience of nobody.** It belongs in the release, not after it.

---

## How it works

**It orchestrates the existing installers. It does not reimplement them.**

- `tnkr install studio` → shells out to `uv tool install --python 3.12 tnkr-studio`
  (the `--python 3.12` is load-bearing; see `tnkr-studio/docs/DISTRIBUTION.md`)
- `tnkr install openduck-mini` → runs `Open_Duck_Mini_Runtime/scripts/setup.sh` over SSH
  against the resolved host and relays its output

There are already two installer surfaces. A third that duplicates their logic would
drift from both within a release. This one stays a thin wrapper.

**The installer's output passes through untouched.** We inherit stdio and allocate a
TTY; we do not parse, filter, or re-render what `setup.sh` prints. It is already a
progress UI and a better-informed one than anything we could reconstruct from its own
stdout: `[3/9]` step counters (`setup.sh:272`), a braille spinner (`setup.sh:208-233`),
and check/cross result lines per step (`setup.sh:250-252`). It even probes for a usable
terminal with a real write test and degrades to static lines when there isn't one
(`setup.sh:213-215`) — which is exactly why the `-t` below is load-bearing. Parsing it
would mean rebuilding a worse version of that display, and breaking it every time
somebody adds a step.

**The curl one-liners keep working, forever.** They are printed in docs, referenced by
the dashboard setup wizard, and pasted into messages to testers. That lesson is already
recorded in `tnkr-app-dashboard/app/install/[robot]/route.ts`, where retired per-robot
installer URLs 308-redirect rather than 404 into somebody's shell. Same discipline
applies here: `tnkr install openduck-mini` becomes the *recommended* way to reach
`setup.sh`, never the only one. Anyone already holding the raw curl line keeps working.

---

## What the runtime installs today, and what migrates here

**The Open Duck Mini runtime installs zero commands.** `setup.cfg` ends with:

```ini
[options.entry_points]
console_scripts =
```

Declared and empty. No executable lands on the Pi. Two things carry the tnkr name and
neither is a CLI: the ASCII logo `scripts/setup.sh` prints at startup, and the systemd
service, `tnkr-robot`.

Everything an operator can do today is one of three shapes:

| Shape | Example |
|---|---|
| systemd unit control | `sudo systemctl restart tnkr-robot` |
| bare script, invoked by path | `python scripts/check_motors.py` |
| HTTP endpoint on `tnkr_server.py` (:8000) | `POST /api/walk/start` |

### What migrates

The success banner (`print_success`, `scripts/setup.sh:814`) advertises five commands.
They split cleanly along the scope rule above:

| Group | Migrates? | Becomes |
|---|---|---|
| The systemd five (`status` / `stop` / `start` / `restart`, `journalctl -f`) | **Yes** | `tnkr status`, `tnkr restart openduck-mini`, `tnkr logs openduck-mini` |
| Hardware scripts (`check_motors`, `find_soft_offsets`, `calibrate_imu`, `v2_rl_walk_mujoco`) | **No** | Studio. It already drives these through `tnkr_server.py` endpoints |
| Banner "Next steps" (check motors → calibrate → configure → walk) | Becomes a handoff | "Open Studio to connect your duck" |

The systemd five are worth wrapping, and not because `tnkr logs` is shorter than
`journalctl -u tnkr-robot -f`. It is that today those commands **require you to already
be SSH'd into the Pi**. Wrapping them makes them work from the laptop, which is the
entire premise of this tool.

The hardware scripts are the erosion case. They are live-robot-session commands, Studio
already drives them over HTTP, and each would be easy to add and would give somebody a
reason to stay in the terminal. They stay out.

### Shipped: `setup.sh`'s flag parser and `--from-cli`

> **This landed.** `Open_Duck_Mini_Runtime#20` merged to `v2`: one
> order-independent parser, and `--from-cli` gating `print_success` from inside
> the function rather than at its two call sites, so a third caller cannot forget.
> The section below is kept as the reasoning, not as work outstanding.
>
> Two consequences worth carrying forward. **`--clean` still goes first** in
> `provision._script_args` — `--setup-ref` can pin a pre-T4 script that reads
> `$1` alone, so the order moved from load-bearing to defensive rather than
> stopping mattering. And **the contract tripwires were inverted, not deleted**:
> `TestTheFlagsWeSend` now asserts the parser is present, because a test that
> only ever watched for the landing leaves the thing it watched unguarded
> afterwards.

`setup.sh` prints its success banner **on the Pi** (`print_success`, `setup.sh:814`, called
at `:952` and `:982`). When `tnkr install openduck-mini` relays that output over SSH, the
operator reads "run `sudo systemctl status tnkr-robot`" while sitting at a machine where
that command does nothing.

**The fix is `--from-cli`, and it is not the CLI's job to suppress the tail.** Suppressing
a tail means reading the stream to find where it starts, which is stream parsing, which
contradicts the inherited-stdio decision above. You cannot filter output you are
deliberately not reading.

**`--from-cli` could not be added until the flag parser was fixed.** `setup.sh` used to
parse two ways, and only one of them worked:

```sh
for arg in "$@"; do case "$arg" in --test) … ;; esac; done   # positional-independent
if [ "${1:-}" = "--clean" ]                                  # FIRST ARGUMENT ONLY
```

So `bash -s -- --from-cli --clean` put `--from-cli` in `$1`, the `--clean` check failed,
and a user who asked for a pristine reinstall silently got a resumed one. That was not
hypothetical: `--clean` is the flag Studio's own documented command uses
(`duckSetup.ts:9`), while this CLI's plan did not originally mention it exists.

The parser was fixed first, then the flag added:

```sh
for arg in "$@"; do
  case "$arg" in
    --test)     TEST_MODE=true ;;
    --clean)    CLEAN_INSTALL=true ;;   # was positional; now works anywhere
    --from-cli) FROM_CLI=true ;;        # skip print_success, caller owns the ending
  esac
done
```

`tnkr install <robot> --clean` is now expressible, and both surfaces of the product mean
the same thing by the same flag.

> **Unknown flags degrade silently, in both directions.** An old `setup.sh` ignores
> `--from-cli` and prints the banner anyway — safe. But an operator pinning an older
> script with `--setup-ref` gets the same silent degradation with no warning. The CLI
> should compare the resolved ref against the ref where `--from-cli` landed and say so.

### Two things that fall out of this

- **`tnkr upgrade openduck-mini` needs no new mechanism.** Re-running `setup.sh` on a
  finished install already takes an update path ("Already set up — updating instead"):
  it skips the system steps, git-pulls the runtime, and restarts the server.
- **`.local` resolution is assumed by the runtime, which is not the same as assumed to
  work here.** The success banner builds its URL as `http://$(hostname).local:$SERVER_PORT`
  (`setup.sh:816`), but that is the Pi resolving *its own* name for a string it prints.
  Whether the **operator's** machine resolves `.local` is a separate question: true on
  macOS, not on Linux without avahi. This is why `--host` accepts an address, why the
  prompt asserts nothing, and why `duck.local` in the examples above is one plausible
  value rather than the value. A remembered `.local` host can also stop resolving when
  the operator changes networks, which is rung 2 of the `doctor` ladder.
- **`tnkr upgrade` may owe more than a re-run.** `tnkr-studio/docs/designs/wired-physical-agents-plan.md:174`
  notes that upgrading the runtime can invalidate the contract of an already-installed
  policy component. This document specifies `upgrade` as "identical to install", which has
  no concept of that. The two plans need reconciling before either ships. That doc also
  carves out a twelfth verb, `tnkr rollback openduck-mini` (`:162-169`), which is not in
  this command surface.

---

## Telemetry

The runtime already has telemetry and this CLI joins it rather than starting a second
system: same PostHog project, same event stream, same privacy contract. See
`Open_Duck_Mini_Runtime/mini_bdx_runtime/mini_bdx_runtime/telemetry.py` — rate-capped, fail-silent,
scrubs home paths and usernames, consent in `~/.tnkr-telemetry.json`, contract documented
in that repo's README. What it cannot share is the *device id*, for a mechanical reason
covered below.

Two tiers, kept separate:

- **Fleet and setup health** — anonymous, no identity. Answers "step 7 fails on Pi Zero
  2W with a CH343 adapter". Identity adds nothing here; the published privacy promise is
  worth more than the attribution. CLI events go into this stream.
- **Per-user attribution** — attached at the Studio seam, never here. `telemetry.py`
  currently only calls `capture(distinct_id=device_id())`; there is no `identify` or
  `alias` anywhere. When a signed-in Studio session first touches a robot, aliasing that
  device UUID to the user id makes every prior anonymous setup event attributable
  retroactively — no login prompt during install.

That alias is a change to a published privacy contract, not just plumbing. It should
only be made for users who chose to sign in, and the runtime README's telemetry section
has to be updated when it ships.

### The CLI needs its own id — it cannot reuse the Pi's

The runtime's device UUID comes from `/proc/sys/kernel/random/uuid` (`setup.sh:120`),
which does not exist on macOS, and `setup.sh:157` disables telemetry outright when there
is no UUID source. The operator's laptop therefore cannot participate in the runtime's
identity scheme at all. Two ids, on the two machines that exist:

| Id | Lives on | File |
|---|---|---|
| `device_id` | the Pi | `~/.tnkr-telemetry.json` (already built) |
| `installer_id` | the laptop | `~/.tnkr/telemetry.json` (new, same shape and semantics) |

**`tnkr install` is the only place both ids are ever visible at once.** One
`cli_install_completed` event carrying both is the join key for the entire funnel — and
it is what lets the Studio-side alias later attribute the whole setup, laptop and Pi,
rather than just the robot's half.

Reuse `telemetry.py`'s *contract* — rate-capped, fail-silent, scrubs home paths and
usernames — not its code. The CLI cannot take a runtime dependency and stay featherweight.

### Consent is asked on the laptop and propagated

`TNKR_TELEMETRY` is already a hard override in both directions (`setup.sh:142-147`), so
the CLI asks once, on the machine the human is actually sitting at, and passes the answer
down:

```sh
ssh -t $HOST 'curl -fsSL $SETUP_URL | TNKR_TELEMETRY=0 bash'
```

**This needs no change to `setup.sh`.** It also removes the CLI's dependence on
`setup.sh`'s own prompt rendering over SSH — that prompt is gated on a usable TTY
(`setup.sh:130`), so without `ssh -t` it never appears and telemetry defaults to enabled
with the operator never asked. Consent given locally is not subject to that failure.

> ⚠️ **Only send the variable on a first install.** `setup.sh:112-118` promises that an
> existing device reuses its saved preference and is *never re-prompted*. Because the env
> var is a hard override, a CLI that sends `TNKR_TELEMETRY=1` on every run would silently
> re-enable telemetry for someone who opted out on the Pi — breaking a published promise
> from the outside. Read `~/.tnkr-telemetry.json` over SSH first; if it exists, send
> nothing and let the Pi's saved preference stand.
>
> ⚠️ **The probe must fail closed.** If that read does not cleanly return "no consent file
> exists" — SSH blip, permission error, timeout — the CLI must send nothing rather than
> assume a fresh device. Failing open silently reverses a privacy choice the user made,
> produces no error, and is invisible to both sides. This is the one failure mode in this
> document that is untested, unhandled and silent by default, and it is the one with
> consequences beyond a broken install. Fail closed, always.

### No `tnkr login`, and no session-reading either

The [No `tnkr login`](#no-tnkr-login) section rules out authenticating here. Worth
recording that reading Studio's *existing* session was considered and also rejected.

Studio persists auth under `~/.tnkr-studio` (`services/auth.py`), split deliberately: the
refresh token goes to the OS keychain (service `tnkr-studio`, account
`supabase-refresh-token`) with a chmod-600 `refresh-token.plaintext` fallback, the access
token is memory-only, and `profile.json` caches the display profile — `name`, `email`,
`org`, `initials`, `avatarUrl` (`models.py:251-258`).

The refresh token is readable by anything running as the user. The CLI still must not
read it: `auth.py` owns every token for the session's lifetime, and a CLI minting its own
access tokens is the second identity path this document already rejects, at double the
credential blast radius.

Nor is there a reason to. **`TnkrAccount` carries no user id**, so `profile.json` cannot
drive the telemetry alias even if we read it — and the alias belongs in Studio anyway,
which already holds an authenticated session and the user id. The CLI's only obligation
is to make `device_id` knowable, which it already does over SSH.

If a command ever genuinely needs to show who is signed in, `profile.json` is the thing
to read and `GET /api/account` on a running Studio (`routes/api.py:516`) is the
authoritative version. Both are **display hints, never authorization** — the cache
outlives a revoked token or a sign-out on another machine.

---

## Testing

The obvious objection is that this tool's whole job is SSH-ing to a physical Raspberry Pi,
and CI has no Pi. That objection dissolves once you name the unit under test correctly:

> **This CLI does not install anything. It constructs a command and hands it to `ssh`.**

The thing to assert on is *the command it built*, not the installation that results. The
installation is `setup.sh`'s job and `setup.sh` is tested by being run. Four layers, cheap
to expensive:

### 1. Command construction — where most of the value is

Given inputs, assert on the argv the CLI produces. A pure function, no I/O, milliseconds.
All three known traps in this document are single assertions at this layer:

| Risk | The assertion |
|---|---|
| Missing TTY silently disables the consent prompt and flattens progress | `-t` present on every interactive `ssh` invocation |
| Re-running install silently re-enables telemetry for someone who opted out | `TNKR_TELEMETRY` absent from argv when the robot already has a consent file |
| Host guessing | resolution order: `--host` → remembered → prompt → fail, never a silent default |
| Silent opt-out reversal on a probe failure | probe error ⇒ `TNKR_TELEMETRY` absent from argv (fail closed) |
| `--clean` silently dropped | `--clean` present in the argument list whenever the flag was passed |

That the highest-risk behaviours land in the cheapest layer is the useful signal here.

**One test that is not argv, and belongs here anyway: the registry contract test.** Fetch
`setup.sh` at the pinned `setup_ref` and assert the registry still agrees with it —
`SERVICE_NAME`, `INSTALL_DIR`, `STATE_DIR`, `LOG_FILE`. The registry transcribes constants
from another repo on a mutable branch, and no argv assertion can catch a rename, because
the argv is built from the same stale value it is asserting. This test is the only thing
between `SERVICE_NAME` changing and `logs`, `restart` and `doctor` breaking silently.

### 2. Fake `ssh` and `uv` on `PATH`

Put a temp directory at the front of `PATH` holding fake `ssh` and `uv` executables that
record their argv and replay canned stdout and exit codes. This is the standard
language-agnostic trick for testing anything that shells out, and it catches what layer 1
cannot: quoting, environment propagation, and what the CLI does when `ssh` exits 255
because the host is unreachable.

In Python, [`pytest-subprocess`](https://pypi.org/project/pytest-subprocess) offers a
`fake_process` fixture that hooks `subprocess.Popen` directly. For the SSH calls
specifically, prefer the hand-rolled `PATH` directory (about twenty lines): a fixture that
intercepts before `Popen` cannot see argument boundaries at all, and boundaries are
load-bearing here because `setup.sh:787` reads `--clean` positionally.

> **Be precise about what this layer proves, because the obvious claim is wrong.** There are
> *two* parses between the CLI and the installer: Python builds an argv for `ssh`, and then a
> shell **on the Pi** parses the remote command string. The fake `ssh` records the first and
> is executed with an argv, so it never runs the second. Layer 2 therefore proves what the CLI
> handed over, which is most of the value — and says nothing about how the remote shell reads
> it. The remote parse is layer 4's job and nothing else's, which is the real argument for
> keeping the container.

### 3. Snapshot the output

For a CLI, **the output is the product** — the handoff line, the `doctor` report, the
"SO-101 connects over USB" message. Snapshot tests catch accidental UX regressions that
no assertion would think to check.

The pattern worth copying is [`uv`'s](https://docs.astral.sh/uv/reference/contributing/),
which is convenient since it is already a dependency: `uv_snapshot!(context.filters(), …)`.
The load-bearing half is `filters()` — normalising paths, versions, durations and
hostnames so snapshots survive moving between machines. Skip that and snapshot tests
become the flakiest thing in the suite. Python equivalents:
[`syrupy`](https://github.com/syrusakbary/snapshottest) or
[`inline-snapshot`](https://github.com/15r10nk/inline-snapshot).

For the one HTTP call there is no transport to mock, because there is no HTTP client:
inject the fetch function and hand tests a fake that returns canned JSON, a timeout, or a
connection error. Studio already has
the richer version of this pattern if a real server is ever wanted — `sim_agent.py` driven
over ASGI with no socket (`tests/test_openduck_sim_agent.py`) — but the CLI should copy
the idea, not take the dependency.

### 4. A container that answers SSH

Debian + `sshd` in a container standing in for the Pi, exercising real `ssh` against the
real `setup.sh`. **`setup.sh --test` already exists** (`setup.sh:851`) and skips the
hardware packages — onnxruntime, rustypot, adafruit — which is exactly the affordance this
needs.

It catches what no fake can: TTY allocation genuinely working end to end, `sudo`
behaviour, the telemetry consent path, and `curl | bash` quoting under a real shell. It
will not cover motors or GPIO. Run it nightly, not per-commit.

**Stub `systemctl` and `journalctl` in the image.** Real systemd in a container is
privileged and fiddly, but leaving it out would strand `restart`, `logs`, and the
service-install step with argv assertions (L1) and a real Pi (L5) and nothing in between —
which is exactly where a renamed unit or a dropped `-u` ships invisibly. The CLI does not
restart anything; it issues a command and surfaces the result, so a stub covers everything
the CLI is accountable for:

```sh
# /usr/local/bin/systemctl in the test image
#!/bin/sh
echo "$@" >> /tmp/systemctl.log
case "$2" in
  tnkr-robot) exit 0 ;;
  *)          exit 5 ;;   # unknown unit -> the CLI must say something readable
esac
```

Whether the unit genuinely restarts is the runtime's contract, tested where the unit file
lives.

### 5. One real Pi, at release

The only thing that proves the hardware steps and real systemd. A release checklist item,
never a CI gate.

**Build layers 1–3 as the suite, 4 as a nightly job, 5 as a checklist.** Layers 1 and 2
cover every risk this document has identified **except remote-shell parsing**, which only
layer 4 can see. That is still the argument for writing them first rather than reaching for
hardware; it is not an argument that layer 4 is optional.

> **Layer 2 is POSIX-only.** Fake binaries on `PATH` do not work on Windows, and Studio
> ships `start-backend.cmd`, so Windows is at least a partial target for the product.
> `install.sh` is `sh`-only too, so "there is one door" currently has no Windows door.
> Out of scope for v1, named here so it is a decision rather than an oversight.

## The stack

**Python, packaged exactly like `tnkr-studio/server`.** No new toolchain, no new
prerequisite, no new publish path.

```toml
[build-system]
requires      = ["hatchling"]
build-backend = "hatchling.build"

[project]
name           = "tnkr"
requires-python = ">=3.12"
dependencies   = [
  "typer>=0.12",   # verb/target/flag surface; Click underneath
  "rich>=13",      # status table + doctor report ONLY, lazy-imported
]

[project.scripts]
tnkr = "tnkr.cli:app"

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts   = "-q"
```

**Two runtime dependencies, and that is the whole list.** The reasoning behind each
absence is worth keeping, because "featherweight" decays into a wishlist otherwise:

- **No HTTP client.** The CLI makes three kinds of request — a health GET, the pin
  resolution against GitHub's API, and one telemetry POST — and every one of them is a
  URL, a timeout and a JSON body. `httpx` would bring httpcore, anyio, h11, certifi, idna
  and sniffio along permanently to serve that. Stdlib `urllib.request` does it in ten
  lines; `ThreadPoolExecutor` covers the concurrent checks. Inject the fetch function so
  tests need no server.

  > This bullet used to say "exactly one kind of request … the roadmap below guarantees it
  > never grows a second". It grew two. Both were named in this document before they were
  > built — pinning in T8, the telemetry event in T3 — so the guarantee was wrong when it
  > was written rather than broken later. What it was reaching for is still right and is
  > restated above: **no dependency**, not no request. The `posthog` SDK would have brought
  > six packages to send one JSON body.
- **No SSH library.** The system `ssh` binary means `~/.ssh/config`, agents and keys work
  with no configuration.
- **No YAML parser.** The registry ships as a Python literal or JSON, not a config file
  users edit.
- **Nothing that transitively pulls torch.** The 1.5 GB belongs to Studio.

**Why Python rather than Go or Rust.** This is a process launcher: it builds an `ssh` or
`uv` command line, runs it, and shows what came back. Its own speed rounds to zero against
commands that take seconds to twenty minutes. A static binary would buy ~145ms of startup
and a Windows cross-compile, and cost a toolchain absent from all ten repos plus a
replacement for the publish pipeline that already exists. The usual tiebreaker — "this
might get big" — is answered below, and the answer is no.

- **Lint with `ruff`**, matching the convention already in the org.

## Build notes

- **`ssh -t`, with stdio inherited.** Without the TTY allocation, `setup.sh`'s own probe
  (`setup.sh:213`) falls back to `/dev/null` and the operator silently gets flat static
  lines instead of live progress — the install looks hung during the long pip steps.
  `rich` is for our own framing around the SSH call, never for the install stream.
- **Re-runnable.** `setup.sh` is state-file resumable and `install.sh` upgrades in
  place; nothing here should break that.
- **A latency budget, not just an install-time one.** `rich` and `typer` are the slow
  imports (~100-200ms for `rich` alone). Import them inside the functions that use them so
  argv-only paths and `--help` never pay. Health checks get short explicit timeouts and
  run concurrently across registered robots, so N powered-off ducks cost one timeout
  rather than N. Targets: `tnkr --help` under 100ms, `tnkr status` with one live robot
  under 500ms, `tnkr status` with everything off under 3s.
- **The CLI needs a self-upgrade path.** `tnkr upgrade` covers "everything installed", but
  the CLI is itself a `uv` tool and it carries the registry — the file deciding which
  robots exist and where their logs live. A stale CLI on a laptop has no update path and
  no nag. It is the one package with a hard reason to self-update. `tnkr upgrade` with no
  target should include `tnkr` itself, and `status` should say when it is behind.

### Distribution: how `tnkr` reaches a laptop

**`uv tool install tnkr` only works if `tnkr` is published, and it is not.** This is not a
follow-up task; the install path above is inert until it exists.

The pattern to copy is already in the family:
`tnkr-studio/.github/workflows/publish.yml`. It is tag-triggered (`tags: ["v*"]`), uses
**PyPI Trusted Publishing over OIDC** so no token is stored anywhere, defaults
`workflow_dispatch` runs to TestPyPI so a mis-click cannot reach production, and already
publishes two packages from one repo. Version comes from `pyproject.toml`; bump, tag, push.

One prerequisite before the first tag: register a **pending publisher** for the name
`tnkr` at pypi.org, pointing at this repo and workflow.

### Settled: the `tnkr` name

Previously recorded here as an open collision with `tnkr-sdk`. It is not one, twice over:

```
pypi.org/pypi/tnkr/json         -> 404   the name is unclaimed
pypi.org/pypi/tnkr-studio/json  -> 200   published
```

`tnkr-sdk` is dead, so the import-namespace question dies with it. And it was never
published anyway — no workflow, and `tnkr-client` returns 404 — so there was no package to
collide with even while it lived.

**The actual risk is the opposite of the one recorded.** `tnkr` is an unclaimed
four-letter name on PyPI. The exposure is failing to claim it, not colliding with it.
Register the pending publisher before the first tag.

### Why this stays small: Studio's roadmap says so

The usual reason a CLI grows is that nobody wrote down where it stops. Studio already did.
`docs/designs/wired-physical-agents-plan.md:154` has a section titled *"Where 'install'
lives, and why it isn't the CLI"*, which cites this document, upholds the boundary, and
puts component installation on `tnkr_server.py` driven by Studio.

Its eight phases — component contracts, robot sockets, expression, persona, voice-in, the
`brains` document, the slot board, fork — land in Studio and the cloud. Not one of them
lands here. `docs/designs/physical-agents.md` goes further: *"Studio is not local-first"*,
and an agent is a cloud build with a robot attached.

So the growth vector for `tnkr` is known and narrow:

| Grows | Does not grow |
|---|---|
| More robots — each a registry entry, which is data, not code | Component or policy installation |
| `tnkr rollback <robot>` — restore last-known-good | Catalogue choices of any kind |
| Runtime-upgrade obligations (see below) | Personas, voice, brains, slots, fork |

**Two version axes, and they interact.** The same doc (`:174`) notes that `tnkr upgrade`
versions the *runtime* while components are versioned inside it, so a runtime upgrade can
silently change the contract under an already-installed component. This document specifies
`upgrade` as "identical to install", which has no concept of that. **`tnkr upgrade` will
owe a post-upgrade contract re-check**, and that obligation should be designed once, in
both repos, rather than discovered after a duck stops walking.

This is also the answer to "should this be Go or Rust." The tiebreaker for a heavier
toolchain is usually not knowing how big a thing gets. Here it is written down, and it
stays small.

---

## Build order

**v1 is the whole command surface.** Nothing above is deferred to a later release — the
sequence below is dependency order within one release, not scope tiers. A half-surface
CLI would be worse than none, because every missing verb sends someone back to SSH and
teaches them they did not need this tool.

1. **Registry, host resolution, `~/.tnkr/robots.json`.** Every other command reads these.
   Getting the resolution order wrong here is felt everywhere, so it goes first and gets
   layer-1 tests before anything else exists.
2. **`install` / `upgrade`.** The SSH-plus-inherited-stdio core, and the reason the tool
   exists. `upgrade` is the same call, so it costs a flag rather than a feature.
3. **`status` / `doctor` / `logs` / `restart`.** All four reuse the plumbing from step 2 —
   a different remote command and a different way of presenting the result.
4. **Studio commands** (`install studio`, `upgrade studio`, `uninstall studio`, `studio`).
   Independent of steps 1–3 — plain `uv` shell-outs with no robot involved — so they can
   land at any point, including in parallel.
5. **Telemetry.** Deliberately last: events should describe flows that actually exist, and
   instrumenting a surface still in motion produces a schema you regret.

### The work that is not in this repo

Half the risk in this plan lives in four other repos, and none of it has an owner in the
list above. **One of the two that gated a correct v1 has landed; the other has not.**

| Repo | Change | Gates |
|---|---|---|
| ~~`Open_Duck_Mini_Runtime`~~ | ~~Fix the flag parser, add `--from-cli`~~ | **Done** — merged to `v2` as #20 |
| `tnkr-studio` | Name `tnkr install openduck-mini` in `SetupDuckWizard` steps 3-4 and the Connect card | **Yes** — this is the entire discovery mechanism. Without it nobody learns the CLI exists, and this document refuses to tell them any other way |
| `tnkr-app-dashboard` | The `public/install.sh` line, placed per the rules above | No, but nothing installs until it lands |
| `Open_Duck_Mini_Runtime` | Telemetry contract update in its README | No |
| `tnkr-studio` | The PostHog `alias` at the auth seam | No — deferred, but currently ownerless |
| `tnkr-studio` | Reconcile `driver.py:39`'s `openduck.local` with the no-prefill policy | No |

The runtime flag-parser change was the one to start first, despite being the smallest item
here — another repo, required before a CLI release, and the kind of dependency discovered
late enough to block a ship date. It landed. **`tnkr-studio` is now the one that gates a
correct release**, for the same reason and with the same shape: without it nobody learns
the CLI exists, and the release ships to an audience of nobody.

**Studio's wizard is a partner, not a competitor.** `SetupDuckWizard.tsx` executes nothing
— `SETUP_COMMAND` and `SSH_COMMAND` are display strings, and `SSH_COMMAND` contains
literal `<username>@<hostname>` placeholders. It walks the operator through flashing,
SSHing in by hand, and pasting. That is a rendering of exactly the manual process this
tool removes, which makes its steps 3 and 4 the natural place for the CLI to be named.

---

## Running it

```bash
uv sync --group dev                      # once
uv tool install --editable . --force     # `tnkr` on PATH, tracking your working copy
tnkr --help
```

Use the editable install rather than `uv run` while implementing: it exercises the
same entry point users get, so a packaging mistake surfaces immediately instead of at
release. The two should behave identically, and a divergence is a bug worth chasing
rather than working around.

```bash
uv run pytest              # whole suite
uv run pytest -k registry  # one area
uv run pytest -x           # stop at the first failure
uv run ruff check .        # lint; --fix handles the mechanical ones
```

CI runs exactly those and a tag will not publish unless they pass.

### Testing commands that talk to a robot

**You do not need a Pi, and you should not use one for this.** The CLI's job is to build
a command line and hand it to `ssh`, so the thing worth asserting is *what it handed
over*. The `fake_bin` fixture puts a fake executable at the front of `PATH`, records
every invocation, and lets the test dictate the exit code:

```python
def test_install_allocates_a_tty(fake_bin):
    fake_bin.install("ssh")
    install("openduck-mini", host="duck.local")
    assert "-t" in fake_bin.last("ssh")


def test_a_reinstall_never_re_enables_telemetry(fake_bin):
    # setup.sh:112-118 promises an existing device is never re-prompted, and
    # TNKR_TELEMETRY is a hard override — sending it reverses an opt-out.
    fake_bin.install("ssh")
    install("openduck-mini", host="duck.local", consent_file_exists=True)
    assert not any("TNKR_TELEMETRY" in a for a in fake_bin.last("ssh"))


def test_unreachable_is_not_a_bare_255(fake_bin):
    fake_bin.install("ssh", exit_code=255)
    assert install("openduck-mini", host="nope.local") == Exit.UNREACHABLE
```

`install(name, exit_code=, stdout=)` · `last(name)` · `calls(name)` · `called(name)`.
Pair it with the `tnkr_home` fixture so tests never touch your real `~/.tnkr`.

**Why a real script on `PATH` rather than mocking `subprocess`:** a mock intercepts before
`Popen`, so it cannot see argument boundaries at all — and boundaries are load-bearing,
because `setup.sh:787` parses `--clean` positionally. The harness records arguments with a
unit separator so an argument containing spaces stays one argument. What it does **not**
prove is how the shell *on the Pi* re-parses the remote command string: the fake `ssh` is
handed an argv and never runs that second parse. That one is layer 4's. `tests/test_harness.py` tests the harness itself:
every assertion about what the CLI handed to `ssh` rests on that fixture being faithful.

### Four things that will bite

- **`ssh` returns 255 for its own failures *and* for a remote command that exited 255.**
  Never map it straight to "unreachable"; the `doctor` ladder decides. See `exits.py`.
- **`rich` must not be imported at module scope.** Use `console()` from `console.py`.
  Every module-scope import is paid by `tnkr --help`.
- **The installer's output is never parsed.** Inherit stdio, allocate a TTY. Without
  `-t`, `setup.sh:213` falls back to `/dev/null`, the operator loses live progress, *and*
  the telemetry consent prompt silently never appears.
- **The registry transcribes constants from another repo on a mutable branch.** The
  contract test in `tests/test_registry.py` is what catches a rename. It is currently
  skipped and needs wiring into the nightly job.

Longer version in `DEVELOPING.md`; what to build next is in `TODOS.md`.

---

## Related

| Repo | Relevance |
|---|---|
| `tnkr-studio` | The install target. `docs/DISTRIBUTION.md` is the packaging reference; `.github/workflows/publish.yml` is the publish pattern this repo copies |
| `tnkr-studio` | `app/src/components/SetupDuckWizard.tsx` + `components/duck/duckSetup.ts` — **the GUI path this shortens, and where the CLI gets named.** Executes nothing today; its `SETUP_COMMAND` and `SSH_COMMAND` are display strings |
| `tnkr-studio` | `docs/designs/wired-physical-agents-plan.md` — cites this document, carves out `tnkr rollback`, and adds a post-upgrade obligation this plan has no concept of. **Reconcile before either ships** |
| `tnkr-studio` | `services/auth.py`, `models.py` — the session this CLI deliberately does not read |
| `Open_Duck_Mini_Runtime` | `scripts/setup.sh` (the Pi installer), `scripts/tnkr_server.py` (robot API), `mini_bdx_runtime/mini_bdx_runtime/telemetry.py` (the telemetry contract) |
| `tnkr-app-dashboard` | Serves `public/install.sh`. Its `app/components/setup-wizard/` is **being deleted**, so Studio's wizard is the surviving GUI path |

---

## GSTACK REVIEW REPORT

| Review | Trigger | Why | Runs | Status | Findings |
|--------|---------|-----|------|--------|----------|
| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | — |
| Codex Review | `/codex review` | Independent 2nd opinion | 0 | — | — |
| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 2 | CLEAR (PLAN) | 21 issues, 0 critical gaps open |
| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | — |
| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | — |

**Run 1 (whole plan).** Architecture 4, Code Quality 2, Tests 1, Performance 1, plus 3
cross-model tension points (2 resolved, 1 withdrawn) and 2 implementation-stack decisions.
All 12 accepted decisions are folded into the sections above. Scope accepted as-is; the
complexity check did not trigger.

**Stack decided:** Python, hatchling, `requires-python >=3.12`, matching
`tnkr-studio/server`. Two runtime dependencies — `typer` and `rich` (lazy-imported) — with
stdlib `urllib.request` plus `ThreadPoolExecutor` in place of an HTTP client. Go and Rust
were considered and declined: the tiebreaker for a heavier toolchain is not knowing how
big a tool gets, and Studio's roadmap bounds this one explicitly.

Cross-repo verification: 21 claims checked against `Open_Duck_Mini_Runtime`, `tnkr-studio`
and `tnkr-app-dashboard`. 18 confirmed, 2 stale line references corrected, 1 open question
(`tnkr` name) resolved by checking PyPI directly.

One critical gap was found and closed in-plan: the telemetry consent probe could fail
open and silently reverse a user's opt-out. It now fails closed.

**Run 2 (Stage 1: T6, T7, T10).** Architecture 4, Code Quality 4, Tests 2, Performance 1,
plus 4 cross-model tensions and 6 further outside-voice findings. 21 decisions, all
accepted, all folded into the sections above and into `TODOS.md`'s Stage 1. Scope accepted
as-is; the complexity check did not trigger (3 modules, ~7 files, 0 new services).

Three of the run-2 findings were defects in text this document had already published, not
gaps in the plan: `--clean` was documented as preserving `duck_config.json` while
`setup.sh:880` deletes it (it holds the joint offsets); the layer-2 harness was credited
with catching quoting bugs it structurally cannot see, because the fake `ssh` never runs
the Pi's shell; and the exit-code contract was unsatisfiable, since Click already exits 2
for usage errors. Two were defects in shipped code: `registry.py:42` builds
`http://pi@duck.local:8000/…` from an SSH destination, and `conftest.py:96` sets a
`TNKR_HOME` nothing reads.

Verified rather than assumed: `uv run tnkr --bogus` exits 2, `lookup("openduck-mini")
.health_url("pi@duck.local")` returns a URL with an SSH username in the authority, and
`setup.sh:849` vs `:859` parse `--test` and `--clean` two different ways.

- **CODEX:** ran (`codex exec`, read-only, high reasoning). 15 findings, none repeating the
  review. 6 folded into Stage 1 (curl-pipeline masking a failed download, SSH destination
  versus HTTP authority, upsert semantics, schema slots for the run counter and the
  `ssh-copy-id` decline, corruption inhibiting writes, option-shaped host injection), 4
  amended decisions the review had already taken, 1 corrected a false coverage claim in this
  document, and 3 went to `TODOS.md` against T2, T8 and T7.
- **CROSS-MODEL:** Four tensions, all resolved in the outside voice's favour. `flock` on
  `robots.json` is void once `os.replace` swaps the inode, so the lock moved to a sidecar.
  `ResolvedHost` enforces nothing without a type checker, so one joins CI rather than the
  claim being dropped. `status` and `upgrade` fan out over every robot, so the resolver
  gained a second arity instead of prompting. And the exit-code contract was impossible as
  written, so the CLI's own codes moved to the `sysexits` range.
- **VERDICT:** ENG CLEARED — Stage 1 ready to implement. CEO review still optional; design
  review not applicable. Stage 0 (T4, T5, T1b) remains unstarted and T4 gates a release.

NO UNRESOLVED DECISIONS
