auto-harness-client 0.6.0 → 0.8.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/README.md +467 -0
- package/package.json +5 -1
- package/src/cli/admin-login.js +71 -0
- package/src/cli/allowlist.js +34 -0
- package/src/cli/args.js +57 -0
- package/src/cli/cli-errors.js +16 -0
- package/src/cli/commands/api.js +91 -0
- package/src/cli/commands/attach-repository.js +117 -0
- package/src/cli/commands/dependency-conflict.js +15 -0
- package/src/cli/commands/detach-repository.js +109 -0
- package/src/cli/commands/doctor.js +164 -0
- package/src/cli/commands/host-drain.js +22 -0
- package/src/cli/commands/host-inventory-get.js +40 -0
- package/src/cli/commands/host-inventory-set.js +66 -0
- package/src/cli/commands/host-inventory.js +15 -0
- package/src/cli/commands/host-list.js +87 -0
- package/src/cli/commands/host-post-action.js +38 -0
- package/src/cli/commands/host-repo-add.js +81 -0
- package/src/cli/commands/host-repo-rm.js +51 -0
- package/src/cli/commands/host-repo.js +16 -0
- package/src/cli/commands/host-resume.js +17 -0
- package/src/cli/commands/host-smoke-format.js +30 -0
- package/src/cli/commands/host-smoke-poll.js +77 -0
- package/src/cli/commands/host-smoke-provider.js +172 -0
- package/src/cli/commands/host-smoke-repository.js +49 -0
- package/src/cli/commands/host-smoke-session-attempt.js +104 -0
- package/src/cli/commands/host-smoke-teardown.js +164 -0
- package/src/cli/commands/host-smoke.js +151 -0
- package/src/cli/commands/host.js +31 -0
- package/src/cli/commands/parse-worktree-flag.js +30 -0
- package/src/cli/commands/repo-add.js +38 -0
- package/src/cli/commands/repo-list.js +84 -0
- package/src/cli/commands/repo-rm.js +83 -0
- package/src/cli/commands/repo.js +18 -0
- package/src/cli/commands/service-account-create.js +114 -0
- package/src/cli/commands/service-account-list.js +84 -0
- package/src/cli/commands/service-account-rm.js +48 -0
- package/src/cli/commands/service-account.js +19 -0
- package/src/cli/commands/session-cancel.js +28 -0
- package/src/cli/commands/session-create.js +117 -0
- package/src/cli/commands/session-get.js +26 -0
- package/src/cli/commands/session-logs.js +65 -0
- package/src/cli/commands/session-target.js +29 -0
- package/src/cli/commands/session.js +22 -0
- package/src/cli/commands/whoami.js +22 -0
- package/src/cli/config.js +96 -0
- package/src/cli/index.js +21 -0
- package/src/cli/main.js +71 -0
- package/src/cli/path-segment.js +20 -0
- package/src/cli/read-stdin.js +9 -0
- package/src/cli/report-error.js +34 -0
- package/src/cli/service-account-format.js +24 -0
- package/src/cli/session-format.js +15 -0
- package/src/cli/usage.js +83 -0
- package/src/cli/wait-for-session.js +46 -0
- package/src/errors.js +1 -0
- package/src/index.d.ts +64 -4
- package/src/index.js +76 -30
- package/src/resolve-target.js +29 -13
package/README.md
CHANGED
|
@@ -160,6 +160,20 @@ const resumed = await harness.resumeSession("session-1", { prompt: "Address the
|
|
|
160
160
|
console.log(resumed.status);
|
|
161
161
|
```
|
|
162
162
|
|
|
163
|
+
Pass `target` (and, only alongside it, `fallbacks`) to **rebind** the resume onto a different
|
|
164
|
+
Command/Provider instead of continuing the source session's original route — the same
|
|
165
|
+
`providerId`/`commandId`/`providerName`/`commandName` shapes as `createSession()`, resolved to ids
|
|
166
|
+
the same way. This replaces the source session's whole target/fallback policy (a bare `fallbacks`
|
|
167
|
+
override with no `target` is rejected) and always resumes on a fresh assignment: there is no native
|
|
168
|
+
CLI resume onto a different Command, so it may also land on a different host.
|
|
169
|
+
|
|
170
|
+
```js
|
|
171
|
+
const resumed = await harness.resumeSession("session-1", {
|
|
172
|
+
prompt: "Continue with the updated Command.",
|
|
173
|
+
target: { commandName: "claude-print-auto" },
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
163
177
|
## List sessions
|
|
164
178
|
|
|
165
179
|
Session listings are bounded pages with the same cursor shape as repository listings, plus
|
|
@@ -203,3 +217,456 @@ import { parseHarnessTarget, requiredEnvironmentValue } from "auto-harness-clien
|
|
|
203
217
|
const target = parseHarnessTarget(process.env.HARNESS_TARGET);
|
|
204
218
|
const apiKey = requiredEnvironmentValue(process.env, "HARNESS_API_KEY");
|
|
205
219
|
```
|
|
220
|
+
|
|
221
|
+
## CLI: `auto-harness`
|
|
222
|
+
|
|
223
|
+
This package also ships a small operator CLI, `auto-harness`, for scripting and debugging
|
|
224
|
+
against a control plane from a shell:
|
|
225
|
+
|
|
226
|
+
```sh
|
|
227
|
+
npx auto-harness-client whoami
|
|
228
|
+
# or, once installed:
|
|
229
|
+
auto-harness whoami
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Configuration
|
|
233
|
+
|
|
234
|
+
The CLI never touches your repository config or the host daemon's persisted env file — it only
|
|
235
|
+
reads what you pass it:
|
|
236
|
+
|
|
237
|
+
| Setting | Flag | Environment variable(s) |
|
|
238
|
+
| -------------------- | ----------------------- | --------------------------------------------- |
|
|
239
|
+
| Base URL | `--api-url <url>` | `HARNESS_API_URL` (alias: `HARNESS_API_HTTP`) |
|
|
240
|
+
| API key | — | `HARNESS_API_KEY` |
|
|
241
|
+
| API key, from a file | `--api-key-file <path>` | `HARNESS_API_KEY_FILE` |
|
|
242
|
+
| Allow plain HTTP | `--allow-insecure-http` | — |
|
|
243
|
+
|
|
244
|
+
A flag always wins over its environment variable. `HARNESS_API_URL`/`HARNESS_API_HTTP` are the
|
|
245
|
+
same two variables the host daemon itself already accepts, so operators typically have one set.
|
|
246
|
+
An `--api-key-file`/`HARNESS_API_KEY_FILE` value is read and trimmed of surrounding whitespace.
|
|
247
|
+
|
|
248
|
+
**There is no `--api-key` flag.** A key passed on the command line lands in `ps` output and shell
|
|
249
|
+
history for the life of the process and the life of the shell's history file. Use the
|
|
250
|
+
`HARNESS_API_KEY` environment variable, or point `--api-key-file` / `HARNESS_API_KEY_FILE` at a
|
|
251
|
+
file holding it; passing `--api-key` fails immediately with a usage error telling you so.
|
|
252
|
+
|
|
253
|
+
Exit codes: `0` success, `1` an API/HTTP failure (or a failed `doctor` check), `2` a usage or
|
|
254
|
+
configuration error.
|
|
255
|
+
|
|
256
|
+
#### Admin bootstrap: `--admin-password-stdin`
|
|
257
|
+
|
|
258
|
+
Every API key is minted by `service-account create`, which itself requires being authenticated —
|
|
259
|
+
so the very first maintainer or service-account key can only come from an admin, and admin exists
|
|
260
|
+
only as a username/password, not an API key. `--admin-password-stdin` (with `--admin-username
|
|
261
|
+
<name>`, default `admin`) logs in as that admin instead of using an API key: it reads the password
|
|
262
|
+
from stdin (one trailing newline stripped), `POST`s it once to `/auth/login`, and carries the
|
|
263
|
+
returned session cookie on every later request for the rest of the invocation. The password never
|
|
264
|
+
touches argv, shell history, or output, and the login response body — which carries the
|
|
265
|
+
principal — is deliberately never read or printed.
|
|
266
|
+
|
|
267
|
+
```sh
|
|
268
|
+
aws ssm get-parameter --name /auto-harness/admin-password --with-decryption \
|
|
269
|
+
--query Parameter.Value --output text \
|
|
270
|
+
| auto-harness --admin-password-stdin service-account create --name ci --role operator --print-key
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
It cannot be combined with an API key (`--api-key-file`, `HARNESS_API_KEY`, or
|
|
274
|
+
`HARNESS_API_KEY_FILE` all make the identity ambiguous) or with a command that also reads stdin
|
|
275
|
+
for its own input (`api --body-file -`, `host inventory set --file -`) — both are usage errors
|
|
276
|
+
(exit 2) before any request is made. A rejected password is `error: admin login failed (HTTP 401)`
|
|
277
|
+
(exit 1), never anything about the password itself. `whoami` and `doctor` work in this mode too,
|
|
278
|
+
reporting the admin identity instead of an API key's role.
|
|
279
|
+
|
|
280
|
+
### `auto-harness api <METHOD> <path>`
|
|
281
|
+
|
|
282
|
+
A generic escape hatch for any route the control plane exposes. Both `/hosts` and
|
|
283
|
+
`/api/v1/hosts` are accepted (a leading `/api/v1` is stripped). `--body` takes inline JSON;
|
|
284
|
+
`--body-file <path>` reads a file, and `--body-file -` reads stdin. A `204` or otherwise empty
|
|
285
|
+
response prints nothing; anything else is pretty-printed JSON on stdout.
|
|
286
|
+
|
|
287
|
+
```sh
|
|
288
|
+
auto-harness api GET /hosts
|
|
289
|
+
auto-harness api POST /repositories --body '{"name":"org/repo","url":"https://github.com/org/repo"}'
|
|
290
|
+
echo '{"prompt":"Review the latest changes"}' | auto-harness api POST /sessions --body-file -
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### `auto-harness whoami [--json]`
|
|
294
|
+
|
|
295
|
+
`GET /auth/me`, printing only an allowlist of fields (`id`, `kind`, `username`, `name`, `role`,
|
|
296
|
+
`capabilities`, `boundHostId`, `allowedRepositoryIds`) — never `passwordHash`, `apiKeyHash`, or
|
|
297
|
+
anything else the API response happens to carry.
|
|
298
|
+
|
|
299
|
+
```sh
|
|
300
|
+
auto-harness whoami
|
|
301
|
+
auto-harness whoami --json
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### `auto-harness doctor`
|
|
305
|
+
|
|
306
|
+
Runs a handful of independent checks and reports each as `ok`, `warn`, or `fail` with a one-line
|
|
307
|
+
reason, exiting `1` if any check `fail`s:
|
|
308
|
+
|
|
309
|
+
- **url** — `fail`s for a plain `http://` base URL unless `--allow-insecure-http` is set; `warn`s
|
|
310
|
+
for a raw `*.execute-api.*.amazonaws.com` URL, which bypasses CloudFront (and the ingress token
|
|
311
|
+
CloudFront injects), so requests against it will be rejected.
|
|
312
|
+
- **reachability** — `GET /health` (at the site root, not under `/api/v1`) expecting HTTP 200 and
|
|
313
|
+
`{"ok":true}`.
|
|
314
|
+
- **auth** — if an API key is configured, `GET /auth/me`; `warn`s instead, without making the
|
|
315
|
+
call, when no key is configured. A `401` reports "API key rejected".
|
|
316
|
+
|
|
317
|
+
```sh
|
|
318
|
+
auto-harness doctor
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### `auto-harness host <subcommand>`
|
|
322
|
+
|
|
323
|
+
Operator commands for the host fleet. Every call goes through `client.request()` against the
|
|
324
|
+
same routes `auto-harness api` would hit — nothing here is a special path.
|
|
325
|
+
|
|
326
|
+
#### `auto-harness host list [--online | --offline] [--limit N] [--cursor C] [--all] [--json]`
|
|
327
|
+
|
|
328
|
+
`GET /hosts`, printing one line per host (`hostId`, `online`/`offline`, and `draining` when
|
|
329
|
+
true). `--online`/`--offline` filter server-side (mutually exclusive — passing both is a usage
|
|
330
|
+
error); `--limit`/`--cursor` page manually. If the page has a `nextCursor` and `--all` was not
|
|
331
|
+
passed, a final line prints it so you can continue.
|
|
332
|
+
|
|
333
|
+
`--all` follows `nextCursor` itself and prints every host across pages (or `{ items: [...all] }`
|
|
334
|
+
with `--json`). Per this repo's [list/history invariant](../../docs/plan.md#5-invariants), it
|
|
335
|
+
never collects pages without bound: it stops after 20 pages and warns on stderr if there was
|
|
336
|
+
still more, rather than silently truncating or looping forever.
|
|
337
|
+
|
|
338
|
+
```sh
|
|
339
|
+
auto-harness host list --online
|
|
340
|
+
auto-harness host list --all --json
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
#### `auto-harness host drain <hostId> [--json]`
|
|
344
|
+
|
|
345
|
+
`POST /hosts/drain` with `{"hostId": "<id>"}` in the JSON body — **the host id is never in the
|
|
346
|
+
path** (`POST /hosts/<id>/drain` does not exist). Prints how many sessions are still running and
|
|
347
|
+
their ids. A `409 CONFLICT` means the host's connection changed mid-request; the normal error is
|
|
348
|
+
printed, followed by a one-line hint that retrying is safe.
|
|
349
|
+
|
|
350
|
+
#### `auto-harness host resume <hostId> [--json]`
|
|
351
|
+
|
|
352
|
+
`POST /hosts/resume`, same body-param shape as `drain`. Idempotent — safe to run on a host that
|
|
353
|
+
is not currently draining, which returns 200 rather than an error.
|
|
354
|
+
|
|
355
|
+
#### `auto-harness host inventory get <hostId> [--json]`
|
|
356
|
+
|
|
357
|
+
`GET /hosts/<hostId>/inventory`. Human output shows the record's `version`, each attached
|
|
358
|
+
repository (`id`, `path`, worktree count), and the provider account count; `--json` prints the
|
|
359
|
+
raw record.
|
|
360
|
+
|
|
361
|
+
#### `auto-harness host inventory set <hostId> --file <path|->`
|
|
362
|
+
|
|
363
|
+
`PUT /hosts/<hostId>/inventory` from a JSON file (or `-` for stdin), sent **verbatim** — this is
|
|
364
|
+
an authoritative write that replaces the entire inventory record, not a merge. Two traps this
|
|
365
|
+
command guards against:
|
|
366
|
+
|
|
367
|
+
- **Omitting `version` silently disables optimistic concurrency**: the server falls back to
|
|
368
|
+
whatever version is currently stored, so a concurrent edit can be overwritten with no error.
|
|
369
|
+
This command refuses (exit 2) to send a document with no integer `version` field, and tells you
|
|
370
|
+
to start from `auto-harness host inventory get <hostId> --json`.
|
|
371
|
+
- A version that has moved since you read it comes back as `409 CONFLICT` — re-read and reapply
|
|
372
|
+
rather than retrying the same body.
|
|
373
|
+
|
|
374
|
+
Because the write replaces the whole record, omitting `providerAccounts` wipes the host's
|
|
375
|
+
provider routing — always build the new document from a fresh `inventory get --json`, editing
|
|
376
|
+
only what you mean to change.
|
|
377
|
+
|
|
378
|
+
#### `auto-harness host repo add <hostId> <repositoryId> --path <path> [--worktree <id>=<path>]... [--default-branch <branch>] [--dry-run] [--json]`
|
|
379
|
+
|
|
380
|
+
Attaches an already-registered repository (create it first with `auto-harness repo add`) to a
|
|
381
|
+
host's inventory — the counterpart of `host repo rm`. Same safe read-modify-write shape:
|
|
382
|
+
|
|
383
|
+
1. `GET /repositories/<repositoryId>` — a 404 fails with a clear message naming the id.
|
|
384
|
+
`--default-branch` defaults to that repository's own `defaultBranch` when omitted.
|
|
385
|
+
2. `GET` the host's inventory. If this repository id is already attached, it fails (exit 1)
|
|
386
|
+
naming the path it is attached at — this command never overwrites an existing attachment;
|
|
387
|
+
remove it first with `host repo rm`.
|
|
388
|
+
3. `--dry-run` prints what would be attached and exits without writing.
|
|
389
|
+
4. Otherwise builds the new document as the record exactly as read, with the new entry appended
|
|
390
|
+
to `repositories` and the read `version` kept — every other field, including
|
|
391
|
+
`providerAccounts`, is preserved untouched.
|
|
392
|
+
5. On a `409` (someone else wrote first) it re-reads and re-applies, up to 3 attempts total. If
|
|
393
|
+
the repository is now attached at the same path, that is treated as convergence (another
|
|
394
|
+
writer already did what this call wanted) rather than an error; a different path fails,
|
|
395
|
+
naming what is actually attached. Any other error status is not retried.
|
|
396
|
+
6. On success it prints what was attached and the version transition (e.g. `version 29 → 30`).
|
|
397
|
+
|
|
398
|
+
`--worktree <id>=<path>` is repeatable and adds one worktree entry per occurrence (split on the
|
|
399
|
+
first `=`, so a path containing `=` still parses); a malformed value or a repeated id is a usage
|
|
400
|
+
error (exit 2) before any request is made. Each becomes `{ id, name: id, path, labels: [] }` —
|
|
401
|
+
`name` mirrors `id`, and the worktree's own name/slug shape is validated server-side.
|
|
402
|
+
|
|
403
|
+
```sh
|
|
404
|
+
auto-harness host repo add host-1 repo-1 --path /repos/repo-1 --dry-run
|
|
405
|
+
auto-harness host repo add host-1 repo-1 --path /repos/repo-1 --worktree wt-1=/repos/repo-1/wt-1
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
#### `auto-harness host repo rm <hostId> <repositoryId> [--dry-run] [--json]`
|
|
409
|
+
|
|
410
|
+
Detaches one repository from a host. This exists because the only prior way to do it was to
|
|
411
|
+
hand-assemble a full `PUT` of the inventory record — easy to get wrong, especially for
|
|
412
|
+
`providerAccounts`, which a naive PUT can silently drop. This command does a safe
|
|
413
|
+
read-modify-write instead:
|
|
414
|
+
|
|
415
|
+
1. `GET` the inventory.
|
|
416
|
+
2. If no repository with that id is attached, it fails (exit 1) and lists the ids that _are_
|
|
417
|
+
attached — nothing is written.
|
|
418
|
+
3. Builds the new document as the record exactly as read, with only that repository removed from
|
|
419
|
+
`repositories` and the read `version` kept — every other field, including
|
|
420
|
+
`providerAccounts`, is preserved untouched. (The control plane also re-adds any repository a
|
|
421
|
+
scoped API key can't see before persisting, so this read-modify-write is safe even when the
|
|
422
|
+
caller's key is scoped to a subset of repositories.)
|
|
423
|
+
4. `--dry-run` prints what would be removed — the repository's id, path, and the ids of its
|
|
424
|
+
worktrees — and exits without writing.
|
|
425
|
+
5. On a `409` (someone else wrote first) it re-reads and re-applies, up to 3 attempts total, then
|
|
426
|
+
fails saying the inventory kept changing. Any other error status is not retried.
|
|
427
|
+
6. On success it prints what was removed and the version transition (e.g. `version 29 → 30`).
|
|
428
|
+
|
|
429
|
+
**Removing a repository from the inventory also removes its worktrees** — they are a projection
|
|
430
|
+
of the repository, not independent records. Deleting the repository record itself (as opposed to
|
|
431
|
+
detaching it from this host's inventory) is a separate operation.
|
|
432
|
+
|
|
433
|
+
```sh
|
|
434
|
+
auto-harness host repo rm host-1 repo-1 --dry-run
|
|
435
|
+
auto-harness host repo rm host-1 repo-1
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
#### `auto-harness host smoke <hostId> --repo-path <path> --provider <id|name> [--provider <id|name>]... [--timeout <seconds>] [--json]`
|
|
439
|
+
|
|
440
|
+
Proves a host can run a real provider-routed session end to end, then cleans up after itself —
|
|
441
|
+
useful after standing up a new host, or after touching its execution profiles, without having to
|
|
442
|
+
open the control plane UI. It never needs to be run against production to be trusted: the CI
|
|
443
|
+
end-to-end suite (`e2e/control/cli-host-smoke.spec.ts`) exercises this exact command against a
|
|
444
|
+
real API, a real in-process host daemon, and a real (`echo`-backed) provider on every change.
|
|
445
|
+
|
|
446
|
+
**Preconditions this command cannot check itself:** `--repo-path` names a directory on the
|
|
447
|
+
**host**, not on whatever machine runs this CLI — they may be different machines entirely — so
|
|
448
|
+
this command never calls `existsSync` or otherwise inspects it locally. That path must already
|
|
449
|
+
be a git repository with a clean `main` checkout, and its `.gitignore` must exclude
|
|
450
|
+
`.worktrees/`, since this command attaches one worktree at `<repo-path>/.worktrees/<name>`,
|
|
451
|
+
named after the throwaway repository. Worktree names are unique across the whole fleet, so each
|
|
452
|
+
run uses a fresh name and concurrent smokes on different hosts never collide.
|
|
453
|
+
|
|
454
|
+
What it does, in order, always tearing down in a `finally` no matter which step failed:
|
|
455
|
+
|
|
456
|
+
1. **Create** a throwaway repository (`POST /repositories`) with a unique, valid (slug) name.
|
|
457
|
+
Its `url` is a syntactically valid but inert `https://example.test/<name>.git` placeholder —
|
|
458
|
+
the daemon dispatches sessions against the host-local path this same run attaches, never a
|
|
459
|
+
repository's `url`, so nothing ever needs to resolve or dial it.
|
|
460
|
+
2. **Attach** it to `<hostId>`'s inventory via the same `attachRepository` read-modify-write
|
|
461
|
+
`host repo add` uses, with one worktree named after the repository.
|
|
462
|
+
3. **For each `--provider`, in order** (accepts an id or a name, exactly like `session create`):
|
|
463
|
+
create a session targeting it with the prompt `Reply with exactly: <MARKER>` (`MARKER` is
|
|
464
|
+
random and unique per run), wait for it, then fetch one page of its logs. A provider `PASS`es
|
|
465
|
+
only if the session `completed` with `exitCode` `0` **and** its stdout contains `MARKER`.
|
|
466
|
+
- **The host racing its own inventory poll:** a host only learns about a newly attached
|
|
467
|
+
repository through its own periodic poll — there is no push-on-write — so the very first
|
|
468
|
+
session against the repository this command _just_ attached routinely loses that race in
|
|
469
|
+
any real deployment, not only here: the host rejects it with a `setup_failed` session whose
|
|
470
|
+
`errorMessage` is exactly `Unknown repository: <id>` (`services/host-daemon/src/
|
|
471
|
+
worktree-manager.ts`), which the control plane never retries on its own. This command
|
|
472
|
+
recognizes that one exact, unambiguous shape and retries with it doubling backoff (up to 5
|
|
473
|
+
attempts, capped at 16s between attempts) — bounded by the same `--timeout` deadline as
|
|
474
|
+
everything else — before giving up and reporting it like any other failure. Any other
|
|
475
|
+
`setup_failed` (a real checkout/setup problem) is never retried.
|
|
476
|
+
- **Usage limits:** the control plane does not fail a session whose provider account hit its
|
|
477
|
+
usage limit — it requeues the session (`errorCode: "usage_limit"`) and puts the account on
|
|
478
|
+
cooldown instead (see `services/api/src/session-transition-planner.ts`'s
|
|
479
|
+
`planUsageLimit()`). This command checks for that on every poll (not only a status change,
|
|
480
|
+
since a requeued session's status can go right back to `"queued"` with no visible
|
|
481
|
+
transition) and fails that one provider immediately with "provider account hit its usage
|
|
482
|
+
limit" — it never sits out the rest of `--timeout` waiting for a cooldown to end.
|
|
483
|
+
- **On timeout**, the session is cancelled (this command owns it) and the provider fails with
|
|
484
|
+
a hint keyed off its last status: stuck in `queued` usually means no online host advertises
|
|
485
|
+
a ready execution profile for that provider's account (`HARNESS_EXECUTION_PROFILES`), or
|
|
486
|
+
nothing is running the scheduler.
|
|
487
|
+
- **A genuine polling failure** (a `getSession` network error or a `5xx`, not the usage-limit
|
|
488
|
+
or setup-failure shapes above) fails only that one provider with `session_wait_failed` — it
|
|
489
|
+
never aborts the run, so every remaining `--provider` still gets its own attempt. The
|
|
490
|
+
session is left for teardown's own safety net to cancel.
|
|
491
|
+
4. **Teardown**, always: cancel any session this run created that isn't already terminal, detach
|
|
492
|
+
the repository (only if it was actually attached), then `DELETE` it. The delete retries a `409`
|
|
493
|
+
(the worktree/host-inventory projection the delete guard reads can lag the detach write
|
|
494
|
+
teardown just made) and a transient failure (`5xx`, or a network/timeout error) a few times
|
|
495
|
+
with a short backoff before giving up; once a transient failure has actually happened, a later
|
|
496
|
+
`404` is treated as success (the delete most likely landed and the response never arrived). If
|
|
497
|
+
a session could not be cancelled (anything other than a `409`, which just means it was already
|
|
498
|
+
terminal), or the repository is left behind, this command exits `1` and prints the exact
|
|
499
|
+
`session cancel` and/or `host repo rm`/`repo rm` commands needed to finish cleanup by hand.
|
|
500
|
+
|
|
501
|
+
Exit `0` only when every provider passed **and** teardown itself succeeded; `1` otherwise (a
|
|
502
|
+
malformed invocation is the usual usage-error exit `2`, before any of this runs). Progress
|
|
503
|
+
(`ok`/`FAIL` per step) goes to stderr as it happens; stdout stays a clean final summary — one
|
|
504
|
+
`PASS`/`FAIL` line per provider plus an overall line — or, with `--json`, the full structured
|
|
505
|
+
result (`hostId`, `repositoryId`, `providers[]`, `teardown` — including any `uncancelledSessionIds`
|
|
506
|
+
— `ok`).
|
|
507
|
+
|
|
508
|
+
```sh
|
|
509
|
+
auto-harness host smoke host-1 --repo-path /repos/repo-1 --provider claude
|
|
510
|
+
auto-harness host smoke host-1 --repo-path /repos/repo-1 --provider claude --provider codex --timeout 600
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
### `auto-harness repo <subcommand>`
|
|
514
|
+
|
|
515
|
+
Repository CRUD, straight against the same routes `auto-harness api` would hit.
|
|
516
|
+
|
|
517
|
+
#### `auto-harness repo add --name <name> --url <url> [--default-branch <branch>] [--json]`
|
|
518
|
+
|
|
519
|
+
`POST /repositories`. The response is the created repository record itself — there is no
|
|
520
|
+
`{ repository }` wrapper, matching the shape `GET /repositories/<id>` returns — so `--json`
|
|
521
|
+
prints it verbatim. Human output is one line, `<id> <name>`, mirroring `repo list`'s per-line
|
|
522
|
+
format. `--default-branch` defaults server-side to `main` when omitted. The server also enforces
|
|
523
|
+
uniqueness and URL/name format; a rejected value comes back as the normal `error:` line (exit 1)
|
|
524
|
+
rather than a client-side re-check.
|
|
525
|
+
|
|
526
|
+
```sh
|
|
527
|
+
auto-harness repo add --name org/repo --url https://github.com/org/repo
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
#### `auto-harness repo list [--limit N] [--cursor C] [--all] [--json]`
|
|
531
|
+
|
|
532
|
+
`GET /repositories`, printing one line per repository (id, name, and status/url when present).
|
|
533
|
+
Paging works exactly like `host list`: `--limit`/`--cursor` page manually, and `--all` follows
|
|
534
|
+
`nextCursor` itself, capped at 20 pages with a stderr warning if more remain.
|
|
535
|
+
|
|
536
|
+
```sh
|
|
537
|
+
auto-harness repo list --all
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
#### `auto-harness repo rm <repositoryId> [--json]`
|
|
541
|
+
|
|
542
|
+
`DELETE /repositories/<id>`. The point of this command is that a refusal explains itself: a `409`
|
|
543
|
+
means other records still reference the repository, and rather than dumping that as raw JSON,
|
|
544
|
+
each blocking dependency gets the concrete next step to actually clear it:
|
|
545
|
+
|
|
546
|
+
| Dependency kind | Next step |
|
|
547
|
+
| ----------------------------- | ------------------------------------------------------------------------------------ |
|
|
548
|
+
| `schedule` | `auto-harness api DELETE /schedules/<id>` |
|
|
549
|
+
| `session` (live) | wait for it, or `auto-harness api POST /sessions/<id>/cancel` |
|
|
550
|
+
| `session-drain` | `auto-harness api POST /repositories/<repositoryId>/session-drains/<id>/release` |
|
|
551
|
+
| `host-inventory` | `auto-harness host repo rm <hostId> <repositoryId>` |
|
|
552
|
+
| `worktree` | same as `host-inventory` — worktrees go with detaching the repository from that host |
|
|
553
|
+
| `integration: github-ingress` | remove this repository's binding from the GitHub ingress configuration |
|
|
554
|
+
| `integration` (other) | remove or retarget that integration |
|
|
555
|
+
| anything else | printed as `<kind> <id>`, so a new server-side kind still displays |
|
|
556
|
+
|
|
557
|
+
Exits `1` on a `409` (after printing the server's message and every hint). With `--json`, a `409`
|
|
558
|
+
prints `{ "deleted": false, "dependencies": [...], "hints": [...] }`; success prints
|
|
559
|
+
`{ "deleted": true, "id": "..." }`.
|
|
560
|
+
|
|
561
|
+
```sh
|
|
562
|
+
auto-harness repo rm repo-1
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
### `auto-harness service-account <subcommand>`
|
|
566
|
+
|
|
567
|
+
Service-account lifecycle. Every account's real permissions are its `role` and grants
|
|
568
|
+
(`boundHostId`/`allowedRepositoryIds`) — the CLI never invents a route for anything else.
|
|
569
|
+
|
|
570
|
+
#### `auto-harness service-account list [--limit N] [--cursor C] [--all] [--json]`
|
|
571
|
+
|
|
572
|
+
`GET /auth/service-accounts`, printing id, name, role, `boundHostId` (if set), and `createdAt`.
|
|
573
|
+
Items are already server-sanitized, but this still filters them through the same allowlist as
|
|
574
|
+
`whoami`/`doctor` as defense in depth — in **both** human and `--json` output, so a field outside
|
|
575
|
+
the allowlist can never leak through either mode. Paging matches `host list`/`repo list`.
|
|
576
|
+
|
|
577
|
+
#### `auto-harness service-account create --name <name> --role <role> [--bound-host <hostId>] [--repositories <id,id,...>] (--key-file <path> | --print-key) [--json]`
|
|
578
|
+
|
|
579
|
+
`POST /auth/service-accounts`. The response's API key is shown **exactly once** — the server
|
|
580
|
+
stores only a hash — so this command forces a deliberate choice about where that one-time value
|
|
581
|
+
goes:
|
|
582
|
+
|
|
583
|
+
- **`--key-file <path>`** writes the key to a new file, created with `O_CREAT | O_EXCL` and mode
|
|
584
|
+
`0600` — it never overwrites an existing file (checked before the request is even made, and
|
|
585
|
+
enforced atomically when the file is actually written). Only the account id and
|
|
586
|
+
`key written to <path>` are printed; if the write fails _after_ the account was already
|
|
587
|
+
created, the error names the account id and tells you to run `service-account rm` on it, since
|
|
588
|
+
the key itself is unrecoverable at that point.
|
|
589
|
+
- **`--print-key`** writes _only_ the key to stdout, on its own line, so
|
|
590
|
+
`KEY=$(auto-harness service-account create … --print-key)` works; the human-readable account
|
|
591
|
+
summary goes to stderr instead. Combining this with `--json` is a usage error — the stdout
|
|
592
|
+
contract would be ambiguous.
|
|
593
|
+
|
|
594
|
+
Exactly one of `--key-file`/`--print-key` is required; neither/both is a usage error (exit 2,
|
|
595
|
+
no request made). `--repositories a,b,c` maps to `allowedRepositoryIds`. The server validates
|
|
596
|
+
`--role` and any grants; an invalid one surfaces as its own `400`, unchanged.
|
|
597
|
+
|
|
598
|
+
```sh
|
|
599
|
+
auto-harness service-account create --name ci --role operator --key-file ./ci.key
|
|
600
|
+
KEY=$(auto-harness service-account create --name ci --role operator --print-key)
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
#### `auto-harness service-account rm <id> [--json]`
|
|
604
|
+
|
|
605
|
+
`DELETE /auth/service-accounts/<id>`. A `409` prints each blocking dependency generically, as
|
|
606
|
+
`<kind> <id>` — unlike `repo rm`, there is no per-kind hint here.
|
|
607
|
+
|
|
608
|
+
```sh
|
|
609
|
+
auto-harness service-account rm svc-1
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
### `auto-harness session <subcommand>`
|
|
613
|
+
|
|
614
|
+
Session lifecycle for the operator CLI. `--provider`/`--command` accept either a catalog id or a
|
|
615
|
+
name — see below.
|
|
616
|
+
|
|
617
|
+
#### `auto-harness session create --repo <repositoryId> (--provider <id|name> | --command <id|name>) --prompt <text> [--timeout <seconds>] [--ref <ref>] [--concurrency-id <id>] [--wait [--wait-timeout <seconds>]] [--json]`
|
|
618
|
+
|
|
619
|
+
`POST /sessions`. Exactly one of `--provider`/`--command` is required. Each accepts either a
|
|
620
|
+
catalog id or a name: the CLI lists the relevant catalog once and checks for an exact id match;
|
|
621
|
+
on a miss it sends the value as `providerName`/`commandName` and lets `createSession()`'s own
|
|
622
|
+
name resolution handle it — so an unresolvable or ambiguous name fails with the same
|
|
623
|
+
`AutoHarnessError` (`UNKNOWN_PROVIDER_NAME`, `AMBIGUOUS_PROVIDER_NAME`, ...) documented above,
|
|
624
|
+
never a separate "unknown id" error. `--timeout` defaults to `600` seconds (matching the
|
|
625
|
+
create-session form's own default) since the server requires it but sets no default itself; the
|
|
626
|
+
server's own ceiling (7 days) is enforced there, not duplicated here.
|
|
627
|
+
|
|
628
|
+
With `--wait`, polls the new session until it reaches a terminal status (`completed`, `failed`,
|
|
629
|
+
`cancelled`, or `timed_out`), printing each status change to **stderr** so stdout stays the final
|
|
630
|
+
session record. `--wait-timeout <seconds>` bounds the wait (default: the session's own
|
|
631
|
+
`--timeout`); on expiry the CLI prints that the session is still running and its id, then exits 1
|
|
632
|
+
— it never cancels the session. Exit 0 only when the session `completed` with `exitCode` exactly
|
|
633
|
+
`0`; every other terminal status, or a wait timeout, exits 1.
|
|
634
|
+
|
|
635
|
+
```sh
|
|
636
|
+
auto-harness session create --repo repo-1 --command claude-print --prompt "Review the diff" --wait
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
#### `auto-harness session get <sessionId> [--json]`
|
|
640
|
+
|
|
641
|
+
`GET /sessions/<id>`, via the library's `getSession()`. Prints one line: id, status, and — only
|
|
642
|
+
when present — `exitCode`, `errorCode`, `errorMessage` (session records use `errorCode`/
|
|
643
|
+
`errorMessage`, never a top-level `error`, and `completedAt`, never `finishedAt`). `--json` prints
|
|
644
|
+
the full record, including `result.summary` when the session set one.
|
|
645
|
+
|
|
646
|
+
```sh
|
|
647
|
+
auto-harness session get session-1
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
#### `auto-harness session logs <sessionId> [--limit N] [--cursor C] [--json]`
|
|
651
|
+
|
|
652
|
+
`GET /sessions/<id>/logs` — a bounded page, printed once; this command never loops over every
|
|
653
|
+
page (see `docs/plan.md` invariant 13). Unlike `repo list`/`host list`, the logs endpoint has no
|
|
654
|
+
`nextCursor`; its only continuation knob is `since`, a whole ISO-8601 timestamp (exclusive),
|
|
655
|
+
which this CLI exposes as `--cursor` for a pagination vocabulary consistent with the other list
|
|
656
|
+
commands. A full page (`items.length === limit`, default `1000`) prints a hint to pass the last
|
|
657
|
+
line's own timestamp as the next `--cursor` — which, because `since` excludes an entire
|
|
658
|
+
timestamp rather than one row, also skips any other record sharing that exact timestamp. This is
|
|
659
|
+
the bounded contract the endpoint offers today; there is no exact row cursor over REST.
|
|
660
|
+
|
|
661
|
+
```sh
|
|
662
|
+
auto-harness session logs session-1 --limit 200
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
#### `auto-harness session cancel <sessionId> [--json]`
|
|
666
|
+
|
|
667
|
+
`POST /sessions/<id>/cancel`, via the library's `cancelSession()`. Prints the same one-line
|
|
668
|
+
summary as `session get`.
|
|
669
|
+
|
|
670
|
+
```sh
|
|
671
|
+
auto-harness session cancel session-1
|
|
672
|
+
```
|
package/package.json
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auto-harness-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Dependency-free client for the Auto Harness automation API",
|
|
5
|
+
"license": "MIT",
|
|
5
6
|
"repository": {
|
|
6
7
|
"type": "git",
|
|
7
8
|
"url": "git+https://github.com/jonathanong/auto-harness.git",
|
|
8
9
|
"directory": "modules/client"
|
|
9
10
|
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"auto-harness": "./src/cli/index.js"
|
|
13
|
+
},
|
|
10
14
|
"files": [
|
|
11
15
|
"LICENSE",
|
|
12
16
|
"README.md",
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { isLoopbackOrigin } from "../loopback.js";
|
|
2
|
+
import { CliConfigError, CliUsageError } from "./cli-errors.js";
|
|
3
|
+
import { readStdin } from "./read-stdin.js";
|
|
4
|
+
|
|
5
|
+
// Matches `AutoHarnessClient`'s default `requestTimeoutMs`.
|
|
6
|
+
const ADMIN_LOGIN_TIMEOUT_MS = 30_000;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Usage-error checks for `--admin-password-stdin`, run before any request. An admin login is an
|
|
10
|
+
* alternate *identity*, not an additional one, so it is ambiguous to also have an API key
|
|
11
|
+
* configured — and it needs stdin for the password, so it conflicts with any command that also
|
|
12
|
+
* reads stdin for its own input (pass that command's own description as `stdinClaimedBy`, e.g.
|
|
13
|
+
* `"api --body-file -"`).
|
|
14
|
+
*/
|
|
15
|
+
export function checkAdminLoginUsage(flags, env, stdinClaimedBy) {
|
|
16
|
+
if (!flags["--admin-password-stdin"]) return;
|
|
17
|
+
// Presence, not truthiness: an empty `--api-key-file=` is still an explicit (broken) request
|
|
18
|
+
// for API-key identity, and must not slip past this ambiguity check.
|
|
19
|
+
if (Object.hasOwn(flags, "--api-key-file") || env.HARNESS_API_KEY || env.HARNESS_API_KEY_FILE) {
|
|
20
|
+
throw new CliUsageError(
|
|
21
|
+
"--admin-password-stdin cannot be combined with an API key (--api-key-file, " +
|
|
22
|
+
"HARNESS_API_KEY, or HARNESS_API_KEY_FILE); pick one identity",
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
if (stdinClaimedBy) {
|
|
26
|
+
throw new CliUsageError(
|
|
27
|
+
`--admin-password-stdin and ${stdinClaimedBy} both need stdin; pass one of them another way`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `POST /auth/login` with the password read from stdin (one trailing newline stripped), then
|
|
34
|
+
* returns a `fetch` that carries the session cookie on every later request. The login response
|
|
35
|
+
* body carries the principal but is deliberately never read — see the CLI spec — so a non-200 is
|
|
36
|
+
* reported by status code alone, never by inspecting or printing the body.
|
|
37
|
+
*/
|
|
38
|
+
export async function loginAsAdmin(io, baseUrl, username, allowInsecureHttp = false) {
|
|
39
|
+
// The password is a credential, so it gets the same transport rule the client applies to an
|
|
40
|
+
// API key (`assertSecureTransport` in loopback.js): https, or plain http only to a loopback
|
|
41
|
+
// host and only with --allow-insecure-http. Admin mode sets no API key, so the client's own
|
|
42
|
+
// check never fires here. Checked before stdin is read, so a refused URL never consumes the
|
|
43
|
+
// password, and the session cookie that comes back is bound to the same transport.
|
|
44
|
+
if (!baseUrl.startsWith("https://") && !(allowInsecureHttp && isLoopbackOrigin(baseUrl))) {
|
|
45
|
+
throw new CliConfigError(
|
|
46
|
+
"--admin-password-stdin requires an https API URL (--allow-insecure-http only permits " +
|
|
47
|
+
"plain HTTP to a loopback host, e.g. http://127.0.0.1 or http://localhost)",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const rawPassword = await readStdin(io.stdin);
|
|
51
|
+
const password = rawPassword.endsWith("\n") ? rawPassword.slice(0, -1) : rawPassword;
|
|
52
|
+
const origin = baseUrl.replace(/\/$/, "").replace(/\/api\/v1$/, "");
|
|
53
|
+
// A raw fetch, so it needs its own bound — the same one `doctor`'s /health probe uses —
|
|
54
|
+
// or a stalled server hangs the command before `AutoHarnessClient`'s timeout ever applies.
|
|
55
|
+
const response = await io.fetch(`${origin}/api/v1/auth/login`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "content-type": "application/json" },
|
|
58
|
+
body: JSON.stringify({ username, password }),
|
|
59
|
+
signal: (io.timeoutSignal ?? AbortSignal.timeout)(ADMIN_LOGIN_TIMEOUT_MS),
|
|
60
|
+
});
|
|
61
|
+
if (response.status !== 200) throw new Error(`admin login failed (HTTP ${response.status})`);
|
|
62
|
+
const cookieHeader = response.headers
|
|
63
|
+
.getSetCookie()
|
|
64
|
+
.map((header) => header.split(";")[0].trim())
|
|
65
|
+
.join("; ");
|
|
66
|
+
return {
|
|
67
|
+
username,
|
|
68
|
+
fetch: (url, init = {}) =>
|
|
69
|
+
io.fetch(url, { ...init, headers: { ...init.headers, cookie: cookieHeader } }),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Defense in depth. `GET /auth/me` spreads the server's principal object into its response.
|
|
2
|
+
// That principal is sanitized today — verified live, it carries no password or API-key hash —
|
|
3
|
+
// but the stored records behind it do hold `passwordHash` / `apiKeyHash`, and the client cannot
|
|
4
|
+
// see a future server change that lets one through. So `whoami` and `doctor` print only these
|
|
5
|
+
// named fields rather than trusting the response shape.
|
|
6
|
+
const ALLOWED_PRINCIPAL_FIELDS = [
|
|
7
|
+
"id",
|
|
8
|
+
"kind",
|
|
9
|
+
"username",
|
|
10
|
+
"name",
|
|
11
|
+
"role",
|
|
12
|
+
"capabilities",
|
|
13
|
+
"boundHostId",
|
|
14
|
+
"allowedRepositoryIds",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function allowlistPrincipal(principal) {
|
|
18
|
+
const allowed = {};
|
|
19
|
+
if (!principal || typeof principal !== "object") return allowed;
|
|
20
|
+
for (const field of ALLOWED_PRINCIPAL_FIELDS) {
|
|
21
|
+
if (Object.hasOwn(principal, field)) allowed[field] = principal[field];
|
|
22
|
+
}
|
|
23
|
+
return allowed;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatWhoami(principal) {
|
|
27
|
+
const lines = [];
|
|
28
|
+
for (const field of ALLOWED_PRINCIPAL_FIELDS) {
|
|
29
|
+
if (!Object.hasOwn(principal, field)) continue;
|
|
30
|
+
const value = principal[field];
|
|
31
|
+
lines.push(`${field}: ${Array.isArray(value) ? value.join(", ") : value}`);
|
|
32
|
+
}
|
|
33
|
+
return lines.length > 0 ? `${lines.join("\n")}\n` : "(no fields returned)\n";
|
|
34
|
+
}
|
package/src/cli/args.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { CliUsageError } from "./cli-errors.js";
|
|
2
|
+
|
|
3
|
+
const REJECTED_API_KEY_MESSAGE =
|
|
4
|
+
"--api-key is not accepted on the command line (it would leak into `ps` output and shell " +
|
|
5
|
+
"history); set HARNESS_API_KEY instead, or point --api-key-file / HARNESS_API_KEY_FILE at a file";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Splits `argv` into recognized `--flag`/`--flag value`/`--flag=value` entries and positional
|
|
9
|
+
* arguments. Every `--name=value` token is split into name and value *before* any matching, so
|
|
10
|
+
* every check below — the rejected `--api-key` flag, an unknown flag, a value flag missing its
|
|
11
|
+
* value — only ever sees the flag name. This is what keeps a mistyped `--api-key=<secret>` (or
|
|
12
|
+
* any other `--unknown=<secret>`) from echoing the secret back in a "unknown flag" error.
|
|
13
|
+
*
|
|
14
|
+
* `repeatableFlags` behave like `valueFlags` (same inline-`=`/next-argument value rule, same
|
|
15
|
+
* missing-value error) except every occurrence is collected into an array at `flags[name]`,
|
|
16
|
+
* rather than the last one winning — e.g. `--worktree a=/a --worktree b=/b` yields
|
|
17
|
+
* `flags["--worktree"] === ["a=/a", "b=/b"]`. A flag not given at all is still `undefined`, not
|
|
18
|
+
* an empty array, so callers can tell "none passed" from "passed with zero values" (impossible
|
|
19
|
+
* here, but keeps the convention consistent with `valueFlags`/`booleanFlags`).
|
|
20
|
+
*/
|
|
21
|
+
export function parseFlags(
|
|
22
|
+
argv,
|
|
23
|
+
{ valueFlags = [], booleanFlags = [], repeatableFlags = [] } = {},
|
|
24
|
+
) {
|
|
25
|
+
const flags = {};
|
|
26
|
+
const positionals = [];
|
|
27
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
28
|
+
const arg = argv[i];
|
|
29
|
+
if (!arg.startsWith("--")) {
|
|
30
|
+
positionals.push(arg);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const equals = arg.indexOf("=");
|
|
34
|
+
const name = equals === -1 ? arg : arg.slice(0, equals);
|
|
35
|
+
const inlineValue = equals === -1 ? undefined : arg.slice(equals + 1);
|
|
36
|
+
if (name === "--api-key") throw new CliUsageError(REJECTED_API_KEY_MESSAGE);
|
|
37
|
+
if (valueFlags.includes(name)) {
|
|
38
|
+
const value = inlineValue !== undefined ? inlineValue : argv[(i += 1)];
|
|
39
|
+
if (value === undefined) throw new CliUsageError(`${name} requires a value`);
|
|
40
|
+
flags[name] = value;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (repeatableFlags.includes(name)) {
|
|
44
|
+
const value = inlineValue !== undefined ? inlineValue : argv[(i += 1)];
|
|
45
|
+
if (value === undefined) throw new CliUsageError(`${name} requires a value`);
|
|
46
|
+
(flags[name] ??= []).push(value);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (booleanFlags.includes(name)) {
|
|
50
|
+
if (inlineValue !== undefined) throw new CliUsageError(`${name} does not take a value`);
|
|
51
|
+
flags[name] = true;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
throw new CliUsageError(`unknown flag: ${name}`);
|
|
55
|
+
}
|
|
56
|
+
return { flags, positionals };
|
|
57
|
+
}
|