@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.
- package/CHANGELOG.md +70 -0
- package/README.md +70 -9
- package/bin/leg.mjs +55 -14
- package/docs/ERRORS.md +53 -0
- package/docs/ROADMAP-v2.md +24 -11
- package/docs/adapters.md +93 -11
- package/docs/cli-contracts.md +36 -17
- package/docs/configuration.md +55 -5
- package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
- package/fixtures/live/grok/cmd.txt +1 -1
- package/fixtures/live/grok/parsed.json +6 -3
- package/fixtures/live/grok/run.json +22 -10
- package/fixtures/verified.json +8 -1
- package/package.json +1 -1
- package/scripts/build-docs-site.mjs +4 -4
- package/scripts/probe.mjs +2 -1
- package/src/adapters/cli.mjs +130 -0
- package/src/adapters/custom.mjs +271 -0
- package/src/adapters/grok.mjs +51 -10
- package/src/adapters/index.mjs +34 -7
- package/src/attach.mjs +35 -9
- package/src/audit.mjs +118 -0
- package/src/board/audit.js +123 -0
- package/src/board/board.css +15 -0
- package/src/board/index.html +22 -0
- package/src/board/sessions.js +29 -1
- package/src/server.mjs +89 -23
- package/src/share.mjs +66 -6
- package/src/taps/grok.mjs +4 -0
- package/src/usage.mjs +21 -5
package/docs/cli-contracts.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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 -
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
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.
|
|
214
|
-
|
|
215
|
-
|
|
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 -
|
|
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":
|
|
3
|
-
"last_message":
|
|
4
|
-
"stop_reason":
|
|
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-
|
|
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\\
|
|
9
|
+
"cwd": "~\\AppData\\Local\\Temp\\claude\\C--Projects-leg\\9c2d72c5-4654-46ea-a183-81767ec30af5\\scratchpad\\probe-repo3",
|
|
9
10
|
"status": "exited",
|
|
10
|
-
"supervisor_pid":
|
|
11
|
-
"agent_pid":
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
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
|
}
|
package/fixtures/verified.json
CHANGED
|
@@ -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":
|
|
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.
|
|
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
|
|
80
|
-
description: 'The exact usage endpoints, hooks, log lines and limit strings Leg reads from Claude Code, Codex and
|
|
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
|
|
87
|
-
description: 'How each agent adapter spawns its CLI, what it strips from the environment,
|
|
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
|
-
|
|
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
|
+
}
|