@ucsandman/legcli 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,7 +25,7 @@ questions."
25
25
  | claude | 2.1.268 | exit 0, file yes, DONE yes, 20 s | `src/adapters/claude.mjs` |
26
26
  | codex | codex-cli 0.153.4 | exit 0, file yes, DONE yes, 28 s | `src/adapters/codex.mjs` |
27
27
  | agy | 1.2.0 | attempt 1 exit 0 but wrote to its scratch workspace; attempt 2 (`--add-dir`) exit 0, file yes, DONE yes, 41 s | `src/adapters/agy.mjs` |
28
- | grok | 0.2.51 | exit 0, `stopReason: Cancelled`, no file: not logged in (device-code prompt) | `src/adapters/grok.mjs` exists, NOT registered |
28
+ | grok | 1.0.34 | exit 1, no file, no DONE: the account answered `402 Payment Required: Grok Build usage balance exhausted`, classified `limit` (re-probed 2026-09-17; the 2026-09-11 run on 0.2.51 was not logged in) | `src/adapters/grok.mjs` |
29
29
 
30
30
  The auth-source check: the build shell carried `ANTHROPIC_API_KEY` and
31
31
  `OPENAI_API_KEY` (injected by the shell profile). Every adapter's `env()` deletes
@@ -165,29 +165,48 @@ stderr are 0 bytes, and codex's is the one stdin notice. observed-live.
165
165
 
166
166
  ## grok
167
167
 
168
- - Version grok 0.2.51 (f4f85a649) [stable] (source: `grok --version`).
168
+ - Version grok 1.0.34 (3736acbc8658) [stable] (source: `grok --version`,
169
+ 2026-09-17). The 2026-09-11 sweep saw 0.2.51 on a machine with no login; every
170
+ line below was re-read on 1.0.34.
169
171
  - Binary: `~/.grok/bin/grok.exe` (native; also an npm shim on PATH);
170
172
  `LEG_GROK_BIN` overrides (source: `where grok`).
171
- - Headless argv (from cmd.txt): `grok.exe -p "<prompt>" --output-format json --permission-mode acceptEdits`; stdin `ignore`.
172
- `--prompt-file <path>` also exists (source: `grok --help`).
173
- - Output: one JSON object `{text, stopReason, sessionId, requestId, thought}`
174
- (observed-live, fixtures/live/grok/out.log).
173
+ - Headless argv (from cmd.txt): `grok.exe --prompt-file <run>/prompt.txt
174
+ --output-format json --permission-mode acceptEdits --cwd <worktree>`; stdin
175
+ `ignore`. The file form is used whenever the runner has written a prompt file,
176
+ because a hand-off prompt carries the whole bundle summary and Windows caps a
177
+ command line near 32k; with none, the adapter falls back to `-p "<prompt>"`
178
+ (source: `grok --help`, `-p, --single <PROMPT>` and `--prompt-file <PATH>`).
179
+ `--cwd <CWD>` is passed explicitly rather than trusting the spawn's working
180
+ directory, because grok can run against a shared leader process
181
+ (`~/.grok/leader.sock`) and a leg must edit its own worktree.
182
+ - Output: the Claude Code result envelope. `"type":"result"`, `subtype`,
183
+ `is_error`, `session_id`, `result`, `num_turns`, `stop_reason`, `total_cost`,
184
+ read out of the shipped `grok.exe` on 2026-09-17 alongside the streaming
185
+ types (`assistant`, `system`, `text`, `usage`, `end`, `error`). An error is
186
+ the other envelope, `{"type":"error","message":…}` (observed-live,
187
+ `fixtures/live/grok/out.log`).
175
188
  - Exit codes:
176
189
 
177
190
  | exit | meaning | source |
178
191
  |------|---------|--------|
179
- | 0 | printed JSON with `stopReason:"Cancelled"` and did no work: the CLI was not logged in, printed a device-code prompt on stderr (`https://accounts.x.ai/oauth2/device?user_code=…`, "Waiting for authorization...") and gave up after ~58 s | observed-live, fixtures/live/grok/err.log |
180
-
181
- A zero exit with no DONE marker and no diff is exactly the `no_progress` class
182
- the completion contract exists for.
183
- - Permission modes: `default`, `acceptEdits`, `auto`, `dontAsk`,
184
- `bypassPermissions`, `plan` (source: `grok --help`). Leg: default
185
- `acceptEdits`; forbidden `bypassPermissions`, `--always-approve`.
192
+ | 1 | printed `{"type":"error","message":"Internal error: API error (status 402 Payment Required): Grok Build usage balance exhausted"}` and did no work: the account had no balance left | observed-live 2026-09-17, fixtures/live/grok/out.log |
193
+ | 0 | printed JSON with `stopReason:"Cancelled"` and did no work: the CLI was not logged in, printed a device-code prompt on stderr and gave up after ~58 s | observed-live 2026-09-11 on 0.2.51 |
194
+
195
+ The 402 is a wall, not a failure: `fixtures/limits/grok/grok-balance-exhausted.json`
196
+ classifies it `limit`, so the card hands off instead of stopping. None of the
197
+ rate-limit strings the tap watched for appear in a 402, which is why an
198
+ exhausted grok terminal used to sit there; `src/taps/grok.mjs` reads it too now.
199
+ - Permission modes: `default`, `acceptEdits`, `auto`, `dontAsk`, `plan`, and a
200
+ bypass mode Leg never passes (source: `grok --help`). Leg: default
201
+ `acceptEdits`; forbidden the bypass mode and `--always-approve`.
186
202
  - Login: `grok login` (source: `grok --help` Commands).
187
- - **Verdict: not verified, no adapter registered.** `src/adapters/grok.mjs` is
188
- built from `--help` and unit-tested for shape and forbidden flags, but stays
189
- out of `src/adapters/index.mjs` until `grok login` has been completed on the
190
- machine and `node scripts/probe.mjs --adapter grok --repo <toy>` passes.
203
+ - **Verdict: registered, wall path verified, success path not.** The probe
204
+ reached the account through the real runner, so the binary resolution, the
205
+ argv, the auth and the limit classification are all observed-live. A leg that
206
+ completes its task has not been seen, because that needs balance on the
207
+ account; until it is, a grok leg whose envelope does not parse is judged by
208
+ its `.leg/DONE` marker and its diff, as every adapter is. Re-run
209
+ `node scripts/probe.mjs --adapter grok --repo <toy>` with balance to close it.
191
210
 
192
211
  ## `leg harness` (the portable harness)
193
212
 
@@ -126,7 +126,8 @@ resolves to by default.
126
126
  | `LEG_CLAUDE_BIN` | claude | `src/adapters/claude.mjs` |
127
127
  | `LEG_CODEX_BIN` | codex | `src/adapters/codex.mjs` |
128
128
  | `LEG_AGY_BIN` | agy | `src/adapters/agy.mjs` |
129
- | `LEG_GROK_BIN` | grok (not registered by default) | `src/adapters/grok.mjs` |
129
+ | `LEG_GROK_BIN` | grok | `src/adapters/grok.mjs` |
130
+ | `LEG_<NAME>_BIN` | a custom adapter called `<name>` (dashes become underscores, so `my-agent` reads `LEG_MY_AGENT_BIN`) | `src/adapters/custom.mjs` |
130
131
  | `LEG_GH_BIN` | the `pr` land-mode stub | `src/stations/pr.mjs`; unset, `land_mode: pr` returns an error rather than running a real `gh` |
131
132
  | `LEG_CHB_BIN` | `context-handoff-bundle` | `src/handoff.mjs`; unset, Leg tries `context-handoff-bundle` on PATH, then `python -m context_handoff_bundle` |
132
133
 
@@ -210,9 +211,10 @@ other address without also setting `LEG_TOKEN` makes the server refuse to
210
211
  start, exit code `3` (`src/auth.mjs` `checkBind`/`BindRefused`). With a
211
212
  token set, every `/api/*` request needs an `Authorization: Bearer <token>`
212
213
  header; the event stream (`EventSource`, which cannot set headers) accepts
213
- the same token as a `?token=` query parameter instead. There is no TLS; keep
214
- `LEG_BIND` on loopback unless you are using `leg share`, which gives
215
- each human their own token (see [Share](#share-more-than-one-human)).
214
+ the same token as a `?token=` query parameter instead. Keep `LEG_BIND` on
215
+ loopback unless you are using `leg share`, which gives each human their own
216
+ token (see [Share](#share-more-than-one-human)) and can serve the board over
217
+ TLS.
216
218
 
217
219
  ## Share (more than one human)
218
220
 
@@ -224,7 +226,8 @@ switch and the roster; the env variables below only tune the limits.
224
226
  | `on` | share is on (it also needs a `bind` and at least one person) |
225
227
  | `bind`, `bind_kind`, `port` | where the board listens: the Tailscale address by default, `lan`, or one you named |
226
228
  | `owner` | the name a terminal belongs to when nothing else says (`LEG_PERSON`), and the name a loopback browser is treated as |
227
- | `people[]` | `{ name, role: owner\|guest, token_sha256, created_at }`; the token itself is printed once and never stored |
229
+ | `people[]` | `{ name, role: owner\|operator\|guest, token_sha256, created_at }`; the token itself is printed once and never stored |
230
+ | `tls` | `{ cert, key }`, the paths to a certificate pair; absent means plain http |
228
231
  | `loopback_owner` | default `true`: a browser on this machine is the owner without a token. Set it to `false` to ask for a link even here |
229
232
 
230
233
  | variable | default | meaning | read in |
@@ -234,6 +237,53 @@ switch and the roster; the env variables below only tune the limits.
234
237
 
235
238
  `LEG_TOKEN` is the single-token mode and is ignored while share is on.
236
239
 
240
+ ### Roles
241
+
242
+ | role | terminals | cards | this machine |
243
+ |------|-----------|-------|--------------|
244
+ | `owner` | every one | every one | settings, harness policy, the trunk's repo paths, the history index, the worktree map, the audit trail |
245
+ | `operator` | their own; someone else's is read-only and redacted | adds, runs, approves, reassigns, kills | nothing: `/api/settings`, `/api/history`, `/api/worktrees`, `/api/trunk` and `/api/audit` all answer 403, and `/api/health` omits the home path |
246
+ | `guest` | their own; someone else's is read-only and redacted, with **Request handoff** as the only button | nothing: 403 | nothing |
247
+
248
+ A guest and an operator both see their own terminal's hand-off destinations,
249
+ so they can use the picker on it — but never the reset times behind them,
250
+ which are this machine's usage data. `src/share.mjs` `mayUseCards` and
251
+ `mayUseMachine` are the only place a role is turned into permission.
252
+
253
+ ### TLS
254
+
255
+ Off unless a certificate pair is configured. Leg never issues one: a
256
+ self-signed pair teaches everyone on the board to click through a warning,
257
+ which is worse than plaintext on a network that is already private. On
258
+ Tailscale, `tailscale cert <machine>.<tailnet>.ts.net` issues a pair browsers
259
+ already trust.
260
+
261
+ | variable | meaning | read in |
262
+ |----------|---------|---------|
263
+ | `LEG_TLS_CERT` | path to the certificate; wins over `share.json`'s `tls.cert` | `src/share.mjs` `readTls` |
264
+ | `LEG_TLS_KEY` | path to the private key; wins over `share.json`'s `tls.key` | `src/share.mjs` `readTls` |
265
+
266
+ `leg share on --tls-cert <file> --tls-key <file>` writes the pair into
267
+ `share.json` and reads it once, so a bad pair fails there rather than at the
268
+ next board start. With a pair configured the shared address serves https and
269
+ every link `leg share` prints says `https://`. The companion listener on
270
+ `127.0.0.1` — the one that lets this machine's own browser in without a token
271
+ — stays plain http, because the certificate is for the shared name and
272
+ loopback traffic never leaves the machine. Half a pair, a missing file, an
273
+ unreadable file or an empty one stops the board with exit `3` rather than
274
+ falling back to plaintext. A renewed pair is picked up by `leg down && leg up`.
275
+
276
+ ### The audit trail
277
+
278
+ `GET /api/audit` (owner only) and Settings → **Audit trail** on the board: one
279
+ list across every terminal and every card, newest first, of the actions a
280
+ person or an agent took — hand-offs, landings, approvals, reassignments,
281
+ kills. It reads the events already on disk and stores nothing new. Query
282
+ parameters: `limit` (default 200, max 1000), `since` (an ISO timestamp), `who`
283
+ (a name), `kind` (`human`, `agent` or `leg`). Every answer carries `scanned`
284
+ — how many terminals, cards and events it read — so an empty trail cannot be
285
+ mistaken for a quiet week.
286
+
237
287
  ## Card-level options
238
288
 
239
289
  These configure one card, not the whole install (set with `card add` flags
@@ -0,0 +1,11 @@
1
+ {
2
+ "id": "grok-balance-exhausted",
3
+ "adapter": "grok",
4
+ "source": "observed-live",
5
+ "produced_by": "grok 1.0.34 -p --output-format json on an account with no balance left, 2026-09-17: stdout {\"type\":\"error\",\"message\":\"Internal error: {\n \\\"message\\\": \\\"API error (status 402 Payment Required): Grok Build usage balance exhausted\\\",\n \\\"http_status\\\": 402\n}\"}, exit 1",
6
+ "where": "any",
7
+ "text": "API error (status 402 Payment Required): Grok Build usage balance exhausted",
8
+ "pattern": "usage balance exhausted|status 402 Payment Required",
9
+ "exit_code": null,
10
+ "classification": "limit"
11
+ }
@@ -1 +1 @@
1
- ~\.grok\bin\grok.exe -p "Create a file named hello-grok.txt in the current directory containing exactly the word hi. Then create the directory .baton if it is missing and write the file .baton/DONE containing the single line: done. Do nothing else. Do not ask questions.\n" --output-format json --permission-mode acceptEdits
1
+ ~\.grok\bin\grok.exe --prompt-file ~\.baton\cards\card-20260917-1748-probe-grok\runs\1\prompt.txt --output-format json --permission-mode acceptEdits --cwd ~\AppData\Local\Temp\claude\C--Projects-leg\9c2d72c5-4654-46ea-a183-81767ec30af5\scratchpad\probe-repo3
@@ -1,5 +1,8 @@
1
1
  {
2
- "session_id": "01a08de5-c64c-7a31-ac58-1c11c2dd7332",
3
- "last_message": null,
4
- "stop_reason": null
2
+ "session_id": null,
3
+ "last_message": "Internal error: {\n \"message\": \"API error (status 402 Payment Required): Grok Build usage balance exhausted\",\n \"http_status\": 402\n}",
4
+ "stop_reason": "error",
5
+ "subtype": null,
6
+ "is_error": true,
7
+ "num_turns": null
5
8
  }
@@ -1,18 +1,30 @@
1
1
  {
2
- "card_id": "card-20260911-0036-probe-grok",
2
+ "card_id": "card-20260917-1748-probe-grok",
3
3
  "run": 1,
4
4
  "adapter": "grok",
5
5
  "mode": "acceptEdits",
6
6
  "max_turns": null,
7
+ "model": null,
7
8
  "resume": null,
8
- "cwd": "~\\AppData\\Local\\Temp\\claude\\C--Projects\\49952fac-5ff4-486c-8395-9973cebdfdcb\\scratchpad\\toy-probe",
9
+ "cwd": "~\\AppData\\Local\\Temp\\claude\\C--Projects-leg\\9c2d72c5-4654-46ea-a183-81767ec30af5\\scratchpad\\probe-repo3",
9
10
  "status": "exited",
10
- "supervisor_pid": 70732,
11
- "agent_pid": 12580,
12
- "started_at": "2026-09-11T00:36:48.183Z",
13
- "outcome": null,
14
- "updated_at": "2026-09-11T00:37:45.845Z",
15
- "exit_code": 0,
16
- "session_id": "01a08de5-c64c-7a31-ac58-1c11c2dd7332",
17
- "ended_at": "2026-09-11T00:37:45.380Z"
11
+ "supervisor_pid": 47116,
12
+ "agent_pid": 48864,
13
+ "driver_pid": null,
14
+ "started_at": "2026-09-17T17:48:07.055Z",
15
+ "outcome": "limit",
16
+ "updated_at": "2026-09-17T17:48:18.619Z",
17
+ "exit_code": 1,
18
+ "session_id": null,
19
+ "ended_at": "2026-09-17T17:48:18.538Z",
20
+ "signal": "grok-balance-exhausted",
21
+ "handoff": true,
22
+ "reason": "observed-live limit signal: grok-balance-exhausted",
23
+ "done_marker": false,
24
+ "diff": {
25
+ "changed": true,
26
+ "files": 1,
27
+ "head_at_start": "f8d264942a080ca398751d0d481d612f73ef3d62",
28
+ "head": "f8d264942a080ca398751d0d481d612f73ef3d62"
29
+ }
18
30
  }
@@ -2,7 +2,7 @@
2
2
  "_comment": "The single source of truth for every dated, numbered claim Leg makes in public. scripts/check-claims.mjs asserts that each surface listed here states exactly these values, and --strict additionally compares the pinned CLI versions against what those CLIs currently publish. Re-verify on a real machine, then edit this file and every surface in the same commit.",
3
3
  "verifiedOn": "2026-09-11",
4
4
  "verifiedOnLong": "11 September 2026",
5
- "tests": 473,
5
+ "tests": 674,
6
6
  "clis": {
7
7
  "claude": {
8
8
  "label": "Claude Code",
@@ -22,6 +22,13 @@
22
22
  "npm": null,
23
23
  "probe": "agy --version",
24
24
  "note": "Antigravity CLI is a closed Go binary with no npm package, so drift is only visible from a machine that has it installed."
25
+ },
26
+ "grok": {
27
+ "label": "grok",
28
+ "version": "1.0.34",
29
+ "npm": null,
30
+ "probe": "grok --version",
31
+ "note": "Grok Build ships as a downloaded binary, not an npm package. Probed on 2026-09-17, separately from the 2026-09-11 sweep above: the flags came from grok --help and the headless envelope from the shipped exe, and scripts/probe.mjs reached the account and returned a real 402 wall. The success path of a grok leg is still unprobed for want of balance on the account."
25
32
  }
26
33
  },
27
34
  "surfaces": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ucsandman/legcli",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Usage-limit monitor and automatic handoff for Claude Code, Codex, agy and Grok. Type leg claude|codex|agy|grok and get the same interactive agent with a board alongside, auto-approve on by default, usage tracking per agent and account, a live context handoff bundle, and at the limit the next agent continuing in the same terminal. $79 once, 30-day money-back guarantee.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -76,15 +76,15 @@ const PAGES = [
76
76
  slug: 'cli-contracts',
77
77
  nav: 'What it reads',
78
78
  source: 'docs/cli-contracts.md',
79
- title: 'What Leg reads from Claude Code, Codex and agy',
80
- description: 'The exact usage endpoints, hooks, log lines and limit strings Leg reads from Claude Code, Codex and agy, each cited to its source.',
79
+ title: 'What Leg reads from Claude Code, Codex, agy and Grok',
80
+ description: 'The exact usage endpoints, hooks, log lines and limit strings Leg reads from Claude Code, Codex, agy and Grok, each cited to its source.',
81
81
  },
82
82
  {
83
83
  slug: 'adapters',
84
84
  nav: 'Adapters',
85
85
  source: 'docs/adapters.md',
86
- title: 'Leg agent adapters for Claude Code, Codex and agy',
87
- description: 'How each agent adapter spawns its CLI, what it strips from the environment, and how to tell whether a tap is live or docs-only.',
86
+ title: 'Leg agent adapters for Claude Code, Codex, agy and Grok',
87
+ description: 'How each agent adapter spawns its CLI, what it strips from the environment, how to tell whether a tap is live or docs-only, and how to add any other CLI from a JSON spec.',
88
88
  },
89
89
  {
90
90
  slug: 'faq',
package/scripts/probe.mjs CHANGED
@@ -79,7 +79,8 @@ mkdirSync(out, { recursive: true })
79
79
  for (const f of ['out.log', 'err.log', 'run.json', 'supervisor.log', 'last.md']) {
80
80
  if (existsSync(join(runDir, f))) writeFileSync(join(out, f), scrubPaths(readFileSync(join(runDir, f), 'utf8')))
81
81
  }
82
- const spec = adapter.argv({ mode, cwd: repo, prompt: PROMPT, runDir, killMs: 5400000 })
82
+ // the same opts the runner passed, so cmd.txt is the command that really ran
83
+ const spec = adapter.argv({ mode, cwd: repo, prompt: PROMPT, promptFile: join(runDir, 'prompt.txt'), runDir, killMs: 5400000 })
83
84
  writeFileSync(join(out, 'cmd.txt'), scrubPaths([spec.bin, ...spec.args].map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' ')) + '\n')
84
85
  const textOf = (f) => (existsSync(join(runDir, f)) ? readFileSync(join(runDir, f), 'utf8') : '')
85
86
  const authSource = /another auth source/i.test(textOf('err.log') + textOf('out.log'))
@@ -0,0 +1,130 @@
1
+ // `leg adapter …` — the custom-adapter surface. A spec is a JSON file in
2
+ // $LEG_HOME/adapters/<name>.json; these verbs write, validate and explain it
3
+ // so a bad spec is caught here rather than when a card tries to spawn.
4
+ import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
5
+ import { resolve } from 'node:path'
6
+ import { writeJsonAtomic } from '../fsx.mjs'
7
+ import { BUILTIN_NAMES, customSpecs, get as getAdapter } from './index.mjs'
8
+ import { adaptersDir, specPath, validateSpec, makeAdapter, SpecError, TEMPLATE, PLACEHOLDERS } from './custom.mjs'
9
+
10
+ const USAGE = `usage:
11
+ leg adapter list every adapter this machine has, built-in and custom
12
+ leg adapter show <name> the spec, as JSON
13
+ leg adapter check <name> validate it and print the command line a leg would run
14
+ leg adapter add <file.json> [--name n] validate a spec and install it
15
+ leg adapter rm <name> remove a custom spec
16
+ leg adapter template [--name n] print a starter spec to fill in`
17
+
18
+ function quote(a) { return /\s/.test(a) ? JSON.stringify(a) : a }
19
+
20
+ export async function adapterCommand(verb, args, { out, die }) {
21
+ const first = args._?.[0]
22
+
23
+ if (!verb || verb === 'help' || verb === '--help') { out(USAGE); return 0 }
24
+
25
+ if (verb === 'list') {
26
+ const specs = customSpecs()
27
+ if (args.json) {
28
+ out(JSON.stringify({
29
+ builtin: BUILTIN_NAMES,
30
+ custom: specs.map((s) => ({ name: s.name, file: s.file, ok: Boolean(s.adapter), error: s.error, bin: s.spec?.bin ?? null })),
31
+ }, null, 2))
32
+ return specs.some((s) => s.error) ? 1 : 0
33
+ }
34
+ out('built-in:')
35
+ for (const n of BUILTIN_NAMES) out(` ${n}`)
36
+ out(`custom (${adaptersDir()}):`)
37
+ if (!specs.length) out(' none yet — leg adapter template > my-agent.json, then leg adapter add my-agent.json')
38
+ for (const s of specs) {
39
+ out(s.adapter ? ` ${s.name.padEnd(20)} ${s.spec.bin}` : ` ${s.name.padEnd(20)} BROKEN: ${s.error}`)
40
+ }
41
+ return specs.some((s) => s.error) ? 1 : 0
42
+ }
43
+
44
+ if (verb === 'template') {
45
+ const spec = { ...TEMPLATE }
46
+ // the common case is that the command is called what the adapter is called
47
+ if (typeof args.name === 'string') { spec.name = args.name; spec.bin = args.name }
48
+ out(JSON.stringify(spec, null, 2))
49
+ return 0
50
+ }
51
+
52
+ if (verb === 'show') {
53
+ if (!first) return die(2, 'usage: leg adapter show <name>')
54
+ const hit = customSpecs().find((s) => s.name === first)
55
+ if (!hit) {
56
+ if (BUILTIN_NAMES.includes(first)) return die(2, `${first} is a built-in adapter, not a spec on disk (src/adapters/${first}.mjs)`)
57
+ return die(3, `no custom adapter called "${first}" in ${adaptersDir()}`)
58
+ }
59
+ if (hit.error) return die(1, `${first}: ${hit.error}`)
60
+ out(JSON.stringify(hit.spec, null, 2))
61
+ return 0
62
+ }
63
+
64
+ if (verb === 'check') {
65
+ if (!first) return die(2, 'usage: leg adapter check <name>')
66
+ let adapter
67
+ try { adapter = await getAdapter(first) } catch (err) { return die(3, err.message) }
68
+ const cwd = typeof args.cwd === 'string' ? args.cwd : process.cwd()
69
+ let spec
70
+ try {
71
+ spec = adapter.argv({
72
+ prompt: 'THE TASK PROMPT',
73
+ promptFile: typeof args['prompt-file'] === 'string' ? args['prompt-file'] : null,
74
+ cwd,
75
+ mode: typeof args.mode === 'string' ? args.mode : undefined,
76
+ model: typeof args.model === 'string' ? args.model : null,
77
+ runDir: null,
78
+ killMs: 5400000,
79
+ })
80
+ } catch (err) { return die(1, `${first}: ${err.message}`) }
81
+ out(`${first}: ${adapter.custom ? 'custom spec' : 'built in'}`)
82
+ out(` stdin ${adapter.stdin}`)
83
+ out(` modes ${adapter.modes.allowed.join(', ')} (default ${adapter.modes.default})`)
84
+ out(` would run ${[spec.bin, ...spec.args].map(quote).join(' ')}`)
85
+ // A spec that names a binary nothing can find spawns once and fails once;
86
+ // saying so here is the difference between a typo and a mystery.
87
+ const bin = spec.bin
88
+ const found = existsSync(bin) || !/[\\/]/.test(bin)
89
+ out(` binary ${bin}${existsSync(bin) ? ' (found)' : found ? ' (looked up on PATH at spawn time)' : ' — NOT FOUND at that path'}`)
90
+ return existsSync(bin) || found ? 0 : 1
91
+ }
92
+
93
+ if (verb === 'add') {
94
+ if (!first) return die(2, 'usage: leg adapter add <file.json> [--name <name>]')
95
+ const file = resolve(first)
96
+ if (!existsSync(file)) return die(2, `no such file: ${file}`)
97
+ let raw
98
+ try { raw = JSON.parse(readFileSync(file, 'utf8')) } catch (err) { return die(2, `${file} is not JSON: ${err.message}`) }
99
+ if (raw && typeof raw === 'object' && typeof args.name === 'string') raw.name = args.name
100
+ let spec
101
+ try { spec = validateSpec(raw, { reserved: BUILTIN_NAMES }) } catch (err) {
102
+ if (err instanceof SpecError) return die(2, err.message)
103
+ throw err
104
+ }
105
+ const dest = specPath(spec.name)
106
+ const replacing = existsSync(dest)
107
+ mkdirSync(adaptersDir(), { recursive: true })
108
+ writeJsonAtomic(dest, spec)
109
+ const adapter = makeAdapter(spec)
110
+ // the runner always writes a prompt file, so the preview shows one too
111
+ const line = adapter.argv({ prompt: 'THE TASK PROMPT', promptFile: '<run>/prompt.txt', cwd: process.cwd() })
112
+ out(`${replacing ? 'replaced' : 'added'} ${spec.name} → ${dest}`)
113
+ out(` would run ${[line.bin, ...line.args].map(quote).join(' ')}`)
114
+ out(` use it leg card add --repo <path> --task "<t>" --chain ${spec.name},claude --queue`)
115
+ return 0
116
+ }
117
+
118
+ if (verb === 'rm' || verb === 'remove') {
119
+ if (!first) return die(2, 'usage: leg adapter rm <name>')
120
+ if (BUILTIN_NAMES.includes(first)) return die(2, `${first} is built in; there is no spec file to remove`)
121
+ const dest = specPath(first)
122
+ if (!existsSync(dest)) return die(3, `no custom adapter called "${first}" in ${adaptersDir()}`)
123
+ rmSync(dest, { force: true })
124
+ out(`removed ${first} (${dest})`)
125
+ out('cards that already name it keep their chain; they will fail to launch until it is added again')
126
+ return 0
127
+ }
128
+
129
+ return die(2, `unknown: leg adapter ${verb}\n\n${USAGE}\n\nplaceholders: ${PLACEHOLDERS.map((p) => `{{${p}}}`).join(' ')}`)
130
+ }