From a61558b6e00695b0384f7b45afc54fd5b0a336b2 Mon Sep 17 00:00:00 2001 From: Kurtis Jantzen Date: Thu, 10 Sep 2026 11:28:54 -0700 Subject: [PATCH 1/5] Define shared CLI command invocation catalog --- src/command-spec.ts | 85 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/command-spec.ts diff --git a/src/command-spec.ts b/src/command-spec.ts new file mode 100644 index 00000000..4a5e0b6c --- /dev/null +++ b/src/command-spec.ts @@ -0,0 +1,85 @@ +/** Supported invocation shapes shared by argument validation and help discovery. */ +export interface CommandSpec { + readonly path: readonly string[]; + readonly minArgs: number; + readonly maxArgs: number; + readonly flags: readonly string[]; +} + +export const GLOBAL_FLAGS = ["json", "help", "version", "api-url", "config", "request-id", "idempotency-key", "timeout", "beta-key", "token"] as const; + +export const BOOLEAN_FLAGS = new Set([ + "json", "help", "version", "open-dashboard", "print-url", "yes", "allow-lockout", + "no-wait", "no-transcode", "no-progress", "no-audio", "clear-tag", "safe-area", + "lint-only", "combined", "open", "contact-sheet", "no-context", "repair-config", +]); + +export const COMMAND_SPECS: readonly CommandSpec[] = [ + { path: ["account", "show"], minArgs: 0, maxArgs: 0, flags: [] }, + { path: ["agent", "enroll"], minArgs: 0, maxArgs: 0, flags: ["email", "name", "open-dashboard"] }, + { path: ["agent", "connect"], minArgs: 0, maxArgs: 0, flags: ["name", "print-url"] }, + { path: ["agent", "status"], minArgs: 0, maxArgs: 0, flags: [] }, + { path: ["agent", "disconnect"], minArgs: 0, maxArgs: 0, flags: ["yes", "allow-lockout"] }, + { path: ["dashboard"], minArgs: 0, maxArgs: 0, flags: ["print-url"] }, + { path: ["app", "pack"], minArgs: 1, maxArgs: 1, flags: ["output"] }, + { path: ["app", "upload"], minArgs: 1, maxArgs: 1, flags: ["name", "no-wait", "poll-ms"] }, + { path: ["app", "update"], minArgs: 2, maxArgs: 2, flags: ["if-match", "name", "no-wait", "poll-ms"] }, + { path: ["app", "list"], minArgs: 0, maxArgs: 0, flags: [] }, + { path: ["app", "show"], minArgs: 1, maxArgs: 1, flags: [] }, + { path: ["media", "generate"], minArgs: 0, maxArgs: 0, flags: ["prompt", "aspect-ratio", "quality", "tag"] }, + { path: ["media", "upload"], minArgs: 1, maxArgs: 1, flags: ["content-type", "tag", "no-wait", "poll-ms", "no-transcode", "codec", "max-fps", "max-edge", "webp-quality", "no-progress", "preset", "no-audio"] }, + { path: ["media", "upload-batch"], minArgs: 1, maxArgs: 1, flags: ["state", "concurrency", "no-transcode", "tag", "no-progress", "poll-ms", "codec", "max-fps", "max-edge", "webp-quality", "preset", "no-audio"] }, + { path: ["media", "show"], minArgs: 1, maxArgs: 1, flags: [] }, + { path: ["media", "download"], minArgs: 1, maxArgs: 1, flags: ["output"] }, + { path: ["media", "list"], minArgs: 0, maxArgs: 0, flags: ["tag", "primitive"] }, + { path: ["media", "update"], minArgs: 1, maxArgs: 1, flags: ["tag", "clear-tag", "if-match"] }, + { path: ["media", "delete"], minArgs: 1, maxArgs: 1, flags: ["if-match"] }, + { path: ["compose", "catalog"], minArgs: 0, maxArgs: 0, flags: [] }, + { path: ["compose", "batch"], minArgs: 1, maxArgs: 1, flags: ["output", "only", "target-width", "target-height", "safe-area", "lint-only"] }, + { path: ["compose", "render"], minArgs: 1, maxArgs: 1, flags: ["output", "combined", "target-width", "target-height", "safe-area", "open", "lint-only"] }, + { path: ["playlist", "validate"], minArgs: 1, maxArgs: 1, flags: ["lint-only"] }, + { path: ["playlist", "preview"], minArgs: 1, maxArgs: 1, flags: ["output", "frame-ms", "contact-sheet", "lint-only"] }, + { path: ["playlist", "templates"], minArgs: 0, maxArgs: 0, flags: [] }, + { path: ["playlist", "create"], minArgs: 1, maxArgs: 1, flags: [] }, + { path: ["playlist", "update"], minArgs: 2, maxArgs: 2, flags: ["if-match"] }, + { path: ["playlist", "export"], minArgs: 1, maxArgs: 1, flags: ["output"] }, + { path: ["playlist", "import"], minArgs: 1, maxArgs: 1, flags: ["name", "update", "if-match", "poll-ms"] }, + { path: ["playlist", "show"], minArgs: 1, maxArgs: 1, flags: [] }, + { path: ["playlist", "list"], minArgs: 0, maxArgs: 0, flags: [] }, + { path: ["playlist", "delete"], minArgs: 1, maxArgs: 1, flags: ["if-match"] }, + { path: ["screen", "pair"], minArgs: 1, maxArgs: 1, flags: ["label"] }, + { path: ["screen", "provision"], minArgs: 0, maxArgs: 0, flags: ["open", "print-url", "label"] }, + { path: ["browser", "setup"], minArgs: 0, maxArgs: 0, flags: ["code", "open"] }, + { path: ["screen", "update"], minArgs: 1, maxArgs: 1, flags: ["name", "playlist-id", "timezone", "if-match"] }, + { path: ["screen", "list"], minArgs: 0, maxArgs: 0, flags: ["state"] }, + { path: ["screen", "show"], minArgs: 1, maxArgs: 1, flags: [] }, + { path: ["screen", "assign"], minArgs: 1, maxArgs: 1, flags: ["playlist-id", "if-match"] }, + { path: ["screen", "set-timezone"], minArgs: 1, maxArgs: 1, flags: ["timezone", "if-match"] }, + { path: ["screen", "archive"], minArgs: 1, maxArgs: 1, flags: ["if-match"] }, + { path: ["screen", "unarchive"], minArgs: 1, maxArgs: 1, flags: ["if-match"] }, + { path: ["screen", "delete"], minArgs: 1, maxArgs: 1, flags: ["if-match"] }, + { path: ["screen", "rotate-public-id"], minArgs: 1, maxArgs: 1, flags: ["if-match"] }, + { path: ["screen", "toast"], minArgs: 1, maxArgs: 1, flags: ["text", "level", "duration-ms"] }, + { path: ["screen", "screenshot"], minArgs: 1, maxArgs: 1, flags: ["output", "poll-ms"] }, + { path: ["kv", "get"], minArgs: 1, maxArgs: 1, flags: ["application-id"] }, + { path: ["kv", "set"], minArgs: 1, maxArgs: 1, flags: ["application-id", "json-value", "file", "value-base64", "content-type", "if-match"] }, + { path: ["kv", "delete"], minArgs: 1, maxArgs: 1, flags: ["application-id", "if-match"] }, + { path: ["kv", "list"], minArgs: 0, maxArgs: 0, flags: ["application-id"] }, + { path: ["comment", "show", "screen"], minArgs: 1, maxArgs: 1, flags: [] }, + { path: ["comment", "show", "playlist"], minArgs: 1, maxArgs: 1, flags: ["page"] }, + { path: ["comment", "set", "screen"], minArgs: 1, maxArgs: 1, flags: ["json-value", "file"] }, + { path: ["comment", "set", "playlist"], minArgs: 1, maxArgs: 1, flags: ["page", "json-value", "file"] }, + { path: ["comment", "delete", "screen"], minArgs: 1, maxArgs: 1, flags: [] }, + { path: ["comment", "delete", "playlist"], minArgs: 1, maxArgs: 1, flags: ["page"] }, + { path: ["operations", "get"], minArgs: 1, maxArgs: 1, flags: [] }, + { path: ["operations", "wait"], minArgs: 1, maxArgs: 1, flags: ["poll-ms"] }, + { path: ["operations", "cancel"], minArgs: 1, maxArgs: 1, flags: [] }, + { path: ["events", "list"], minArgs: 0, maxArgs: 0, flags: ["after", "cursor", "limit"] }, + { path: ["events", "follow"], minArgs: 0, maxArgs: 0, flags: ["after", "cursor"] }, + { path: ["playback", "list"], minArgs: 0, maxArgs: 0, flags: ["screen-id", "media-id", "day"] }, + { path: ["feedback", "bug"], minArgs: 1, maxArgs: 1, flags: ["body", "body-file", "command", "no-context"] }, + { path: ["feedback", "feature"], minArgs: 1, maxArgs: 1, flags: ["body", "body-file", "command", "no-context"] }, + { path: ["feedback", "list"], minArgs: 0, maxArgs: 0, flags: ["kind"] }, + { path: ["doctor"], minArgs: 0, maxArgs: 0, flags: ["repair-config"] }, + { path: ["version"], minArgs: 0, maxArgs: 0, flags: [] }, +]; From 9f80902329d94ab94d8e5c5622e8c114d9351f2d Mon Sep 17 00:00:00 2001 From: Kurtis Jantzen Date: Thu, 10 Sep 2026 11:31:04 -0700 Subject: [PATCH 2/5] Preserve playlist get alias in command catalog --- src/command-spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/command-spec.ts b/src/command-spec.ts index 4a5e0b6c..d18f5648 100644 --- a/src/command-spec.ts +++ b/src/command-spec.ts @@ -3,6 +3,7 @@ export interface CommandSpec { readonly path: readonly string[]; readonly minArgs: number; readonly maxArgs: number; + readonly aliases?: readonly (readonly string[])[]; readonly flags: readonly string[]; } @@ -23,7 +24,7 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [ { path: ["dashboard"], minArgs: 0, maxArgs: 0, flags: ["print-url"] }, { path: ["app", "pack"], minArgs: 1, maxArgs: 1, flags: ["output"] }, { path: ["app", "upload"], minArgs: 1, maxArgs: 1, flags: ["name", "no-wait", "poll-ms"] }, - { path: ["app", "update"], minArgs: 2, maxArgs: 2, flags: ["if-match", "name", "no-wait", "poll-ms"] }, + { path: ["app", "update"], minArgs: 2, maxArgs: 2, flags: ["if-match", "no-wait", "poll-ms"] }, { path: ["app", "list"], minArgs: 0, maxArgs: 0, flags: [] }, { path: ["app", "show"], minArgs: 1, maxArgs: 1, flags: [] }, { path: ["media", "generate"], minArgs: 0, maxArgs: 0, flags: ["prompt", "aspect-ratio", "quality", "tag"] }, @@ -44,7 +45,7 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [ { path: ["playlist", "update"], minArgs: 2, maxArgs: 2, flags: ["if-match"] }, { path: ["playlist", "export"], minArgs: 1, maxArgs: 1, flags: ["output"] }, { path: ["playlist", "import"], minArgs: 1, maxArgs: 1, flags: ["name", "update", "if-match", "poll-ms"] }, - { path: ["playlist", "show"], minArgs: 1, maxArgs: 1, flags: [] }, + { path: ["playlist", "show"], aliases: [["playlist", "get"]], minArgs: 1, maxArgs: 1, flags: [] }, { path: ["playlist", "list"], minArgs: 0, maxArgs: 0, flags: [] }, { path: ["playlist", "delete"], minArgs: 1, maxArgs: 1, flags: ["if-match"] }, { path: ["screen", "pair"], minArgs: 1, maxArgs: 1, flags: ["label"] }, From fde3c0936aaf3745afd259d7ee0439186653040b Mon Sep 17 00:00:00 2001 From: Kurtis Jantzen Date: Thu, 10 Sep 2026 11:34:10 -0700 Subject: [PATCH 3/5] Add hierarchical command help and fix version aliases --- README.md | 6 + src/adapters/protocol.test.ts | 15 +- src/cli.test.ts | 9 +- src/commands.ts | 130 +------------ src/compose/cli.test.ts | 3 +- src/help.test.ts | 74 ++++++++ src/help.ts | 332 +++++++++++++++++++++++++++++++++ src/log/logger.test.ts | 2 +- src/media-generate.test.ts | 3 +- src/media-upload-batch.test.ts | 3 +- src/playlist-bundle.test.ts | 5 +- src/playlist-preview.test.ts | 3 +- 12 files changed, 447 insertions(+), 138 deletions(-) create mode 100644 src/help.test.ts create mode 100644 src/help.ts diff --git a/README.md b/README.md index a7f46c5c..cfd363da 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,12 @@ Commands return JSON envelopes an agent can branch on. Customer surface is content (`app`, `media`, `compose`), playlists, and screens. Full reference: [https://screenrig.ai/docs/cli.md](https://screenrig.ai/docs/cli.md). +Discover commands progressively with `screenrig --help`, `screenrig screen --help`, +and `screenrig screen assign --help`. Deeper groups work the same way: +`screenrig comment show --help`. `screenrig help screen assign` is equivalent. +Add `--json` for structured child command paths, invocation syntax, and option types; +help runs without configuration or authentication. + Choose by what the page is: - Already have the file: `media upload`, then a playlist and `screen assign`. diff --git a/src/adapters/protocol.test.ts b/src/adapters/protocol.test.ts index 076fe4f3..22777137 100644 --- a/src/adapters/protocol.test.ts +++ b/src/adapters/protocol.test.ts @@ -1,3 +1,4 @@ +import { commandHelp } from "../help.js"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; @@ -859,12 +860,14 @@ test("opaque agent comments are optional on GET resources, dedicated comment rou assert.match(screenComments, /parameters: \[\{ \$ref: "#\/components\/parameters\/IdempotencyKey" \}\]/); assert.doesNotMatch(screenComments, /IfMatch/); - assert.match(commands, /comment show screen /); - assert.match(commands, /comment show playlist \[--page PAGE_ID\]/); - assert.match(commands, /comment set screen \(--json-value JSON \| --file FILE\)/); - assert.match(commands, /comment set playlist \[--page PAGE_ID\] \(--json-value JSON \| --file FILE\)/); - assert.match(commands, /comment delete screen /); - assert.match(commands, /comment delete playlist \[--page PAGE_ID\]/); + const commentHelp = ["show", "set", "delete"].flatMap((action) => + ["screen", "playlist"].map((target) => commandHelp(["comment", action, target]).usage)).join("\n"); + assert.match(commentHelp, /comment show screen /); + assert.match(commentHelp, /comment show playlist \[--page PAGE_ID\]/); + assert.match(commentHelp, /comment set screen \(--json-value JSON \| --file FILE\)/); + assert.match(commentHelp, /comment set playlist \[--page PAGE_ID\] \(--json-value JSON \| --file FILE\)/); + assert.match(commentHelp, /comment delete screen /); + assert.match(commentHelp, /comment delete playlist \[--page PAGE_ID\]/); assert.doesNotMatch(commands, /comment\/screen\/:id/); assert.doesNotMatch(commands, /--value-base64.*comment/); }); diff --git a/src/cli.test.ts b/src/cli.test.ts index 42b09a67..e5d1d487 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,3 +1,4 @@ +import { commandHelp, CREDIT_HELP } from "./help.js"; import assert from "node:assert/strict"; import { createCipheriv, createHash, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync } from "node:crypto"; import { PassThrough } from "node:stream"; @@ -6,7 +7,7 @@ import { mkdir, open, readFile, rename, chmod, stat, writeFile, rm } from "node: import path from "node:path"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; -import { CLI_VERSION, EVENT_STREAM_BACKOFF_CAP_MS, EVENT_STREAM_BACKOFF_MS, USAGE, formatEventLine } from "./commands.js"; +import { CLI_VERSION, EVENT_STREAM_BACKOFF_CAP_MS, EVENT_STREAM_BACKOFF_MS, formatEventLine } from "./commands.js"; import { run, type CliRuntime } from "./main.js"; import { FakeTransport, memoryBackend } from "./transport/fake.js"; import { ExitCode } from "./exit-codes.js"; @@ -3589,7 +3590,7 @@ test("media upload warns on a low-information filename without blocking the uplo }); test("customer-facing credit copy is fail-open until 1 Jan 2027", () => { - const documents: Array<[string, string]> = [["USAGE", USAGE]]; + const documents: Array<[string, string]> = [["media generate help", CREDIT_HELP]]; for (const [name, text] of documents) { assert.match(text, /nonnegative/, `${name} must say remaining is nonnegative`); assert.match(text, /credits_low/, `${name} must name the live credits_low warning`); @@ -3611,7 +3612,7 @@ test("customer-facing credit copy is fail-open until 1 Jan 2027", () => { assert.doesNotMatch(text, /\$30 \/ 1M/, `${name} must not name the vendor image-output rate as the price`); assert.doesNotMatch(text, /OpenRouter/, `${name} must not name OpenRouter`); } - assert.match(USAGE, /feedback list \[--kind bug\|feature\]/); + assert.match(commandHelp(["feedback", "list"]).usage, /feedback list \[--kind bug\|feature\]/); // Maintainer AGENTS.md and README navigation do not duplicate the CLI help // rate card. Behavior and customer command help are validated above. @@ -3942,7 +3943,7 @@ test("screen toast posts the closed write body and does not echo the text", asyn }); test("screen toast defaults omitted --level to info", async () => { - assert.match(USAGE, /screen toast --text TEXT \[--level info\] \[--duration-ms MS\]/); + assert.match(commandHelp(["screen", "toast"]).usage, /screen toast --text TEXT \[--level info\] \[--duration-ms MS\]/); const transport = memoryBackend(); const configDir = await testTemp("toast-default-level-"); const fsLike = { mkdir, open, rename, rm, chmod, stat, homedir: () => configDir, env: { XDG_CONFIG_HOME: configDir } }; diff --git a/src/commands.ts b/src/commands.ts index 0354ef12..7f2ff746 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -137,125 +137,12 @@ import { validateAgentConnectionStart, type AgentConnectionConfig, } from "./agent-identity.js"; +import { commandHelp, isHelpGroup } from "./help.js"; import { CLI_VERSION } from "./version.js"; export { CLI_VERSION }; -export const USAGE = `screenrig — ScreenRig localhost v1 control-plane CLI - -Usage: - screenrig [--json] [--api-url URL] [--config PATH] - [--request-id ID] [--idempotency-key KEY] [--timeout MS] - [--beta-key KEY] - [args] - -Configuration (user config JSON, not flags): - log_socket optional path to an already-listening Unix socket. The CLI - connects as a client and writes one NDJSON operation-log - object per line. Absent or empty keeps current behavior. - Connect or back-pressure drops lines and warns - log_sink_degraded; it does not fail the command. - This is a config field only, not a command-line switch. - -Commands: - account show - agent enroll --email ADDRESS [--name NAME] [--open-dashboard] - agent connect [--name NAME] [--print-url] [--timeout MS] - Approval expires after 24 hours; --timeout defaults to 86400000 ms. - Retry agent connect to resume after an interrupted wait. - agent status - agent disconnect --yes [--allow-lockout] - dashboard [--print-url] - app pack [--output FILE] - app upload [--name NAME] [--no-wait] [--poll-ms MS] - app update --if-match REVISION [--no-wait] [--poll-ms MS] - app list - app show - media generate --prompt TEXT [--aspect-ratio RATIO] [--quality low|medium|high] [--tag TAG] - media upload [--content-type TYPE] [--tag TAG] [--no-wait] [--poll-ms MS] - [--no-transcode] [--codec h264|hevc] [--max-fps N] - [--max-edge PIXELS] [--webp-quality 1-100] [--no-progress] - [--preset signage-1080p30|signage-4k30] [--no-audio] - media upload-batch --state FILE [--concurrency N] - [--no-transcode] [--tag TAG] [--no-progress] - media show - media download [--output FILE] - media list [--tag TAG] [--primitive image|video] - media update (--tag TAG | --clear-tag) --if-match REVISION - media delete --if-match REVISION - compose catalog - (local regions, enter/motion, fonts, examples; no network) - compose batch --output DIRECTORY [--only ID] [--target-width PX --target-height PX] [--safe-area] - [--lint-only] - (contact sheet; 1 to 2000 pages) - playlist validate [--lint-only] - compose render [--output DIRECTORY] [--combined] [--target-width PX --target-height PX] [--safe-area] - [--open] [--lint-only] - playlist preview --output DIR [--frame-ms MS] [--contact-sheet] [--lint-only] - playlist templates - playlist create - playlist update --if-match REVISION - playlist export --output DIRECTORY - playlist import [--name NAME] [--update ID --if-match REVISION] - playlist show - playlist list - playlist delete --if-match REVISION - screen pair CODE [--label LABEL] - screen provision (--open | --print-url) [--label LABEL] - browser setup --code CODE [--open] - screen update [--name NAME] [--playlist-id ID] [--timezone ZONE] - --if-match REVISION - screen list [--state archived] - screen show - screen assign --playlist-id ID --if-match REVISION - screen set-timezone --timezone ZONE --if-match REVISION - screen archive --if-match REVISION - screen unarchive --if-match REVISION - screen delete --if-match REVISION - screen rotate-public-id --if-match REVISION - screen toast --text TEXT [--level info] [--duration-ms MS] - screen screenshot [--output FILE] [--timeout MS] [--poll-ms MS] - kv get --application-id ID - kv set --application-id ID --json-value JSON [--if-match REVISION] - kv set --application-id ID --file FILE --content-type TYPE [--if-match REVISION] - kv set --application-id ID --value-base64 BASE64 --content-type TYPE [--if-match REVISION] - kv delete --application-id ID --if-match REVISION - kv list --application-id ID - comment show screen - comment show playlist [--page PAGE_ID] - comment set screen (--json-value JSON | --file FILE) - comment set playlist [--page PAGE_ID] (--json-value JSON | --file FILE) - comment delete screen - comment delete playlist [--page PAGE_ID] - operations get - operations wait [--timeout MS] [--poll-ms MS] - operations cancel - events list [--after CURSOR] [--limit N] - events follow [--after CURSOR] [--timeout MS] - playback list [--screen-id ID] [--media-id ID] [--day YYYY-MM-DD] - feedback bug (--body TEXT | --body-file FILE) - [--command "GROUP ACTION"] [--no-context] - feedback feature <title> (--body TEXT | --body-file FILE) - [--command "GROUP ACTION"] [--no-context] - feedback list [--kind bug|feature] - doctor [--repair-config] - version - -Credits: - Remaining is a nonnegative whole number (never negative; empty remaining - displays 0). Below 1000 credits, authenticated responses may warn - credits_low. Until 1 Jan 2027 08:00 UTC (midnight PT), production fails open: - billed commands are not rejected for empty remaining and do not return - HTTP 402. After that instant, remaining below 1 credit is payment_required. - Empty remaining does not stop or shut off screens in this window. - media generate is the exception: it is billed per token. - Text input is $10 / 1M tokens, image input is $16 / 1M, image output is - $60 / 1M. Quality changes how detailed the still is and therefore how - many tokens it uses. Remaining that cannot cover the debit returns - payment_required / 402, including during this window. - -${LOOK_AT_THE_CONTACT_SHEET} -`; +export { ROOT_HELP as USAGE } from "./help.js"; export interface CommandResult { envelope: ReturnType<typeof successEnvelope<unknown>>; @@ -476,18 +363,19 @@ function humanLines(title: string, fields: Array<[string, string | undefined]>): export async function dispatch(args: ParsedArgs, runtime: CliRuntime): Promise<CommandResult> { const group = args.positionals[0]; const action = args.positionals[1]; - if (!group || flagBool(args.flags, "help") || group === "help") { + if ((flagBool(args.flags, "version") || group === "version") && !flagBool(args.flags, "help") && group !== "help") { return { - envelope: successEnvelope({ usage: USAGE }), + envelope: successEnvelope({ version: CLI_VERSION, protocol_adapter: TEMPORARY_PROTOCOL_VERSION }), exitCode: ExitCode.Success, - human: USAGE, + human: `screenrig ${CLI_VERSION}`, }; } - if (flagBool(args.flags, "version") || group === "version") { + if (!group || flagBool(args.flags, "help") || group === "help" || isHelpGroup(args.positionals)) { + const help = commandHelp(group === "help" ? args.positionals.slice(1) : args.positionals, group !== "help"); return { - envelope: successEnvelope({ version: CLI_VERSION, protocol_adapter: TEMPORARY_PROTOCOL_VERSION }), + envelope: successEnvelope(help), exitCode: ExitCode.Success, - human: `screenrig ${CLI_VERSION}`, + human: help.usage, }; } diff --git a/src/compose/cli.test.ts b/src/compose/cli.test.ts index 04642e6d..bef77010 100644 --- a/src/compose/cli.test.ts +++ b/src/compose/cli.test.ts @@ -3,7 +3,8 @@ import { mkdir, open, readFile, rename, chmod, stat, writeFile, rm } from "node: import path from "node:path"; import { PassThrough } from "node:stream"; import { test } from "node:test"; -import { USAGE } from "../commands.js"; +import { commandHelp } from "../help.js"; +const USAGE = [["compose", "batch"], ["compose", "render"], ["playlist", "preview"]].map((path) => commandHelp(path).usage).join("\n"); import { ExitCode } from "../exit-codes.js"; import { run, type CliRuntime } from "../main.js"; import { testTemp } from "../test-temp.js"; diff --git a/src/help.test.ts b/src/help.test.ts new file mode 100644 index 00000000..0907cf21 --- /dev/null +++ b/src/help.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { COMMAND_SPECS } from "./command-spec.js"; +import { commandHelp } from "./help.js"; +import { CLI_VERSION } from "./version.js"; + +const bin = fileURLToPath(new URL("./bin.js", import.meta.url)); +function invoke(args: string[]): { data: ReturnType<typeof commandHelp> & { version?: string } } { + return JSON.parse(execFileSync(process.execPath, [bin, "--json", ...args], { + encoding: "utf8", env: { ...process.env, SCREENRIG_CONFIG: "/nonexistent/screenrig-help-config" }, + })); +} + +test("root help is compact and every command is discoverable through immediate children", () => { + const root = commandHelp(); + assert.ok(root.usage.split("\n").length < 35); + assert.doesNotMatch(root.usage, /localhost|log_socket|media upload|screen assign|--token/); + const discovered: string[] = []; + function visit(path: string[]): void { + const help = commandHelp(path); + if (help.kind === "command") { + if (path.join(" ") !== "help") discovered.push(path.join(" ")); + assert.ok(help.synopsis.every((line) => line.startsWith(`screenrig ${path.join(" ")}`))); + return; + } + for (const child of help.commands) { + assert.equal(child.path.length, path.length + 1); + visit(child.path); + } + } + visit([]); + assert.deepEqual(discovered.sort(), COMMAND_SPECS.map((spec) => spec.path.join(" ")).sort()); +}); + +test("help paths, --help, and bare groups work before configuration or authentication", () => { + for (const path of [[], ["screen"], ["screen", "assign"], ["comment"], ["comment", "set"], ["comment", "set", "playlist"]]) { + const flagged = invoke([...path, "--help"]); + assert.deepEqual(flagged, invoke(["help", ...path])); + assert.deepEqual(flagged.data.path, path); + if (flagged.data.kind === "group") assert.deepEqual(flagged, invoke(path)); + } + const action = invoke(["screen", "assign", "--help"]).data; + assert.match(action.usage, /--playlist-id ID --if-match REVISION/); + assert.ok(action.options.some((option) => option.name === "--playlist-id" && option.type === "value")); + assert.deepEqual(invoke(["comment", "show", "--help"]).data.commands.map((item) => item.name), ["screen", "playlist"]); +}); + +test("version aliases report version without needing a positional command", () => { + for (const args of [["version"], ["--version"], ["-V"]]) { + assert.equal(invoke(args).data.version, CLI_VERSION); + } + assert.deepEqual(invoke(["version", "--help"]).data.path, ["version"]); +}); + +test("unknown help paths fail rather than silently showing root help", () => { + assert.throws(() => invoke(["help", "screen", "assgin"]), (error: unknown) => { + const failure = error as { status: number; stdout: string }; + assert.equal(failure.status, 2); + assert.match(failure.stdout, /Unknown help topic/); + return true; + }); +}); + +test("compatibility aliases resolve canonical help", () => { + assert.deepEqual(commandHelp(["playlist", "get"]), commandHelp(["playlist", "show"])); + assert.deepEqual(commandHelp(["playlist", "show"]).aliases, [["playlist", "get"]]); +}); + +test("help appended to an invocation resolves the command without echoing argument values", () => { + assert.deepEqual(invoke(["screen", "assign", "scr_TEST", "--help"]), invoke(["screen", "assign", "--help"])); + assert.deepEqual(invoke(["playlist", "get", "pl_TEST", "--help"]), invoke(["playlist", "show", "--help"])); +}); diff --git a/src/help.ts b/src/help.ts new file mode 100644 index 00000000..7502a2ba --- /dev/null +++ b/src/help.ts @@ -0,0 +1,332 @@ +import { BOOLEAN_FLAGS, COMMAND_SPECS, GLOBAL_FLAGS } from "./command-spec.js"; +import { usageError } from "./problems.js"; +import { LOOK_AT_THE_CONTACT_SHEET } from "./playlist-preview.js"; + +const COMMAND_USAGE: Record<string, readonly string[]> = { + "account show": [ + "account show" + ], + "agent enroll": [ + "agent enroll --email ADDRESS [--name NAME] [--open-dashboard]" + ], + "agent connect": [ + "agent connect [--name NAME] [--print-url] [--timeout MS]" + ], + "agent status": [ + "agent status" + ], + "agent disconnect": [ + "agent disconnect --yes [--allow-lockout]" + ], + "dashboard": [ + "dashboard [--print-url]" + ], + "app pack": [ + "app pack <directory> [--output FILE]" + ], + "app upload": [ + "app upload <directory> [--name NAME] [--no-wait] [--poll-ms MS]" + ], + "app update": [ + "app update <id> <directory> --if-match REVISION [--no-wait] [--poll-ms MS]" + ], + "app list": [ + "app list" + ], + "app show": [ + "app show <id>" + ], + "media generate": [ + "media generate --prompt TEXT [--aspect-ratio RATIO] [--quality low|medium|high] [--tag TAG]" + ], + "media upload": [ + "media upload <file> [--content-type TYPE] [--tag TAG] [--no-wait] [--poll-ms MS] [--no-transcode] [--codec h264|hevc] [--max-fps N] [--max-edge PIXELS] [--webp-quality 1-100] [--no-progress] [--preset signage-1080p30|signage-4k30] [--no-audio]" + ], + "media upload-batch": [ + "media upload-batch <manifest.json> --state FILE [--concurrency N] [--no-transcode] [--tag TAG] [--no-progress]" + ], + "media show": [ + "media show <id>" + ], + "media download": [ + "media download <id> [--output FILE]" + ], + "media list": [ + "media list [--tag TAG] [--primitive image|video]" + ], + "media update": [ + "media update <id> (--tag TAG | --clear-tag) --if-match REVISION" + ], + "media delete": [ + "media delete <id> --if-match REVISION" + ], + "compose catalog": [ + "compose catalog (local regions, enter/motion, fonts, examples; no network)" + ], + "compose batch": [ + "compose batch <file> --output DIRECTORY [--only ID] [--target-width PX --target-height PX] [--safe-area] [--lint-only] (contact sheet; 1 to 2000 pages)" + ], + "playlist validate": [ + "playlist validate <file> [--lint-only]" + ], + "compose render": [ + "compose render <file> [--output DIRECTORY] [--combined] [--target-width PX --target-height PX] [--safe-area] [--open] [--lint-only]" + ], + "playlist preview": [ + "playlist preview <file|id> --output DIR [--frame-ms MS] [--contact-sheet] [--lint-only]" + ], + "playlist templates": [ + "playlist templates" + ], + "playlist create": [ + "playlist create <file>" + ], + "playlist update": [ + "playlist update <id> <file> --if-match REVISION" + ], + "playlist export": [ + "playlist export <id> --output DIRECTORY" + ], + "playlist import": [ + "playlist import <directory> [--name NAME] [--update ID --if-match REVISION]" + ], + "playlist show": [ + "playlist show <id>" + ], + "playlist list": [ + "playlist list" + ], + "playlist delete": [ + "playlist delete <id> --if-match REVISION" + ], + "screen pair": [ + "screen pair CODE [--label LABEL]" + ], + "screen provision": [ + "screen provision (--open | --print-url) [--label LABEL]" + ], + "browser setup": [ + "browser setup --code CODE [--open]" + ], + "screen update": [ + "screen update <id> [--name NAME] [--playlist-id ID] [--timezone ZONE] --if-match REVISION" + ], + "screen list": [ + "screen list [--state archived]" + ], + "screen show": [ + "screen show <id>" + ], + "screen assign": [ + "screen assign <id> --playlist-id ID --if-match REVISION" + ], + "screen set-timezone": [ + "screen set-timezone <id> --timezone ZONE --if-match REVISION" + ], + "screen archive": [ + "screen archive <id> --if-match REVISION" + ], + "screen unarchive": [ + "screen unarchive <id> --if-match REVISION" + ], + "screen delete": [ + "screen delete <id> --if-match REVISION" + ], + "screen rotate-public-id": [ + "screen rotate-public-id <id> --if-match REVISION" + ], + "screen toast": [ + "screen toast <id> --text TEXT [--level info] [--duration-ms MS]" + ], + "screen screenshot": [ + "screen screenshot <id> [--output FILE] [--timeout MS] [--poll-ms MS]" + ], + "kv get": [ + "kv get --application-id ID <key>" + ], + "kv set": [ + "kv set --application-id ID <key> --json-value JSON [--if-match REVISION]", + "kv set --application-id ID <key> --file FILE --content-type TYPE [--if-match REVISION]", + "kv set --application-id ID <key> --value-base64 BASE64 --content-type TYPE [--if-match REVISION]" + ], + "kv delete": [ + "kv delete --application-id ID <key> --if-match REVISION" + ], + "kv list": [ + "kv list --application-id ID" + ], + "comment show screen": [ + "comment show screen <id>" + ], + "comment show playlist": [ + "comment show playlist <id> [--page PAGE_ID]" + ], + "comment set screen": [ + "comment set screen <id> (--json-value JSON | --file FILE)" + ], + "comment set playlist": [ + "comment set playlist <id> [--page PAGE_ID] (--json-value JSON | --file FILE)" + ], + "comment delete screen": [ + "comment delete screen <id>" + ], + "comment delete playlist": [ + "comment delete playlist <id> [--page PAGE_ID]" + ], + "operations get": [ + "operations get <id>" + ], + "operations wait": [ + "operations wait <id> [--timeout MS] [--poll-ms MS]" + ], + "operations cancel": [ + "operations cancel <id>" + ], + "events list": [ + "events list [--after CURSOR] [--limit N]" + ], + "events follow": [ + "events follow [--after CURSOR] [--timeout MS]" + ], + "playback list": [ + "playback list [--screen-id ID] [--media-id ID] [--day YYYY-MM-DD]" + ], + "feedback bug": [ + "feedback bug <title> (--body TEXT | --body-file FILE) [--command \"GROUP ACTION\"] [--no-context]" + ], + "feedback feature": [ + "feedback feature <title> (--body TEXT | --body-file FILE) [--command \"GROUP ACTION\"] [--no-context]" + ], + "feedback list": [ + "feedback list [--kind bug|feature]" + ], + "doctor": [ + "doctor [--repair-config]" + ], + "version": [ + "version" + ] +}; + +export const CREDIT_HELP = "Credits:\n Remaining is a nonnegative whole number (never negative; empty remaining\n displays 0). Below 1000 credits, authenticated responses may warn\n credits_low. Until 1 Jan 2027 08:00 UTC (midnight PT), production fails open:\n billed commands are not rejected for empty remaining and do not return\n HTTP 402. After that instant, remaining below 1 credit is payment_required.\n Empty remaining does not stop or shut off screens in this window.\n media generate is the exception: it is billed per token.\n Text input is $10 / 1M tokens, image input is $16 / 1M, image output is\n $60 / 1M. Quality changes how detailed the still is and therefore how\n many tokens it uses. Remaining that cannot cover the debit returns\n payment_required / 402, including during this window."; + +const SUMMARIES: Record<string, string> = { + account: "Inspect your account and credits", + agent: "Enroll, connect, and manage this agent", + dashboard: "Open the account dashboard", + app: "Pack, upload, and inspect applications", + media: "Generate, upload, and manage images and videos", + compose: "Render and inspect local page compositions", + playlist: "Author, validate, preview, and manage playlists", + screen: "Pair, configure, and inspect screens", + browser: "Complete browser Player setup", + kv: "Read and write application key-value data", + comment: "Read and manage screen and playlist comments", + operations: "Inspect, wait for, or cancel operations", + events: "List or follow account events", + playback: "Inspect playback records", + feedback: "Submit bugs and feature requests", + doctor: "Check this CLI installation and configuration", + version: "Show CLI and protocol versions", + help: "Discover commands and their options", +}; + +const ACTION_SUMMARIES: Record<string, string> = { + enroll: "Create the first agent with an email address", connect: "Connect this installation with approval", + status: "Inspect this agent's connection", disconnect: "Disconnect this agent", + pack: "Pack a local application directory", upload: "Upload a local file or directory", + update: "Update an existing resource", list: "List resources", show: "Inspect a resource", + generate: "Generate an image from a prompt", download: "Download media to a file", + "upload-batch": "Upload a manifest with resumable state", delete: "Delete a resource", + catalog: "Browse local composition capabilities and examples", batch: "Render a batch of compositions", + render: "Render a composition locally", validate: "Validate a playlist file", + preview: "Render playlist previews and a contact sheet", templates: "Browse playlist templates", + create: "Create a playlist from a file", export: "Export a playlist bundle", import: "Import a playlist bundle", + pair: "Claim a Player pairing code", provision: "Create a screen and browser handoff", + setup: "Complete browser setup from a code", assign: "Assign a playlist to a screen", + "set-timezone": "Set a screen's timezone", archive: "Archive a screen", unarchive: "Restore an archived screen", + "rotate-public-id": "Rotate a screen's public identifier", toast: "Show a temporary screen message", + screenshot: "Capture and download a screen screenshot", get: "Read the current value or operation", + set: "Write a value", wait: "Wait for an operation to finish", cancel: "Cancel an operation", + follow: "Stream account events", bug: "Submit a bug report", feature: "Submit a feature request", +}; + +const NOTES: Record<string, string> = { + "agent connect": "Approval expires after 24 hours; --timeout defaults to 86400000 ms. Retry agent connect to resume after an interrupted wait.", + "media generate": CREDIT_HELP, + "account show": CREDIT_HELP, + "compose render": LOOK_AT_THE_CONTACT_SHEET, + "compose batch": LOOK_AT_THE_CONTACT_SHEET, + "playlist preview": LOOK_AT_THE_CONTACT_SHEET, +}; + +export interface HelpDocument { + path: string[]; + aliases: string[][]; + kind: "group" | "command"; + usage: string; + synopsis: string[]; + commands: Array<{ name: string; path: string[]; kind: "group" | "command"; summary: string; help: string }>; + options: Array<{ name: string; type: "boolean" | "value" }>; + globalOptions: Array<{ name: string; type: "boolean" | "value" }>; + notes: string[]; +} + +function matches(prefix: readonly string[], path: readonly string[]): boolean { + return prefix.every((part, index) => path[index] === part); +} + +export function isHelpGroup(path: readonly string[]): boolean { + return COMMAND_SPECS.some((spec) => spec.path.length > path.length && matches(path, spec.path)); +} + +/** Resolve only command path components: arbitrary extra arguments are never hidden by help. */ +export function commandHelp(path: readonly string[] = [], invocation = false): HelpDocument { + if (invocation) { + const invoked = COMMAND_SPECS.find((spec) => [spec.path, ...(spec.aliases ?? [])].some((candidate) => + path.length >= candidate.length && path.length <= candidate.length + spec.maxArgs && matches(candidate, path))); + if (invoked) return commandHelp(invoked.path); + } + const alias = COMMAND_SPECS.find((spec) => spec.aliases?.some((candidate) => candidate.length === path.length && matches(path, candidate))); + if (alias) return commandHelp(alias.path); + const leaf = COMMAND_SPECS.find((spec) => spec.path.length === path.length && matches(path, spec.path)); + const descendants = COMMAND_SPECS.filter((spec) => matches(path, spec.path)); + const helpLeaf = path.length === 1 && path[0] === "help"; + if (!leaf && !descendants.length && path.length && !helpLeaf) { + throw usageError("Unknown help topic.", { + command: "screenrig help", + reason: "List command groups, then request help for a group or command.", + }); + } + const key = path.join(" "); + const childNames = [...new Set(descendants.filter((spec) => spec.path.length > path.length).map((spec) => spec.path[path.length]!))]; + if (!path.length) childNames.push("help"); + const commands = childNames.map((name) => { + const childPath = [...path, name]; + const group = isHelpGroup(childPath); + return { + name, + path: childPath, + kind: group ? "group" as const : "command" as const, + summary: SUMMARIES[childPath.join(" ")] ?? (group ? `Discover ${name} commands` : ACTION_SUMMARIES[name] ?? `Manage ${name} comments`), + help: `screenrig ${childPath.join(" ")} --help`, + }; + }); + const synopsis = helpLeaf ? ["screenrig help [command [subcommand ...]]"] : leaf + ? (COMMAND_USAGE[key] ?? [key]).map((line) => `screenrig ${line}`) + : [`screenrig${key ? ` ${key}` : ""} <command> [args]`]; + const option = (flag: string) => ({ name: `--${flag}`, type: BOOLEAN_FLAGS.has(flag) ? "boolean" as const : "value" as const }); + const options = leaf ? [...leaf.flags].map(option) : []; + const globalOptions = [...GLOBAL_FLAGS].filter((flag) => flag !== "token").map(option); + const optionText = (item: {name: string; type: string}) => `${item.name}${item.type === "value" ? " VALUE" : ""}`; + const notes = NOTES[key] ? [NOTES[key]!] : []; + if (leaf?.aliases?.length) notes.push(`Aliases: ${leaf.aliases.map((aliasPath) => `screenrig ${aliasPath.join(" ")}`).join(", ")}`); + const lines = [path.length ? `screenrig ${key}` : "screenrig — Signage and kiosk infrastructure for agents", "", "Usage:", ...synopsis.map((line) => ` ${line}`)]; + if (commands.length) lines.push("", "Commands:", ...commands.map((command) => ` ${command.name.padEnd(16)} ${command.summary}`)); + if (options.length) lines.push("", `Command options: ${options.map(optionText).join(" ")}`); + lines.push("", `Global options: ${globalOptions.map(optionText).join(" ")}`, "", "Use screenrig <command> --help to explore each group or action. Add --json for structured help."); + if (notes.length) lines.push("", ...notes); + return { path: [...path], aliases: leaf?.aliases?.map((aliasPath) => [...aliasPath]) ?? [], kind: leaf || helpLeaf ? "command" : "group", usage: lines.join("\n"), synopsis, commands, options, globalOptions, notes }; +} + +export const ROOT_HELP = commandHelp().usage; diff --git a/src/log/logger.test.ts b/src/log/logger.test.ts index 7fcf49ce..d6884c68 100644 --- a/src/log/logger.test.ts +++ b/src/log/logger.test.ts @@ -122,7 +122,7 @@ async function listenUnix(socketPath: string): Promise<{ events: LogEvent[]; wai test("USAGE and argv do not define a log-socket flag", () => { assert.doesNotMatch(USAGE, /\[--log-socket/); - assert.match(USAGE, /log_socket/); + assert.doesNotMatch(USAGE, /log_socket/); const parsed = parseArgv(["--log-socket", "/tmp/screenrig.sock", "version"]); assert.equal(parsed.flags["log-socket"], true); assert.equal(parsed.positionals[0], "/tmp/screenrig.sock"); diff --git a/src/media-generate.test.ts b/src/media-generate.test.ts index 83034f25..9697f5f1 100644 --- a/src/media-generate.test.ts +++ b/src/media-generate.test.ts @@ -3,7 +3,8 @@ import { chmod, mkdir, open, rename, rm, stat } from "node:fs/promises"; import path from "node:path"; import { PassThrough } from "node:stream"; import { test } from "node:test"; -import { USAGE } from "./commands.js"; +import { commandHelp } from "./help.js"; +const USAGE = commandHelp(["media", "generate"]).usage; import { writeConfigAtomic, type ConfigFs } from "./config.js"; import { ExitCode } from "./exit-codes.js"; import { run, type CliRuntime } from "./main.js"; diff --git a/src/media-upload-batch.test.ts b/src/media-upload-batch.test.ts index 2fdf6d55..37853255 100644 --- a/src/media-upload-batch.test.ts +++ b/src/media-upload-batch.test.ts @@ -6,7 +6,8 @@ import { PassThrough } from "node:stream"; import { test } from "node:test"; import type { MediaUploadDeclaration, Operation } from "./adapters/protocol.js"; import { ApiClient } from "./client.js"; -import { USAGE } from "./commands.js"; +import { commandHelp } from "./help.js"; +const USAGE = commandHelp(["media", "upload-batch"]).usage; import { writeConfigAtomic, type ConfigFs } from "./config.js"; import { ExitCode } from "./exit-codes.js"; import { createMemoryLogger } from "./log/logger.js"; diff --git a/src/playlist-bundle.test.ts b/src/playlist-bundle.test.ts index 6f16cc98..838dfba6 100644 --- a/src/playlist-bundle.test.ts +++ b/src/playlist-bundle.test.ts @@ -19,7 +19,8 @@ import path from "node:path"; import { test } from "node:test"; import { parseArgv } from "./argv.js"; import { ApiClient } from "./client.js"; -import { USAGE } from "./commands.js"; +import { commandHelp } from "./help.js"; +const USAGE = ["export", "import"].map((action) => commandHelp(["playlist", action]).usage).join("\n"); import { run, type CliRuntime } from "./main.js"; import { PLAYLIST_BUNDLE_MANIFEST, @@ -1006,7 +1007,7 @@ test("command parser and JSON help expose playlist export/import including updat const output = collect(stdout); const errors = collect(stderr); const runtime: CliRuntime = { - argv: ["--json", "--help"], env: {}, stdout, stderr, now: () => new Date(), sleep: async () => undefined, + argv: ["--json", "playlist", "export", "--help"], env: {}, stdout, stderr, now: () => new Date(), sleep: async () => undefined, homedir: () => "/tmp", cwd: () => "/tmp", transport: new FakeTransport(), fs: { mkdir, open, rename, rm, chmod, stat, homedir: () => "/tmp", env: {} }, }; diff --git a/src/playlist-preview.test.ts b/src/playlist-preview.test.ts index 2cbdec2a..8524e3f8 100644 --- a/src/playlist-preview.test.ts +++ b/src/playlist-preview.test.ts @@ -4,7 +4,8 @@ import path from "node:path"; import { PassThrough } from "node:stream"; import { test } from "node:test"; import { loadImage } from "@napi-rs/canvas"; -import { USAGE } from "./commands.js"; +import { commandHelp } from "./help.js"; +const USAGE = commandHelp(["playlist", "preview"]).usage; import { ExitCode } from "./exit-codes.js"; import { run, type CliRuntime } from "./main.js"; import type { ConfigFs } from "./config.js"; From 2533467216ab6374f099ad25a515d85e40347afa Mon Sep 17 00:00:00 2001 From: Kurtis Jantzen <kurtis@telemetrytv.com> Date: Thu, 10 Sep 2026 11:38:41 -0700 Subject: [PATCH 4/5] Assert structured errors for unknown help topics --- src/help.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/help.test.ts b/src/help.test.ts index 0907cf21..ade87866 100644 --- a/src/help.test.ts +++ b/src/help.test.ts @@ -58,7 +58,11 @@ test("unknown help paths fail rather than silently showing root help", () => { assert.throws(() => invoke(["help", "screen", "assgin"]), (error: unknown) => { const failure = error as { status: number; stdout: string }; assert.equal(failure.status, 2); - assert.match(failure.stdout, /Unknown help topic/); + const envelope = JSON.parse(failure.stdout); + assert.equal(envelope.ok, false); + assert.equal(envelope.error.code, "usage_error"); + assert.equal(envelope.error.status, 400); + assert.equal(Object.hasOwn(envelope, "data"), false); return true; }); }); From bdce9b6559c15942ebab888df11a7cd32575da81 Mon Sep 17 00:00:00 2001 From: Kurtis Jantzen <kurtis@telemetrytv.com> Date: Thu, 10 Sep 2026 13:32:46 -0700 Subject: [PATCH 5/5] Use Commander for CLI parsing and hierarchical help --- README.md | 11 +- package-lock.json | 12 +- package.json | 3 +- src/adapters/protocol.test.ts | 23 ++- src/argv.ts | 65 +++--- src/cli.test.ts | 10 +- src/command-spec.ts | 148 +++++++------- src/command-tree.ts | 207 +++++++++++++++++++ src/commander.test.ts | 68 +++++++ src/commands.ts | 9 +- src/compose/cli.test.ts | 24 ++- src/help-text.ts | 54 +++++ src/help.test.ts | 17 +- src/help.ts | 348 +++----------------------------- src/media-generate.test.ts | 17 +- src/media-upload-batch.test.ts | 10 +- src/playlist-bundle.test.ts | 11 +- src/playlist-preview.test.ts | 10 +- src/screenshot.test.ts | 7 +- src/validate-invocation.test.ts | 5 +- src/validate-invocation.ts | 63 ------ 21 files changed, 580 insertions(+), 542 deletions(-) create mode 100644 src/command-tree.ts create mode 100644 src/commander.test.ts create mode 100644 src/help-text.ts delete mode 100644 src/validate-invocation.ts diff --git a/README.md b/README.md index 4cf34428..b990ce68 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,11 @@ Discover commands progressively with `screenrig --help`, `screenrig screen --hel and `screenrig screen assign --help`. Deeper groups work the same way: `screenrig comment show --help`. `screenrig help screen assign` is equivalent. Add `--json` for structured child command paths, invocation syntax, and option types; -help runs without configuration or authentication. +help runs without configuration or authentication. Command-specific options follow +that command, for example `screenrig screen update ID --name Lobby --if-match 1`. +Global options such as `--json` may appear before or after the command. Use +`--name=VALUE` for a value starting with a dash, and `--` before option-like file +names. Duplicate options are rejected. Choose by what the page is: @@ -75,6 +79,11 @@ command. ## Develop +Commander 14 owns argument parsing, command selection, and help rendering while +supporting Node 20.11. The command tree feeds both human and JSON help. The adapter +keeps parser diagnostics inside the CLI error envelope and only forwards explicit +options to handlers, including the existing `no-*` boolean flags. + ```sh npm ci npm run vendor:check diff --git a/package-lock.json b/package-lock.json index 17cd00eb..8955bc7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "@napi-rs/canvas": "^1.0.7", "ajv": "8.18.0", - "ajv-formats": "3.0.1" + "ajv-formats": "3.0.1", + "commander": "14.0.3" }, "bin": { "screenrig": "dist/bin.js" @@ -331,6 +332,15 @@ } } }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/package.json b/package.json index 6bf4db79..82e84196 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "dependencies": { "@napi-rs/canvas": "^1.0.7", "ajv": "8.18.0", - "ajv-formats": "3.0.1" + "ajv-formats": "3.0.1", + "commander": "14.0.3" } } diff --git a/src/adapters/protocol.test.ts b/src/adapters/protocol.test.ts index 22777137..980c3986 100644 --- a/src/adapters/protocol.test.ts +++ b/src/adapters/protocol.test.ts @@ -860,14 +860,21 @@ test("opaque agent comments are optional on GET resources, dedicated comment rou assert.match(screenComments, /parameters: \[\{ \$ref: "#\/components\/parameters\/IdempotencyKey" \}\]/); assert.doesNotMatch(screenComments, /IfMatch/); - const commentHelp = ["show", "set", "delete"].flatMap((action) => - ["screen", "playlist"].map((target) => commandHelp(["comment", action, target]).usage)).join("\n"); - assert.match(commentHelp, /comment show screen <id>/); - assert.match(commentHelp, /comment show playlist <id> \[--page PAGE_ID\]/); - assert.match(commentHelp, /comment set screen <id> \(--json-value JSON \| --file FILE\)/); - assert.match(commentHelp, /comment set playlist <id> \[--page PAGE_ID\] \(--json-value JSON \| --file FILE\)/); - assert.match(commentHelp, /comment delete screen <id>/); - assert.match(commentHelp, /comment delete playlist <id> \[--page PAGE_ID\]/); + for (const action of ["show", "set", "delete"]) { + for (const target of ["screen", "playlist"]) { + const help = commandHelp(["comment", action, target]); + assert.match(help.synopsis[0]!, new RegExp(`comment ${action} ${target} \\[options\\] <id>`)); + const options = new Map(help.options.map((option) => [option.name, option])); + assert.equal(options.has("--page"), target === "playlist"); + if (target === "playlist") assert.equal(options.get("--page")?.type, "value"); + for (const name of ["--json-value", "--file"]) { + assert.equal(options.has(name), action === "set"); + if (action === "set") assert.equal(options.get(name)?.type, "value"); + } + assert.equal(options.has("--if-match"), false); + assert.equal(options.has("--value-base64"), false); + } + } assert.doesNotMatch(commands, /comment\/screen\/:id/); assert.doesNotMatch(commands, /--value-base64.*comment/); }); diff --git a/src/argv.ts b/src/argv.ts index c14ab8f2..eabf4162 100644 --- a/src/argv.ts +++ b/src/argv.ts @@ -1,47 +1,44 @@ -import { BOOLEAN_FLAGS, COMMAND_SPECS, GLOBAL_FLAGS } from "./command-spec.js"; +import { CommanderError } from "commander"; +import { commandPath, createCommandTree, invocationFlags } from "./command-tree.js"; +import { describeHelp, type HelpDocument } from "./help.js"; import { usageError } from "./problems.js"; export interface ParsedArgs { command: string[]; flags: Record<string, string | boolean>; positionals: string[]; + help?: HelpDocument; } export function parseArgv(argv: string[]): ParsedArgs { - const flags: Record<string, string | boolean> = Object.create(null) as Record<string, string | boolean>; - const positionals: string[] = []; - const known = new Set<string>([...GLOBAL_FLAGS, ...COMMAND_SPECS.flatMap((spec) => spec.flags)]); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]!; - if (arg === "--") { - positionals.push(...argv.slice(i + 1)); - break; - } - if (!arg.startsWith("-") || arg === "-") { - positionals.push(arg); - continue; - } - const normalized = arg === "-h" ? "--help" : arg === "-V" ? "--version" : arg; - if (!normalized.startsWith("--")) throw usageError("Unsupported short option. Use --help to discover supported options."); - const eq = normalized.indexOf("="); - const name = normalized.slice(2, eq < 0 ? undefined : eq); - // Do not echo unknown names or values: either can contain credentials. - if (!known.has(name)) throw usageError("Unsupported option. Use --help to discover supported options."); - if (Object.hasOwn(flags, name)) throw usageError(`--${name} may be supplied only once.`); - if (BOOLEAN_FLAGS.has(name)) { - if (eq >= 0) throw usageError(`--${name} takes no value.`); - flags[name] = true; - continue; - } - const value = eq >= 0 ? normalized.slice(eq + 1) : argv[i + 1]; - if (value === undefined || (eq < 0 && value.startsWith("-") && !/^-\d/.test(value))) { - throw usageError(`--${name} requires a value.`); - } - if (value.length === 0 && name !== "value-base64") throw usageError(`--${name} requires a value.`); - flags[name] = value; - if (eq < 0) i += 1; + // Commander handles tokenization, option scope, command paths, aliases, + // positional arity, and option conflicts. It must never exit this process. + const end = argv.indexOf("--"); + const options = end < 0 ? argv : argv.slice(0, end); + for (const spellings of [["--help", "-h"], ["--version", "-V"]]) { + if (options.filter((arg) => spellings.includes(arg)).length > 1) throw usageError(`${spellings[0]} may be supplied only once.`); } - return { command: positionals.slice(0, 2), flags, positionals }; + const tree = createCommandTree(options); + try { + tree.root.parse(argv, { from: "user" }); + } catch (error) { + if (!(error instanceof CommanderError) || !(tree.helpRequested() || tree.versionRequested())) throw error; + } + const selected = tree.selected(); + const flags = invocationFlags(selected); + if (tree.versionRequested()) flags.version = true; + if (tree.helpRequested()) { + flags.help = true; + const help = describeHelp(selected); + return { command: help.path.slice(0, 2), positionals: help.path, flags, help }; + } + // These paired inputs are domain requirements, not parser syntax. + const has = (name: string) => Object.hasOwn(flags, name); + if (has("target-width") !== has("target-height")) throw usageError("Provide both --target-width and --target-height."); + const path = commandPath(selected); + if (path.join(" ") === "playlist import" && has("update") !== has("if-match")) throw usageError("playlist import --update and --if-match must be supplied together."); + const positionals = [...path, ...selected.args]; + return { command: positionals.slice(0, 2), positionals, flags }; } export function flagString(flags: Record<string, string | boolean>, name: string): string | undefined { diff --git a/src/cli.test.ts b/src/cli.test.ts index fa1b7787..831c9d9d 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -3186,7 +3186,7 @@ test("feedback refuses a --command that could carry an argument value", async () { fs: fsLike }, ); assert.equal(valueless.code, 2, valueless.stdout); - assert.match(JSON.parse(valueless.stdout).error.detail as string, /--command requires a value/); + assert.match(JSON.parse(valueless.stdout).error.detail as string, /--command requires a value|--json may be supplied only once/); // --no-context suppresses the diagnostic envelope entirely. const quiet = await withRuntime( @@ -3612,7 +3612,7 @@ test("customer-facing credit copy is fail-open until 1 Jan 2027", () => { assert.doesNotMatch(text, /\$30 \/ 1M/, `${name} must not name the vendor image-output rate as the price`); assert.doesNotMatch(text, /OpenRouter/, `${name} must not name OpenRouter`); } - assert.match(commandHelp(["feedback", "list"]).usage, /feedback list \[--kind bug\|feature\]/); + assert.match(commandHelp(["feedback", "list"]).usage, /--kind <bug\|feature>/); // Maintainer AGENTS.md and README navigation do not duplicate the CLI help // rate card. Behavior and customer command help are validated above. @@ -3943,7 +3943,7 @@ test("screen toast posts the closed write body and does not echo the text", asyn }); test("screen toast defaults omitted --level to info", async () => { - assert.match(commandHelp(["screen", "toast"]).usage, /screen toast <id> --text TEXT \[--level info\] \[--duration-ms MS\]/); + assert.match(commandHelp(["screen", "toast"]).usage, /screen toast \[options\] <id>/); const transport = memoryBackend(); const configDir = await testTemp("toast-default-level-"); const fsLike = { mkdir, open, rename, rm, chmod, stat, homedir: () => configDir, env: { XDG_CONFIG_HOME: configDir } }; @@ -3992,7 +3992,7 @@ test("screen toast rejects invalid level, text, and duration before calling the try { for (const [argv, detail] of [ [["screen", "toast"], /requires <id>/], - [["screen", "toast", "scr_1", "--level", "info"], /requires <id>/], + [["screen", "toast", "scr_1", "--level", "info"], /requires --text/], [["screen", "toast", "scr_1", "--level", "INFO", "--text", "Lobby closed"], /error, alert, or info/], [["screen", "toast", "scr_1", "--level", "warn", "--text", "Lobby closed"], /error, alert, or info/], [["screen", "toast", "scr_1", "--level", "info", "--text", "a\n\n\nb"], /1 to 120 characters/], @@ -4024,7 +4024,7 @@ test("screen toast rejects invalid level, text, and duration before calling the { fs: fsLike }, ); assert.equal(valuelessText.code, ExitCode.Usage); - assert.match(JSON.parse(valuelessText.stdout).error.detail as string, /--text requires a value/); + assert.match(JSON.parse(valuelessText.stdout).error.detail as string, /--text requires a value|--json may be supplied only once/); const cancel = await withRuntime(["--json", "screen", "toast-cancel", "scr_1"], transport, { fs: fsLike }); assert.equal(cancel.code, ExitCode.Usage); diff --git a/src/command-spec.ts b/src/command-spec.ts index d18f5648..76e2ff60 100644 --- a/src/command-spec.ts +++ b/src/command-spec.ts @@ -1,86 +1,80 @@ -/** Supported invocation shapes shared by argument validation and help discovery. */ +/** Declarative command inputs for the Commander tree. */ export interface CommandSpec { readonly path: readonly string[]; - readonly minArgs: number; - readonly maxArgs: number; + readonly arguments?: string; readonly aliases?: readonly (readonly string[])[]; readonly flags: readonly string[]; + readonly requiredFlags?: readonly string[]; } -export const GLOBAL_FLAGS = ["json", "help", "version", "api-url", "config", "request-id", "idempotency-key", "timeout", "beta-key", "token"] as const; - -export const BOOLEAN_FLAGS = new Set([ - "json", "help", "version", "open-dashboard", "print-url", "yes", "allow-lockout", - "no-wait", "no-transcode", "no-progress", "no-audio", "clear-tag", "safe-area", - "lint-only", "combined", "open", "contact-sheet", "no-context", "repair-config", -]); +export const GLOBAL_FLAGS = ["json", "api-url", "config", "request-id", "idempotency-key", "timeout", "beta-key", "token"] as const; export const COMMAND_SPECS: readonly CommandSpec[] = [ - { path: ["account", "show"], minArgs: 0, maxArgs: 0, flags: [] }, - { path: ["agent", "enroll"], minArgs: 0, maxArgs: 0, flags: ["email", "name", "open-dashboard"] }, - { path: ["agent", "connect"], minArgs: 0, maxArgs: 0, flags: ["name", "print-url"] }, - { path: ["agent", "status"], minArgs: 0, maxArgs: 0, flags: [] }, - { path: ["agent", "disconnect"], minArgs: 0, maxArgs: 0, flags: ["yes", "allow-lockout"] }, - { path: ["dashboard"], minArgs: 0, maxArgs: 0, flags: ["print-url"] }, - { path: ["app", "pack"], minArgs: 1, maxArgs: 1, flags: ["output"] }, - { path: ["app", "upload"], minArgs: 1, maxArgs: 1, flags: ["name", "no-wait", "poll-ms"] }, - { path: ["app", "update"], minArgs: 2, maxArgs: 2, flags: ["if-match", "no-wait", "poll-ms"] }, - { path: ["app", "list"], minArgs: 0, maxArgs: 0, flags: [] }, - { path: ["app", "show"], minArgs: 1, maxArgs: 1, flags: [] }, - { path: ["media", "generate"], minArgs: 0, maxArgs: 0, flags: ["prompt", "aspect-ratio", "quality", "tag"] }, - { path: ["media", "upload"], minArgs: 1, maxArgs: 1, flags: ["content-type", "tag", "no-wait", "poll-ms", "no-transcode", "codec", "max-fps", "max-edge", "webp-quality", "no-progress", "preset", "no-audio"] }, - { path: ["media", "upload-batch"], minArgs: 1, maxArgs: 1, flags: ["state", "concurrency", "no-transcode", "tag", "no-progress", "poll-ms", "codec", "max-fps", "max-edge", "webp-quality", "preset", "no-audio"] }, - { path: ["media", "show"], minArgs: 1, maxArgs: 1, flags: [] }, - { path: ["media", "download"], minArgs: 1, maxArgs: 1, flags: ["output"] }, - { path: ["media", "list"], minArgs: 0, maxArgs: 0, flags: ["tag", "primitive"] }, - { path: ["media", "update"], minArgs: 1, maxArgs: 1, flags: ["tag", "clear-tag", "if-match"] }, - { path: ["media", "delete"], minArgs: 1, maxArgs: 1, flags: ["if-match"] }, - { path: ["compose", "catalog"], minArgs: 0, maxArgs: 0, flags: [] }, - { path: ["compose", "batch"], minArgs: 1, maxArgs: 1, flags: ["output", "only", "target-width", "target-height", "safe-area", "lint-only"] }, - { path: ["compose", "render"], minArgs: 1, maxArgs: 1, flags: ["output", "combined", "target-width", "target-height", "safe-area", "open", "lint-only"] }, - { path: ["playlist", "validate"], minArgs: 1, maxArgs: 1, flags: ["lint-only"] }, - { path: ["playlist", "preview"], minArgs: 1, maxArgs: 1, flags: ["output", "frame-ms", "contact-sheet", "lint-only"] }, - { path: ["playlist", "templates"], minArgs: 0, maxArgs: 0, flags: [] }, - { path: ["playlist", "create"], minArgs: 1, maxArgs: 1, flags: [] }, - { path: ["playlist", "update"], minArgs: 2, maxArgs: 2, flags: ["if-match"] }, - { path: ["playlist", "export"], minArgs: 1, maxArgs: 1, flags: ["output"] }, - { path: ["playlist", "import"], minArgs: 1, maxArgs: 1, flags: ["name", "update", "if-match", "poll-ms"] }, - { path: ["playlist", "show"], aliases: [["playlist", "get"]], minArgs: 1, maxArgs: 1, flags: [] }, - { path: ["playlist", "list"], minArgs: 0, maxArgs: 0, flags: [] }, - { path: ["playlist", "delete"], minArgs: 1, maxArgs: 1, flags: ["if-match"] }, - { path: ["screen", "pair"], minArgs: 1, maxArgs: 1, flags: ["label"] }, - { path: ["screen", "provision"], minArgs: 0, maxArgs: 0, flags: ["open", "print-url", "label"] }, - { path: ["browser", "setup"], minArgs: 0, maxArgs: 0, flags: ["code", "open"] }, - { path: ["screen", "update"], minArgs: 1, maxArgs: 1, flags: ["name", "playlist-id", "timezone", "if-match"] }, - { path: ["screen", "list"], minArgs: 0, maxArgs: 0, flags: ["state"] }, - { path: ["screen", "show"], minArgs: 1, maxArgs: 1, flags: [] }, - { path: ["screen", "assign"], minArgs: 1, maxArgs: 1, flags: ["playlist-id", "if-match"] }, - { path: ["screen", "set-timezone"], minArgs: 1, maxArgs: 1, flags: ["timezone", "if-match"] }, - { path: ["screen", "archive"], minArgs: 1, maxArgs: 1, flags: ["if-match"] }, - { path: ["screen", "unarchive"], minArgs: 1, maxArgs: 1, flags: ["if-match"] }, - { path: ["screen", "delete"], minArgs: 1, maxArgs: 1, flags: ["if-match"] }, - { path: ["screen", "rotate-public-id"], minArgs: 1, maxArgs: 1, flags: ["if-match"] }, - { path: ["screen", "toast"], minArgs: 1, maxArgs: 1, flags: ["text", "level", "duration-ms"] }, - { path: ["screen", "screenshot"], minArgs: 1, maxArgs: 1, flags: ["output", "poll-ms"] }, - { path: ["kv", "get"], minArgs: 1, maxArgs: 1, flags: ["application-id"] }, - { path: ["kv", "set"], minArgs: 1, maxArgs: 1, flags: ["application-id", "json-value", "file", "value-base64", "content-type", "if-match"] }, - { path: ["kv", "delete"], minArgs: 1, maxArgs: 1, flags: ["application-id", "if-match"] }, - { path: ["kv", "list"], minArgs: 0, maxArgs: 0, flags: ["application-id"] }, - { path: ["comment", "show", "screen"], minArgs: 1, maxArgs: 1, flags: [] }, - { path: ["comment", "show", "playlist"], minArgs: 1, maxArgs: 1, flags: ["page"] }, - { path: ["comment", "set", "screen"], minArgs: 1, maxArgs: 1, flags: ["json-value", "file"] }, - { path: ["comment", "set", "playlist"], minArgs: 1, maxArgs: 1, flags: ["page", "json-value", "file"] }, - { path: ["comment", "delete", "screen"], minArgs: 1, maxArgs: 1, flags: [] }, - { path: ["comment", "delete", "playlist"], minArgs: 1, maxArgs: 1, flags: ["page"] }, - { path: ["operations", "get"], minArgs: 1, maxArgs: 1, flags: [] }, - { path: ["operations", "wait"], minArgs: 1, maxArgs: 1, flags: ["poll-ms"] }, - { path: ["operations", "cancel"], minArgs: 1, maxArgs: 1, flags: [] }, - { path: ["events", "list"], minArgs: 0, maxArgs: 0, flags: ["after", "cursor", "limit"] }, - { path: ["events", "follow"], minArgs: 0, maxArgs: 0, flags: ["after", "cursor"] }, - { path: ["playback", "list"], minArgs: 0, maxArgs: 0, flags: ["screen-id", "media-id", "day"] }, - { path: ["feedback", "bug"], minArgs: 1, maxArgs: 1, flags: ["body", "body-file", "command", "no-context"] }, - { path: ["feedback", "feature"], minArgs: 1, maxArgs: 1, flags: ["body", "body-file", "command", "no-context"] }, - { path: ["feedback", "list"], minArgs: 0, maxArgs: 0, flags: ["kind"] }, - { path: ["doctor"], minArgs: 0, maxArgs: 0, flags: ["repair-config"] }, - { path: ["version"], minArgs: 0, maxArgs: 0, flags: [] }, + { path: ["account", "show"], flags: [] }, + { path: ["agent", "enroll"], flags: ["email", "name", "open-dashboard"] }, + { path: ["agent", "connect"], flags: ["name", "print-url"] }, + { path: ["agent", "status"], flags: [] }, + { path: ["agent", "disconnect"], flags: ["yes", "allow-lockout"] }, + { path: ["dashboard"], flags: ["print-url"] }, + { path: ["app", "pack"], arguments: "<directory>", flags: ["output"] }, + { path: ["app", "upload"], arguments: "<directory>", flags: ["name", "no-wait", "poll-ms"] }, + { path: ["app", "update"], arguments: "<id> <directory>", requiredFlags: ["if-match"], flags: ["if-match", "no-wait", "poll-ms"] }, + { path: ["app", "list"], flags: [] }, + { path: ["app", "show"], arguments: "<id>", flags: [] }, + { path: ["media", "generate"], requiredFlags: ["prompt"], flags: ["prompt", "aspect-ratio", "quality", "tag"] }, + { path: ["media", "upload"], arguments: "<file>", flags: ["content-type", "tag", "no-wait", "poll-ms", "no-transcode", "codec", "max-fps", "max-edge", "webp-quality", "no-progress", "preset", "no-audio"] }, + { path: ["media", "upload-batch"], arguments: "<manifest.json>", requiredFlags: ["state"], flags: ["state", "concurrency", "no-transcode", "tag", "no-progress", "poll-ms", "codec", "max-fps", "max-edge", "webp-quality", "preset", "no-audio"] }, + { path: ["media", "show"], arguments: "<id>", flags: [] }, + { path: ["media", "download"], arguments: "<id>", flags: ["output"] }, + { path: ["media", "list"], flags: ["tag", "primitive"] }, + { path: ["media", "update"], arguments: "<id>", requiredFlags: ["if-match"], flags: ["tag", "clear-tag", "if-match"] }, + { path: ["media", "delete"], arguments: "<id>", requiredFlags: ["if-match"], flags: ["if-match"] }, + { path: ["compose", "catalog"], flags: [] }, + { path: ["compose", "batch"], arguments: "<file>", requiredFlags: ["output"], flags: ["output", "only", "target-width", "target-height", "safe-area", "lint-only"] }, + { path: ["compose", "render"], arguments: "<file>", flags: ["output", "combined", "target-width", "target-height", "safe-area", "open", "lint-only"] }, + { path: ["playlist", "validate"], arguments: "<file>", flags: ["lint-only"] }, + { path: ["playlist", "preview"], arguments: "<file|id>", requiredFlags: ["output"], flags: ["output", "frame-ms", "contact-sheet", "lint-only"] }, + { path: ["playlist", "templates"], flags: [] }, + { path: ["playlist", "create"], arguments: "<file>", flags: [] }, + { path: ["playlist", "update"], arguments: "<id> <file>", requiredFlags: ["if-match"], flags: ["if-match"] }, + { path: ["playlist", "export"], arguments: "<id>", requiredFlags: ["output"], flags: ["output"] }, + { path: ["playlist", "import"], arguments: "<directory>", flags: ["name", "update", "if-match", "poll-ms"] }, + { path: ["playlist", "show"], aliases: [["playlist", "get"]], arguments: "<id>", flags: [] }, + { path: ["playlist", "list"], flags: [] }, + { path: ["playlist", "delete"], arguments: "<id>", requiredFlags: ["if-match"], flags: ["if-match"] }, + { path: ["screen", "pair"], arguments: "<code>", flags: ["label"] }, + { path: ["screen", "provision"], flags: ["open", "print-url", "label"] }, + { path: ["browser", "setup"], requiredFlags: ["code"], flags: ["code", "open"] }, + { path: ["screen", "update"], arguments: "<id>", requiredFlags: ["if-match"], flags: ["name", "playlist-id", "timezone", "if-match"] }, + { path: ["screen", "list"], flags: ["state"] }, + { path: ["screen", "show"], arguments: "<id>", flags: [] }, + { path: ["screen", "assign"], arguments: "<id>", requiredFlags: ["playlist-id", "if-match"], flags: ["playlist-id", "if-match"] }, + { path: ["screen", "set-timezone"], arguments: "<id>", requiredFlags: ["timezone", "if-match"], flags: ["timezone", "if-match"] }, + { path: ["screen", "archive"], arguments: "<id>", requiredFlags: ["if-match"], flags: ["if-match"] }, + { path: ["screen", "unarchive"], arguments: "<id>", requiredFlags: ["if-match"], flags: ["if-match"] }, + { path: ["screen", "delete"], arguments: "<id>", requiredFlags: ["if-match"], flags: ["if-match"] }, + { path: ["screen", "rotate-public-id"], arguments: "<id>", requiredFlags: ["if-match"], flags: ["if-match"] }, + { path: ["screen", "toast"], arguments: "<id>", requiredFlags: ["text"], flags: ["text", "level", "duration-ms"] }, + { path: ["screen", "screenshot"], arguments: "<id>", flags: ["output", "poll-ms"] }, + { path: ["kv", "get"], arguments: "<key>", requiredFlags: ["application-id"], flags: ["application-id"] }, + { path: ["kv", "set"], arguments: "<key>", requiredFlags: ["application-id"], flags: ["application-id", "json-value", "file", "value-base64", "content-type", "if-match"] }, + { path: ["kv", "delete"], arguments: "<key>", requiredFlags: ["application-id", "if-match"], flags: ["application-id", "if-match"] }, + { path: ["kv", "list"], requiredFlags: ["application-id"], flags: ["application-id"] }, + { path: ["comment", "show", "screen"], arguments: "<id>", flags: [] }, + { path: ["comment", "show", "playlist"], arguments: "<id>", flags: ["page"] }, + { path: ["comment", "set", "screen"], arguments: "<id>", flags: ["json-value", "file"] }, + { path: ["comment", "set", "playlist"], arguments: "<id>", flags: ["page", "json-value", "file"] }, + { path: ["comment", "delete", "screen"], arguments: "<id>", flags: [] }, + { path: ["comment", "delete", "playlist"], arguments: "<id>", flags: ["page"] }, + { path: ["operations", "get"], arguments: "<id>", flags: [] }, + { path: ["operations", "wait"], arguments: "<id>", flags: ["poll-ms"] }, + { path: ["operations", "cancel"], arguments: "<id>", flags: [] }, + { path: ["events", "list"], flags: ["after", "cursor", "limit"] }, + { path: ["events", "follow"], flags: ["after", "cursor"] }, + { path: ["playback", "list"], flags: ["screen-id", "media-id", "day"] }, + { path: ["feedback", "bug"], arguments: "<title>", flags: ["body", "body-file", "command", "no-context"] }, + { path: ["feedback", "feature"], arguments: "<title>", flags: ["body", "body-file", "command", "no-context"] }, + { path: ["feedback", "list"], flags: ["kind"] }, + { path: ["doctor"], flags: ["repair-config"] }, + { path: ["version"], flags: [] }, ]; diff --git a/src/command-tree.ts b/src/command-tree.ts new file mode 100644 index 00000000..7994c261 --- /dev/null +++ b/src/command-tree.ts @@ -0,0 +1,207 @@ +import { Command, CommanderError, Option } from "commander"; +import { COMMAND_SPECS, GLOBAL_FLAGS } from "./command-spec.js"; +import { ACTION_SUMMARIES, SUMMARIES } from "./help-text.js"; +import { quotedRevision } from "./if-match.js"; +import { usageError } from "./problems.js"; +import { CLI_VERSION } from "./version.js"; + +const OPTION_HELP: Record<string, [string, string]> = { + json: ["", "Return a JSON envelope"], + "api-url": ["URL", "Override the API origin"], config: ["PATH", "Use this credential/configuration file"], + "request-id": ["ID", "Use a request correlation ID"], "idempotency-key": ["KEY", "Reuse a key when retrying the same write"], + timeout: ["MS", "Set the command timeout in milliseconds"], "beta-key": ["KEY", "Supply an enrollment beta key"], token: ["TOKEN", "Override the stored credential"], + email: ["ADDRESS", "Set the account contact email"], name: ["NAME", "Set the resource or agent name"], + "open-dashboard": ["", "Open the dashboard after enrolling"], "print-url": ["", "Return the browser handoff URL"], + yes: ["", "Confirm this agent's disconnection"], "allow-lockout": ["", "Allow disconnecting the last agent"], + output: ["PATH", "Write the result to this file or directory"], "no-wait": ["", "Return after acceptance without waiting for processing"], + "poll-ms": ["MS", "Set the operation polling interval"], "if-match": ["REVISION", "Require the current resource revision"], + prompt: ["TEXT", "Describe the complete image to generate"], "aspect-ratio": ["RATIO", "Choose the generated image aspect ratio"], + quality: ["low|medium|high", "Choose generation quality"], tag: ["TAG", "Set or filter by a media tag"], + "content-type": ["TYPE", "Declare the content MIME type"], "no-transcode": ["", "Upload accepted source bytes unchanged"], + codec: ["h264|hevc", "Choose the video codec (default: h264)"], "max-fps": ["N", "Limit video frames per second"], + "max-edge": ["PIXELS", "Limit the longest video edge"], "webp-quality": ["1-100", "Set WebP quality"], + "no-progress": ["", "Suppress stderr progress"], preset: ["PRESET", "Use signage-1080p30 or signage-4k30"], "no-audio": ["", "Remove the audio track when transcoding"], + state: ["VALUE", "Select archived screens or an upload-batch state file"], concurrency: ["N", "Set concurrent batch uploads"], + primitive: ["image|video", "Filter by media primitive"], "clear-tag": ["", "Remove the media tag"], + only: ["ID", "Render only this page"], "target-width": ["PX", "Set the physical content width"], "target-height": ["PX", "Set the physical content height"], + "safe-area": ["", "Apply the display safe area"], "lint-only": ["", "Run the command's local validation/lint mode"], combined: ["", "Also render combined page images"], open: ["", "Open the result in its viewer"], + "frame-ms": ["MS", "Select the preview frame time"], "contact-sheet": ["", "Include a contact sheet"], update: ["ID", "Update this playlist when importing"], + label: ["LABEL", "Set the screen label"], code: ["CODE", "Use the browser setup code"], "playlist-id": ["ID", "Select the playlist to assign"], timezone: ["ZONE", "Set an IANA timezone"], + text: ["TEXT", "Set the screen message"], level: ["LEVEL", "Set toast level (default: info)"], "duration-ms": ["MS", "Show the message for 2000–60000 milliseconds"], + "application-id": ["ID", "Select the application's K/V namespace"], "json-value": ["JSON", "Supply a JSON value"], file: ["FILE", "Read the value from a file"], "value-base64": ["BASE64", "Supply canonical base64 bytes (may be empty)"], + page: ["PAGE_ID", "Select playlist page comments"], after: ["CURSOR", "Read events after this cursor"], cursor: ["CURSOR", "Alias for --after"], limit: ["N", "Limit returned events"], + "screen-id": ["ID", "Filter playback by screen"], "media-id": ["ID", "Filter playback by media"], day: ["YYYY-MM-DD", "Filter playback by UTC day"], + body: ["TEXT", "Supply feedback details"], "body-file": ["FILE", "Read feedback details from a file"], command: ["GROUP ACTION", "Identify the command involved"], "no-context": ["", "Omit diagnostic context"], kind: ["bug|feature", "Filter feedback by kind"], + "repair-config": ["", "Repair configuration permissions"], +}; +const NUMERIC_FLAGS = new Set(["timeout", "poll-ms", "target-width", "target-height", "limit", "max-fps", "max-edge", "webp-quality", "duration-ms", "concurrency", "frame-ms"]); +const CONFLICTS: readonly (readonly string[])[] = [ + ["after", "cursor"], ["tag", "clear-tag"], ["body", "body-file"], ["json-value", "file", "value-base64"], ["json-value", "content-type"], + ...["codec", "max-fps", "max-edge", "webp-quality", "preset", "no-audio"].map((flag) => ["no-transcode", flag]), +]; + +/** Canonical command names, excluding the executable and any supplied values. */ +export function commandPath(command: Command): string[] { + return command.parent ? [...commandPath(command.parent), command.name()] : []; +} + +export function findCommand(root: Command, path: readonly string[]): Command | undefined { + let command = root; + for (const word of path) { + const child = command.commands.find((candidate) => candidate.name() === word || candidate.aliases().includes(word)); + if (!child) return undefined; + command = child; + } + return command; +} + +/** Read explicit CLI options only; Commander defaults must not become write flags. */ +export function invocationFlags(command: Command): Record<string, string | boolean> { + const flags = Object.create(null) as Record<string, string | boolean>; + for (let current: Command | null = command; current; current = current.parent) { + for (const option of current.options) { + if (current.getOptionValueSource(option.attributeName()) !== "cli") continue; + flags[option.long!.slice(2)] = option.negate ? true : current.getOptionValue(option.attributeName()); + } + } + return flags; +} + +function optionValue(name: string, value: string, argv: readonly string[]): string { + if ((!value.length && name !== "value-base64") || + (value.startsWith("-") && !/^-\d/.test(value) && !argv.includes(`--${name}=${value}`))) { + throw usageError(`--${name} requires a value. Use --${name}=VALUE for a value starting with a dash.`); + } + if (NUMERIC_FLAGS.has(name)) { + const number = Number(value); + if (name === "duration-ms" && (!Number.isInteger(number) || number < 2000 || number > 60000)) throw usageError("--duration-ms must be a whole number between 2000 and 60000."); + if (!value.trim() || !Number.isFinite(number) || number < 0 || (name !== "max-fps" && !Number.isSafeInteger(number))) throw usageError(`--${name} requires a nonnegative ${name === "max-fps" ? "number" : "whole number"}.`); + if (!["timeout", "frame-ms"].includes(name) && number === 0) throw usageError(`--${name} must be greater than zero.`); + } + if (name === "if-match") quotedRevision(value); + return value; +} + +/** Commander diagnostics can contain raw option names/values. Never expose them. */ +function commandError(error: CommanderError, command: Command): never { + const path = commandPath(command).join(" "); + // Preserve actionable migration hints using only known command/flag names. + const has = (flag: string) => command.args.some((arg) => arg === `--${flag}` || arg.startsWith(`--${flag}=`)); + if (path === "screen" && command.args[0] === "revoke-credential") throw usageError("screen revoke-credential is retired. Archive the screen instead.", { command: "screenrig --json screen archive <id> --if-match REVISION", reason: "Archive hides the screen; it does not unbind the player." }); + if (path.startsWith("comment ")) { + if (command.commands.length) throw usageError("comment commands require screen <id> or playlist <id>."); + if (has("if-match")) throw usageError("comment commands do not take --if-match; last write wins and does not bump revision."); + if (has("page") && command.name() === "screen") throw usageError("comment screen commands do not take --page; use comment playlist <id> --page PAGE_ID."); + if (has("value-base64")) throw usageError("comment set requires --json-value or --file."); + if (error.code === "commander.conflictingOption") throw usageError("comment set requires exactly one of --json-value or --file."); + } + if (path === "media list" && has("kind")) throw usageError("media list uses --primitive image|video, not --kind."); + switch (error.code) { + case "commander.missingArgument": + if (path === "screen set-timezone") throw usageError("screen set-timezone requires <id> --timezone --if-match."); + throw usageError(`${path} requires ${command.registeredArguments.filter((arg) => arg.required).map((arg) => `<${arg.name()}>`).join(" ")}.`); + case "commander.excessArguments": + throw usageError(`${path || "screenrig"} does not accept ${command.registeredArguments.length ? "extra" : "positional"} arguments.`); + case "commander.conflictingOption": { + const supplied = command.options.filter((option) => command.getOptionValueSource(option.attributeName()) === "cli"); + const pair = CONFLICTS.map((group) => supplied.filter((option) => group.includes(option.long!.slice(2)))).find((options) => options.length > 1); + const [first, second] = pair ?? []; + throw usageError(first && second ? `Use only one of ${first.long}, ${second.long}.` : "Conflicting options. See command help."); + } + case "commander.missingMandatoryOptionValue": { + if (path === "screen set-timezone") throw usageError("screen set-timezone requires <id> --timezone --if-match."); + const required = command.options.filter((option) => option.mandatory && command.getOptionValue(option.attributeName()) === undefined); + const positional = command.registeredArguments.filter((arg, index) => arg.required && index >= command.args.length).map((arg) => `<${arg.name()}>`); + throw usageError(`${path} requires ${[...positional, ...required.map((option) => option.long)].join(" ")}.`); + } + case "commander.optionMissingArgument": { + const option = command.options.find((candidate) => error.message.includes(`option '${candidate.flags}'`)); + throw usageError(option ? `${option.long} requires a value.` : "An option requires a value. See command help for its arguments."); + } + default: + throw usageError("Unknown command or unsupported option. See command help for supported arguments.", { command: `screenrig${path ? ` ${path}` : ""} --help`, reason: "List supported commands, arguments, and options." }); + } +} + +export interface CommandTree { + root: Command; + selected: () => Command; + helpRequested: () => boolean; + versionRequested: () => boolean; +} + +/** Build fresh commands per invocation: no shared parsed values or process exits. */ +export function createCommandTree(argv: readonly string[] = []): CommandTree { + let selected: Command; + let helpRequested = false; + let versionRequested = false; + const seen = new Set<string>(); + const configure = (command: Command): Command => command + .allowExcessArguments(false) + .addHelpCommand(false) + .showSuggestionAfterError(false) + .configureOutput({ writeOut() {}, writeErr() {}, outputError() {} }) + .configureHelp({ helpWidth: 100, showGlobalOptions: true }) + .exitOverride((error) => { + selected = command; + if (error.code === "commander.helpDisplayed" || error.code === "commander.help") helpRequested = true; + else if (error.code === "commander.version") versionRequested = true; + else commandError(error, command); + throw error; + }); + const root = configure(new Command("screenrig")).description("Signage and kiosk infrastructure for agents"); + selected = root; + root.version(CLI_VERSION, "-V, --version", "Show CLI and protocol versions"); + const addOption = (command: Command, name: string): void => { + const entry = OPTION_HELP[name]; + if (!entry) throw new Error(`Missing option definition: ${name}`); + const [placeholder, description] = entry; + const option = new Option(`--${name}${placeholder ? ` <${placeholder}>` : ""}`, description); + if (placeholder) option.argParser((value: string) => optionValue(name, value, argv)); + if (name === "token") option.hideHelp(); + command.addOption(option); + command.on(`option:${option.name()}`, () => { + if (seen.has(name)) throw usageError(`--${name} may be supplied only once.`); + seen.add(name); + }); + }; + for (const name of GLOBAL_FLAGS) addOption(root, name); + for (const spec of COMMAND_SPECS) { + let command = root; + for (const word of spec.path) { + let child = command.commands.find((candidate) => candidate.name() === word); + if (!child) { + child = configure(command.createCommand(word)); + command.addCommand(child); + child.description(SUMMARIES[commandPath(child).join(" ")] ?? ACTION_SUMMARIES[word] ?? `Manage ${word} comments`); + } + command = child; + } + if (spec.arguments) command.arguments(spec.arguments); + for (const alias of spec.aliases ?? []) command.alias(alias[alias.length - 1]!); + for (const name of spec.flags) addOption(command, name); + for (const name of spec.requiredFlags ?? []) { + const option = command.options.find((option) => option.long === `--${name}`)!; + option.makeOptionMandatory(); + option.description += " (required)"; + } + for (const group of CONFLICTS) { + const options = command.options.filter((option) => group.includes(option.long!.slice(2))); + for (const option of options) option.conflicts(options.filter((other) => other !== option).map((other) => other.attributeName())); + } + command.action(() => { selected = command; }); + } + const help = configure(new Command("help")).description("Discover commands and their options").argument("[command...]", "Command path to inspect"); + root.addCommand(help); + help.action((path: string[]) => { + const target = findCommand(root, path); + if (!target) throw usageError("Unknown help topic."); + target.help(); + }); + const groups = (command: Command): void => { + if (command.commands.length) command.action(() => command.help()); + command.commands.forEach(groups); + }; + groups(root); + return { root, selected: () => selected, helpRequested: () => helpRequested, versionRequested: () => versionRequested }; +} diff --git a/src/commander.test.ts b/src/commander.test.ts new file mode 100644 index 00000000..cc53a01a --- /dev/null +++ b/src/commander.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { parseArgv } from "./argv.js"; +import { commandHelp } from "./help.js"; +import { CliError } from "./problems.js"; + +const usage = (error: unknown) => error instanceof CliError && error.problem.code === "usage_error"; + +test("negated options only become flags when explicitly supplied", () => { + const plain = parseArgv(["media", "upload", "clip.mp4"]); + for (const name of ["no-wait", "no-audio", "no-transcode", "no-progress"]) assert.equal(Object.hasOwn(plain.flags, name), false); + const provided = parseArgv(["media", "upload", "clip.mp4", "--no-wait", "--no-audio", "--no-progress"]); + for (const name of ["no-wait", "no-audio", "no-progress"]) assert.equal(provided.flags[name], true); + assert.equal(Object.hasOwn(parseArgv(["media", "upload", "clip.mp4"]).flags, "no-wait"), false, "parse state must not leak between invocations"); +}); + +test("global options retain their meaning around nested command paths", () => { + for (const argv of [ + ["--json", "--timeout", "5", "screen", "show", "scr_TEST"], + ["screen", "--json", "show", "scr_TEST", "--timeout", "5"], + ["screen", "show", "scr_TEST", "--timeout", "5", "--json"], + ]) { + assert.deepEqual({ ...parseArgv(argv).flags }, { json: true, timeout: "5" }); + assert.deepEqual(parseArgv(argv).positionals, ["screen", "show", "scr_TEST"]); + } +}); + +test("missing option values cannot swallow switches or use text after the terminator", () => { + for (const argv of [ + ["app", "upload", "site", "--name", "--no-wait"], + ["app", "upload", "--name", "--no-wait", "--", "--name=--no-wait"], + ["screen", "list", "--timeout", "--json"], + ["--help", "-h"], ["--version", "-V"], + ]) assert.throws(() => parseArgv(argv), usage); + assert.equal(parseArgv(["app", "upload", "site", "--name=--no-wait"]).flags.name, "--no-wait"); + assert.equal(parseArgv(["app", "pack", "--", "--help"]).positionals[2], "--help"); +}); + +test("command-specific flags are scoped to their command", () => { + assert.throws(() => parseArgv(["screen", "list", "--name", "Lobby"]), usage); + assert.throws(() => parseArgv(["--name", "Lobby", "screen", "update", "scr_TEST", "--if-match", "1"]), usage); + assert.equal(parseArgv(["screen", "update", "scr_TEST", "--name", "Lobby", "--if-match", "1"]).flags.name, "Lobby"); +}); + +test("required options are discoverable and enforced before dispatch", () => { + assert.throws(() => parseArgv(["screen", "assign", "scr_TEST"]), usage); + const help = commandHelp(["screen", "assign"]); + for (const name of ["--playlist-id", "--if-match"]) assert.equal(help.options.find((option) => option.name === name)?.required, true); + assert.doesNotThrow(() => parseArgv(["screen", "assign", "--help"])); +}); + +test("Commander errors use one secret-safe envelope and never raw output", () => { + const bin = fileURLToPath(new URL("bin.js", import.meta.url)); + for (const argv of [["--json", "screen", "list", "--private-secret=value"], ["--json", "screen", "list", "--timeout", "private-secret"]]) { + assert.throws(() => execFileSync(process.execPath, [bin, ...argv], { encoding: "utf8", stdio: "pipe" }), (error: unknown) => { + const result = error as { status: number; stdout: string; stderr: string }; + assert.equal(result.status, 2); + assert.equal(result.stderr, ""); + const envelope = JSON.parse(result.stdout); + assert.equal(envelope.ok, false); + assert.equal(envelope.error.code, "usage_error"); + assert.doesNotMatch(result.stdout, /private-secret/); + return true; + }); + } +}); diff --git a/src/commands.ts b/src/commands.ts index 5b24dd5f..900b233a 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -137,9 +137,7 @@ import { validateAgentConnectionStart, type AgentConnectionConfig, } from "./agent-identity.js"; -import { commandHelp, isHelpGroup } from "./help.js"; import { CLI_VERSION } from "./version.js"; -import { validateInvocation } from "./validate-invocation.js"; export { CLI_VERSION }; @@ -362,7 +360,6 @@ function humanLines(title: string, fields: Array<[string, string | undefined]>): } export async function dispatch(args: ParsedArgs, runtime: CliRuntime): Promise<CommandResult> { - validateInvocation(args); const group = args.positionals[0]; const action = args.positionals[1]; if ((flagBool(args.flags, "version") || group === "version") && !flagBool(args.flags, "help") && group !== "help") { @@ -372,8 +369,8 @@ export async function dispatch(args: ParsedArgs, runtime: CliRuntime): Promise<C human: `screenrig ${CLI_VERSION}`, }; } - if (!group || flagBool(args.flags, "help") || group === "help" || isHelpGroup(args.positionals)) { - const help = commandHelp(group === "help" ? args.positionals.slice(1) : args.positionals, group !== "help"); + if (args.help) { + const help = args.help; return { envelope: successEnvelope(help), exitCode: ExitCode.Success, @@ -381,6 +378,8 @@ export async function dispatch(args: ParsedArgs, runtime: CliRuntime): Promise<C }; } + if (!group) throw usageError("A command is required."); + const repair = flagBool(args.flags, "repair-config"); let resolved = await resolveConfig({ flags: args.flags, fs: { ...runtime.fs, env: runtime.env, homedir: runtime.homedir }, repair }); await attachOperationLogger(runtime, args, resolved); diff --git a/src/compose/cli.test.ts b/src/compose/cli.test.ts index bef77010..6d667c29 100644 --- a/src/compose/cli.test.ts +++ b/src/compose/cli.test.ts @@ -4,7 +4,6 @@ import path from "node:path"; import { PassThrough } from "node:stream"; import { test } from "node:test"; import { commandHelp } from "../help.js"; -const USAGE = [["compose", "batch"], ["compose", "render"], ["playlist", "preview"]].map((path) => commandHelp(path).usage).join("\n"); import { ExitCode } from "../exit-codes.js"; import { run, type CliRuntime } from "../main.js"; import { testTemp } from "../test-temp.js"; @@ -198,12 +197,23 @@ test("compose render --output foo.png is usage_error naming a directory", async }); test("USAGE documents compose batch page limit and layered render", () => { - assert.match(USAGE, /compose batch <file> --output DIRECTORY \[--only ID\]/); - assert.match(USAGE, /1 to 2000 pages/); - assert.doesNotMatch(USAGE, /1 to 100 pages/); - assert.match(USAGE, /compose render <file> \[--output DIRECTORY\] \[--combined\]/); - assert.doesNotMatch(USAGE, /\[--ink-tight\]/); - assert.match(USAGE, /playlist preview <file\|id> --output DIR \[--frame-ms MS\] \[--contact-sheet\] \[--lint-only\]/); + const batch = commandHelp(["compose", "batch"]); + assert.match(batch.synopsis[0]!, /compose batch \[options\] <file>/); + for (const name of ["--output", "--only"]) { + assert.equal(batch.options.find((option) => option.name === name)?.type, "value"); + } + assert.match(batch.usage, /1 to 2000 pages/); + assert.doesNotMatch(batch.usage, /1 to 100 pages/); + const render = commandHelp(["compose", "render"]); + assert.match(render.synopsis[0]!, /compose render \[options\] <file>/); + assert.equal(render.options.find((option) => option.name === "--output")?.type, "value"); + assert.equal(render.options.find((option) => option.name === "--combined")?.type, "boolean"); + assert.equal(render.options.some((option) => option.name === "--ink-tight"), false); + const preview = commandHelp(["playlist", "preview"]); + assert.match(preview.synopsis[0]!, /playlist preview \[options\] <file\|id>/); + for (const [name, type] of [["--output", "value"], ["--frame-ms", "value"], ["--contact-sheet", "boolean"], ["--lint-only", "boolean"]]) { + assert.equal(preview.options.find((option) => option.name === name)?.type, type); + } }); test("batch render returns ordered results, preview and supports one-page correction", async () => { diff --git a/src/help-text.ts b/src/help-text.ts new file mode 100644 index 00000000..93d77722 --- /dev/null +++ b/src/help-text.ts @@ -0,0 +1,54 @@ +import { LOOK_AT_THE_CONTACT_SHEET } from "./playlist-preview.js"; + +export const CREDIT_HELP = "Credits:\n Remaining is a nonnegative whole number (never negative; empty remaining\n displays 0). Below 1000 credits, authenticated responses may warn\n credits_low. Until 1 Jan 2027 08:00 UTC (midnight PT), production fails open:\n billed commands are not rejected for empty remaining and do not return\n HTTP 402. After that instant, remaining below 1 credit is payment_required.\n Empty remaining does not stop or shut off screens in this window.\n media generate is the exception: it is billed per token.\n Text input is $10 / 1M tokens, image input is $16 / 1M, image output is\n $60 / 1M. Quality changes how detailed the still is and therefore how\n many tokens it uses. Remaining that cannot cover the debit returns\n payment_required / 402, including during this window."; + +export const SUMMARIES: Record<string, string> = { + account: "Inspect your account and credits", + agent: "Enroll, connect, and manage this agent", + dashboard: "Open the account dashboard", + app: "Pack, upload, and inspect applications", + media: "Generate, upload, and manage images and videos", + compose: "Render and inspect local page compositions", + playlist: "Author, validate, preview, and manage playlists", + screen: "Pair, configure, and inspect screens", + browser: "Complete browser Player setup", + kv: "Read and write application key-value data", + comment: "Read and manage screen and playlist comments", + operations: "Inspect, wait for, or cancel operations", + events: "List or follow account events", + playback: "Inspect playback records", + feedback: "Submit bugs and feature requests", + doctor: "Check this CLI installation and configuration", + version: "Show CLI and protocol versions", + help: "Discover commands and their options", +}; + +export const ACTION_SUMMARIES: Record<string, string> = { + enroll: "Create the first agent with an email address", connect: "Connect this installation with approval", + status: "Inspect this agent's connection", disconnect: "Disconnect this agent", + pack: "Pack a local application directory", upload: "Upload a local file or directory", + update: "Update an existing resource", list: "List resources", show: "Inspect a resource", + generate: "Generate an image from a prompt", download: "Download media to a file", + "upload-batch": "Upload a manifest with resumable state", delete: "Delete a resource", + catalog: "Browse local composition capabilities and examples", batch: "Render a batch of compositions", + render: "Render a composition locally", validate: "Validate a playlist file", + preview: "Render playlist previews and a contact sheet", templates: "Browse playlist templates", + create: "Create a playlist from a file", export: "Export a playlist bundle", import: "Import a playlist bundle", + pair: "Claim a Player pairing code", provision: "Create a screen and browser handoff", + setup: "Complete browser setup from a code", assign: "Assign a playlist to a screen", + "set-timezone": "Set a screen's timezone", archive: "Archive a screen", unarchive: "Restore an archived screen", + "rotate-public-id": "Rotate a screen's public identifier", toast: "Show a temporary screen message", + screenshot: "Capture and download a screen screenshot", get: "Read the current value or operation", + set: "Write a value", wait: "Wait for an operation to finish", cancel: "Cancel an operation", + follow: "Stream account events", bug: "Submit a bug report", feature: "Submit a feature request", +}; + +export const NOTES: Record<string, string> = { + "agent connect": "Approval expires after 24 hours; --timeout defaults to 86400000 ms. Retry agent connect to resume after an interrupted wait.", + "media generate": CREDIT_HELP, + "account show": CREDIT_HELP, + "compose render": LOOK_AT_THE_CONTACT_SHEET, + "compose batch": `Render 1 to 2000 pages. ${LOOK_AT_THE_CONTACT_SHEET}`, + "playlist preview": LOOK_AT_THE_CONTACT_SHEET, +}; + diff --git a/src/help.test.ts b/src/help.test.ts index ade87866..2fab0540 100644 --- a/src/help.test.ts +++ b/src/help.test.ts @@ -42,7 +42,12 @@ test("help paths, --help, and bare groups work before configuration or authentic if (flagged.data.kind === "group") assert.deepEqual(flagged, invoke(path)); } const action = invoke(["screen", "assign", "--help"]).data; - assert.match(action.usage, /--playlist-id ID --if-match REVISION/); + assert.match(action.usage, /Usage: screenrig screen assign \[options\] <id>/); + for (const name of ["--playlist-id", "--if-match"]) { + const option = action.options.find((item) => item.name === name); + assert.equal(option?.type, "value"); + assert.ok(option?.description); + } assert.ok(action.options.some((option) => option.name === "--playlist-id" && option.type === "value")); assert.deepEqual(invoke(["comment", "show", "--help"]).data.commands.map((item) => item.name), ["screen", "playlist"]); }); @@ -76,3 +81,13 @@ test("help appended to an invocation resolves the command without echoing argume assert.deepEqual(invoke(["screen", "assign", "scr_TEST", "--help"]), invoke(["screen", "assign", "--help"])); assert.deepEqual(invoke(["playlist", "get", "pl_TEST", "--help"]), invoke(["playlist", "show", "--help"])); }); + +test("negative switches are documented as booleans without value placeholders", () => { + const help = invoke(["media", "upload", "--help"]).data; + for (const name of ["--no-wait", "--no-transcode", "--no-progress", "--no-audio"]) { + const option = help.options.find((item) => item.name === name); + assert.equal(option?.type, "boolean", name); + assert.ok(option?.description, name); + assert.match(help.usage, new RegExp(`${name}\\s+[^<\\s]`)); + } +}); diff --git a/src/help.ts b/src/help.ts index 7502a2ba..cc02d102 100644 --- a/src/help.ts +++ b/src/help.ts @@ -1,264 +1,8 @@ -import { BOOLEAN_FLAGS, COMMAND_SPECS, GLOBAL_FLAGS } from "./command-spec.js"; +import type { Command, Option } from "commander"; +import { commandPath, createCommandTree, findCommand } from "./command-tree.js"; +import { NOTES } from "./help-text.js"; import { usageError } from "./problems.js"; -import { LOOK_AT_THE_CONTACT_SHEET } from "./playlist-preview.js"; - -const COMMAND_USAGE: Record<string, readonly string[]> = { - "account show": [ - "account show" - ], - "agent enroll": [ - "agent enroll --email ADDRESS [--name NAME] [--open-dashboard]" - ], - "agent connect": [ - "agent connect [--name NAME] [--print-url] [--timeout MS]" - ], - "agent status": [ - "agent status" - ], - "agent disconnect": [ - "agent disconnect --yes [--allow-lockout]" - ], - "dashboard": [ - "dashboard [--print-url]" - ], - "app pack": [ - "app pack <directory> [--output FILE]" - ], - "app upload": [ - "app upload <directory> [--name NAME] [--no-wait] [--poll-ms MS]" - ], - "app update": [ - "app update <id> <directory> --if-match REVISION [--no-wait] [--poll-ms MS]" - ], - "app list": [ - "app list" - ], - "app show": [ - "app show <id>" - ], - "media generate": [ - "media generate --prompt TEXT [--aspect-ratio RATIO] [--quality low|medium|high] [--tag TAG]" - ], - "media upload": [ - "media upload <file> [--content-type TYPE] [--tag TAG] [--no-wait] [--poll-ms MS] [--no-transcode] [--codec h264|hevc] [--max-fps N] [--max-edge PIXELS] [--webp-quality 1-100] [--no-progress] [--preset signage-1080p30|signage-4k30] [--no-audio]" - ], - "media upload-batch": [ - "media upload-batch <manifest.json> --state FILE [--concurrency N] [--no-transcode] [--tag TAG] [--no-progress]" - ], - "media show": [ - "media show <id>" - ], - "media download": [ - "media download <id> [--output FILE]" - ], - "media list": [ - "media list [--tag TAG] [--primitive image|video]" - ], - "media update": [ - "media update <id> (--tag TAG | --clear-tag) --if-match REVISION" - ], - "media delete": [ - "media delete <id> --if-match REVISION" - ], - "compose catalog": [ - "compose catalog (local regions, enter/motion, fonts, examples; no network)" - ], - "compose batch": [ - "compose batch <file> --output DIRECTORY [--only ID] [--target-width PX --target-height PX] [--safe-area] [--lint-only] (contact sheet; 1 to 2000 pages)" - ], - "playlist validate": [ - "playlist validate <file> [--lint-only]" - ], - "compose render": [ - "compose render <file> [--output DIRECTORY] [--combined] [--target-width PX --target-height PX] [--safe-area] [--open] [--lint-only]" - ], - "playlist preview": [ - "playlist preview <file|id> --output DIR [--frame-ms MS] [--contact-sheet] [--lint-only]" - ], - "playlist templates": [ - "playlist templates" - ], - "playlist create": [ - "playlist create <file>" - ], - "playlist update": [ - "playlist update <id> <file> --if-match REVISION" - ], - "playlist export": [ - "playlist export <id> --output DIRECTORY" - ], - "playlist import": [ - "playlist import <directory> [--name NAME] [--update ID --if-match REVISION]" - ], - "playlist show": [ - "playlist show <id>" - ], - "playlist list": [ - "playlist list" - ], - "playlist delete": [ - "playlist delete <id> --if-match REVISION" - ], - "screen pair": [ - "screen pair CODE [--label LABEL]" - ], - "screen provision": [ - "screen provision (--open | --print-url) [--label LABEL]" - ], - "browser setup": [ - "browser setup --code CODE [--open]" - ], - "screen update": [ - "screen update <id> [--name NAME] [--playlist-id ID] [--timezone ZONE] --if-match REVISION" - ], - "screen list": [ - "screen list [--state archived]" - ], - "screen show": [ - "screen show <id>" - ], - "screen assign": [ - "screen assign <id> --playlist-id ID --if-match REVISION" - ], - "screen set-timezone": [ - "screen set-timezone <id> --timezone ZONE --if-match REVISION" - ], - "screen archive": [ - "screen archive <id> --if-match REVISION" - ], - "screen unarchive": [ - "screen unarchive <id> --if-match REVISION" - ], - "screen delete": [ - "screen delete <id> --if-match REVISION" - ], - "screen rotate-public-id": [ - "screen rotate-public-id <id> --if-match REVISION" - ], - "screen toast": [ - "screen toast <id> --text TEXT [--level info] [--duration-ms MS]" - ], - "screen screenshot": [ - "screen screenshot <id> [--output FILE] [--timeout MS] [--poll-ms MS]" - ], - "kv get": [ - "kv get --application-id ID <key>" - ], - "kv set": [ - "kv set --application-id ID <key> --json-value JSON [--if-match REVISION]", - "kv set --application-id ID <key> --file FILE --content-type TYPE [--if-match REVISION]", - "kv set --application-id ID <key> --value-base64 BASE64 --content-type TYPE [--if-match REVISION]" - ], - "kv delete": [ - "kv delete --application-id ID <key> --if-match REVISION" - ], - "kv list": [ - "kv list --application-id ID" - ], - "comment show screen": [ - "comment show screen <id>" - ], - "comment show playlist": [ - "comment show playlist <id> [--page PAGE_ID]" - ], - "comment set screen": [ - "comment set screen <id> (--json-value JSON | --file FILE)" - ], - "comment set playlist": [ - "comment set playlist <id> [--page PAGE_ID] (--json-value JSON | --file FILE)" - ], - "comment delete screen": [ - "comment delete screen <id>" - ], - "comment delete playlist": [ - "comment delete playlist <id> [--page PAGE_ID]" - ], - "operations get": [ - "operations get <id>" - ], - "operations wait": [ - "operations wait <id> [--timeout MS] [--poll-ms MS]" - ], - "operations cancel": [ - "operations cancel <id>" - ], - "events list": [ - "events list [--after CURSOR] [--limit N]" - ], - "events follow": [ - "events follow [--after CURSOR] [--timeout MS]" - ], - "playback list": [ - "playback list [--screen-id ID] [--media-id ID] [--day YYYY-MM-DD]" - ], - "feedback bug": [ - "feedback bug <title> (--body TEXT | --body-file FILE) [--command \"GROUP ACTION\"] [--no-context]" - ], - "feedback feature": [ - "feedback feature <title> (--body TEXT | --body-file FILE) [--command \"GROUP ACTION\"] [--no-context]" - ], - "feedback list": [ - "feedback list [--kind bug|feature]" - ], - "doctor": [ - "doctor [--repair-config]" - ], - "version": [ - "version" - ] -}; - -export const CREDIT_HELP = "Credits:\n Remaining is a nonnegative whole number (never negative; empty remaining\n displays 0). Below 1000 credits, authenticated responses may warn\n credits_low. Until 1 Jan 2027 08:00 UTC (midnight PT), production fails open:\n billed commands are not rejected for empty remaining and do not return\n HTTP 402. After that instant, remaining below 1 credit is payment_required.\n Empty remaining does not stop or shut off screens in this window.\n media generate is the exception: it is billed per token.\n Text input is $10 / 1M tokens, image input is $16 / 1M, image output is\n $60 / 1M. Quality changes how detailed the still is and therefore how\n many tokens it uses. Remaining that cannot cover the debit returns\n payment_required / 402, including during this window."; - -const SUMMARIES: Record<string, string> = { - account: "Inspect your account and credits", - agent: "Enroll, connect, and manage this agent", - dashboard: "Open the account dashboard", - app: "Pack, upload, and inspect applications", - media: "Generate, upload, and manage images and videos", - compose: "Render and inspect local page compositions", - playlist: "Author, validate, preview, and manage playlists", - screen: "Pair, configure, and inspect screens", - browser: "Complete browser Player setup", - kv: "Read and write application key-value data", - comment: "Read and manage screen and playlist comments", - operations: "Inspect, wait for, or cancel operations", - events: "List or follow account events", - playback: "Inspect playback records", - feedback: "Submit bugs and feature requests", - doctor: "Check this CLI installation and configuration", - version: "Show CLI and protocol versions", - help: "Discover commands and their options", -}; - -const ACTION_SUMMARIES: Record<string, string> = { - enroll: "Create the first agent with an email address", connect: "Connect this installation with approval", - status: "Inspect this agent's connection", disconnect: "Disconnect this agent", - pack: "Pack a local application directory", upload: "Upload a local file or directory", - update: "Update an existing resource", list: "List resources", show: "Inspect a resource", - generate: "Generate an image from a prompt", download: "Download media to a file", - "upload-batch": "Upload a manifest with resumable state", delete: "Delete a resource", - catalog: "Browse local composition capabilities and examples", batch: "Render a batch of compositions", - render: "Render a composition locally", validate: "Validate a playlist file", - preview: "Render playlist previews and a contact sheet", templates: "Browse playlist templates", - create: "Create a playlist from a file", export: "Export a playlist bundle", import: "Import a playlist bundle", - pair: "Claim a Player pairing code", provision: "Create a screen and browser handoff", - setup: "Complete browser setup from a code", assign: "Assign a playlist to a screen", - "set-timezone": "Set a screen's timezone", archive: "Archive a screen", unarchive: "Restore an archived screen", - "rotate-public-id": "Rotate a screen's public identifier", toast: "Show a temporary screen message", - screenshot: "Capture and download a screen screenshot", get: "Read the current value or operation", - set: "Write a value", wait: "Wait for an operation to finish", cancel: "Cancel an operation", - follow: "Stream account events", bug: "Submit a bug report", feature: "Submit a feature request", -}; - -const NOTES: Record<string, string> = { - "agent connect": "Approval expires after 24 hours; --timeout defaults to 86400000 ms. Retry agent connect to resume after an interrupted wait.", - "media generate": CREDIT_HELP, - "account show": CREDIT_HELP, - "compose render": LOOK_AT_THE_CONTACT_SHEET, - "compose batch": LOOK_AT_THE_CONTACT_SHEET, - "playlist preview": LOOK_AT_THE_CONTACT_SHEET, -}; +export { CREDIT_HELP } from "./help-text.js"; export interface HelpDocument { path: string[]; @@ -267,66 +11,40 @@ export interface HelpDocument { usage: string; synopsis: string[]; commands: Array<{ name: string; path: string[]; kind: "group" | "command"; summary: string; help: string }>; - options: Array<{ name: string; type: "boolean" | "value" }>; - globalOptions: Array<{ name: string; type: "boolean" | "value" }>; + options: Array<{ name: string; type: "boolean" | "value"; description: string; required: boolean }>; + globalOptions: Array<{ name: string; type: "boolean" | "value"; description: string; required: boolean }>; notes: string[]; } -function matches(prefix: readonly string[], path: readonly string[]): boolean { - return prefix.every((part, index) => path[index] === part); -} - -export function isHelpGroup(path: readonly string[]): boolean { - return COMMAND_SPECS.some((spec) => spec.path.length > path.length && matches(path, spec.path)); +/** JSON discovery and human help both read the actual Commander command tree. */ +export function describeHelp(command: Command): HelpDocument { + const helper = command.createHelp(); + const path = commandPath(command); + const note = NOTES[path.join(" ")]; + const notes = note ? [note] : []; + const describeOption = (option: Option) => ({ + name: option.long!, type: (option.negate || option.isBoolean()) ? "boolean" as const : "value" as const, description: option.description, required: option.mandatory, + }); + return { + path, + aliases: command.aliases().map((alias) => [...path.slice(0, -1), alias]), + kind: command.commands.length ? "group" : "command", + usage: [command.helpInformation().trimEnd(), ...notes].join("\n\n"), + synopsis: [helper.commandUsage(command)], + commands: helper.visibleCommands(command).map((child) => ({ + name: child.name(), path: commandPath(child), kind: child.commands.length ? "group" as const : "command" as const, + summary: child.description(), help: `screenrig ${commandPath(child).join(" ")} --help`, + })), + options: helper.visibleOptions(command).map(describeOption), + globalOptions: helper.visibleGlobalOptions(command).map(describeOption), + notes, + }; } -/** Resolve only command path components: arbitrary extra arguments are never hidden by help. */ -export function commandHelp(path: readonly string[] = [], invocation = false): HelpDocument { - if (invocation) { - const invoked = COMMAND_SPECS.find((spec) => [spec.path, ...(spec.aliases ?? [])].some((candidate) => - path.length >= candidate.length && path.length <= candidate.length + spec.maxArgs && matches(candidate, path))); - if (invoked) return commandHelp(invoked.path); - } - const alias = COMMAND_SPECS.find((spec) => spec.aliases?.some((candidate) => candidate.length === path.length && matches(path, candidate))); - if (alias) return commandHelp(alias.path); - const leaf = COMMAND_SPECS.find((spec) => spec.path.length === path.length && matches(path, spec.path)); - const descendants = COMMAND_SPECS.filter((spec) => matches(path, spec.path)); - const helpLeaf = path.length === 1 && path[0] === "help"; - if (!leaf && !descendants.length && path.length && !helpLeaf) { - throw usageError("Unknown help topic.", { - command: "screenrig help", - reason: "List command groups, then request help for a group or command.", - }); - } - const key = path.join(" "); - const childNames = [...new Set(descendants.filter((spec) => spec.path.length > path.length).map((spec) => spec.path[path.length]!))]; - if (!path.length) childNames.push("help"); - const commands = childNames.map((name) => { - const childPath = [...path, name]; - const group = isHelpGroup(childPath); - return { - name, - path: childPath, - kind: group ? "group" as const : "command" as const, - summary: SUMMARIES[childPath.join(" ")] ?? (group ? `Discover ${name} commands` : ACTION_SUMMARIES[name] ?? `Manage ${name} comments`), - help: `screenrig ${childPath.join(" ")} --help`, - }; - }); - const synopsis = helpLeaf ? ["screenrig help [command [subcommand ...]]"] : leaf - ? (COMMAND_USAGE[key] ?? [key]).map((line) => `screenrig ${line}`) - : [`screenrig${key ? ` ${key}` : ""} <command> [args]`]; - const option = (flag: string) => ({ name: `--${flag}`, type: BOOLEAN_FLAGS.has(flag) ? "boolean" as const : "value" as const }); - const options = leaf ? [...leaf.flags].map(option) : []; - const globalOptions = [...GLOBAL_FLAGS].filter((flag) => flag !== "token").map(option); - const optionText = (item: {name: string; type: string}) => `${item.name}${item.type === "value" ? " VALUE" : ""}`; - const notes = NOTES[key] ? [NOTES[key]!] : []; - if (leaf?.aliases?.length) notes.push(`Aliases: ${leaf.aliases.map((aliasPath) => `screenrig ${aliasPath.join(" ")}`).join(", ")}`); - const lines = [path.length ? `screenrig ${key}` : "screenrig — Signage and kiosk infrastructure for agents", "", "Usage:", ...synopsis.map((line) => ` ${line}`)]; - if (commands.length) lines.push("", "Commands:", ...commands.map((command) => ` ${command.name.padEnd(16)} ${command.summary}`)); - if (options.length) lines.push("", `Command options: ${options.map(optionText).join(" ")}`); - lines.push("", `Global options: ${globalOptions.map(optionText).join(" ")}`, "", "Use screenrig <command> --help to explore each group or action. Add --json for structured help."); - if (notes.length) lines.push("", ...notes); - return { path: [...path], aliases: leaf?.aliases?.map((aliasPath) => [...aliasPath]) ?? [], kind: leaf || helpLeaf ? "command" : "group", usage: lines.join("\n"), synopsis, commands, options, globalOptions, notes }; +export function commandHelp(path: readonly string[] = []): HelpDocument { + const command = findCommand(createCommandTree().root, path); + if (!command) throw usageError("Unknown help topic."); + return describeHelp(command); } export const ROOT_HELP = commandHelp().usage; diff --git a/src/media-generate.test.ts b/src/media-generate.test.ts index 9697f5f1..515e27bd 100644 --- a/src/media-generate.test.ts +++ b/src/media-generate.test.ts @@ -4,7 +4,6 @@ import path from "node:path"; import { PassThrough } from "node:stream"; import { test } from "node:test"; import { commandHelp } from "./help.js"; -const USAGE = commandHelp(["media", "generate"]).usage; import { writeConfigAtomic, type ConfigFs } from "./config.js"; import { ExitCode } from "./exit-codes.js"; import { run, type CliRuntime } from "./main.js"; @@ -106,12 +105,16 @@ function generateTransport(): FakeTransport { } test("USAGE lists media generate", () => { - assert.match( - USAGE, - /media generate --prompt TEXT \[--aspect-ratio RATIO\] \[--quality low\|medium\|high\] \[--tag TAG\]/, - ); - assert.doesNotMatch(USAGE, /media generate.*--no-wait/); - assert.doesNotMatch(USAGE, /--quality auto/); + const help = commandHelp(["media", "generate"]); + assert.match(help.synopsis[0]!, /media generate \[options\]/); + for (const name of ["--prompt", "--aspect-ratio", "--quality", "--tag"]) { + const option = help.options.find((item) => item.name === name); + assert.equal(option?.type, "value"); + assert.ok(option?.description); + } + assert.match(help.usage, /--quality <low\|medium\|high>/); + assert.equal(help.options.some((option) => option.name === "--no-wait"), false); + assert.doesNotMatch(help.usage, /--quality.*auto/); }); test("media generate stores the still and returns med_… plus usage", async () => { diff --git a/src/media-upload-batch.test.ts b/src/media-upload-batch.test.ts index 37853255..b37e2bf5 100644 --- a/src/media-upload-batch.test.ts +++ b/src/media-upload-batch.test.ts @@ -7,7 +7,6 @@ import { test } from "node:test"; import type { MediaUploadDeclaration, Operation } from "./adapters/protocol.js"; import { ApiClient } from "./client.js"; import { commandHelp } from "./help.js"; -const USAGE = commandHelp(["media", "upload-batch"]).usage; import { writeConfigAtomic, type ConfigFs } from "./config.js"; import { ExitCode } from "./exit-codes.js"; import { createMemoryLogger } from "./log/logger.js"; @@ -224,8 +223,13 @@ async function writeManifest(dir: string, files: string[]): Promise<string> { } test("USAGE lists media upload-batch", () => { - assert.match(USAGE, /media upload-batch <manifest\.json> --state FILE \[--concurrency N\]/); - assert.match(USAGE, /\[--no-transcode\] \[--tag TAG\] \[--no-progress\]/); + const help = commandHelp(["media", "upload-batch"]); + assert.match(help.synopsis[0]!, /media upload-batch \[options\] <manifest\.json>/); + for (const [name, type] of [["--state", "value"], ["--concurrency", "value"], ["--no-transcode", "boolean"], ["--tag", "value"], ["--no-progress", "boolean"]]) { + const option = help.options.find((item) => item.name === name); + assert.equal(option?.type, type); + assert.ok(option?.description); + } }); test("backoff honours Retry-After and otherwise uses capped equal jitter", () => { diff --git a/src/playlist-bundle.test.ts b/src/playlist-bundle.test.ts index 838dfba6..21597bce 100644 --- a/src/playlist-bundle.test.ts +++ b/src/playlist-bundle.test.ts @@ -20,7 +20,6 @@ import { test } from "node:test"; import { parseArgv } from "./argv.js"; import { ApiClient } from "./client.js"; import { commandHelp } from "./help.js"; -const USAGE = ["export", "import"].map((action) => commandHelp(["playlist", action]).usage).join("\n"); import { run, type CliRuntime } from "./main.js"; import { PLAYLIST_BUNDLE_MANIFEST, @@ -999,8 +998,14 @@ test("command parser and JSON help expose playlist export/import including updat const parsed = parseArgv(["playlist", "import", "./bundle", "--update", "pl_TARGET", "--if-match", "8"]); assert.equal(parsed.flags.update, "pl_TARGET"); assert.equal(parsed.flags["if-match"], "8"); - assert.match(USAGE, /playlist export <id> --output DIRECTORY/); - assert.match(USAGE, /playlist import <directory> \[--name NAME\] \[--update ID --if-match REVISION\]/); + const exportHelp = commandHelp(["playlist", "export"]); + assert.match(exportHelp.synopsis[0]!, /playlist export \[options\] <id>/); + assert.equal(exportHelp.options.find((option) => option.name === "--output")?.type, "value"); + const importHelp = commandHelp(["playlist", "import"]); + assert.match(importHelp.synopsis[0]!, /playlist import \[options\] <directory>/); + for (const name of ["--name", "--update", "--if-match"]) { + assert.equal(importHelp.options.find((option) => option.name === name)?.type, "value"); + } const stdout = new PassThrough(); const stderr = new PassThrough(); diff --git a/src/playlist-preview.test.ts b/src/playlist-preview.test.ts index 8524e3f8..4a662081 100644 --- a/src/playlist-preview.test.ts +++ b/src/playlist-preview.test.ts @@ -5,7 +5,6 @@ import { PassThrough } from "node:stream"; import { test } from "node:test"; import { loadImage } from "@napi-rs/canvas"; import { commandHelp } from "./help.js"; -const USAGE = commandHelp(["playlist", "preview"]).usage; import { ExitCode } from "./exit-codes.js"; import { run, type CliRuntime } from "./main.js"; import type { ConfigFs } from "./config.js"; @@ -92,9 +91,12 @@ function iframePage(id: string, extra: Record<string, unknown> = {}): Record<str } test("USAGE and README sentence is printed verbatim", () => { - assert.match(USAGE, /playlist preview <file\|id> --output DIR \[--frame-ms MS\] \[--contact-sheet\] \[--lint-only\]/); - assert.ok(USAGE.includes(LOOK_AT_THE_CONTACT_SHEET)); - assert.match(USAGE, /\[--lint-only\]/); + const help = commandHelp(["playlist", "preview"]); + assert.match(help.synopsis[0]!, /playlist preview \[options\] <file\|id>/); + assert.ok(help.usage.includes(LOOK_AT_THE_CONTACT_SHEET)); + for (const [name, type] of [["--output", "value"], ["--frame-ms", "value"], ["--contact-sheet", "boolean"], ["--lint-only", "boolean"]]) { + assert.equal(help.options.find((option) => option.name === name)?.type, type); + } }); test("playlist preview writes N*3 stable files and a 6-column contact sheet", async () => { diff --git a/src/screenshot.test.ts b/src/screenshot.test.ts index 72cfd36c..dcf4011e 100644 --- a/src/screenshot.test.ts +++ b/src/screenshot.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { PassThrough } from "node:stream"; import { test } from "node:test"; import { parseArgv } from "./argv.js"; +import { CliError } from "./problems.js"; import { writeConfigAtomic, type ConfigFs } from "./config.js"; import { ExitCode } from "./exit-codes.js"; import { run, type CliRuntime } from "./main.js"; @@ -130,10 +131,8 @@ function screenshotCalls(transport: FakeTransport): TransportRequest[] { } test("parseArgv keeps screen screenshot id and --output as a file path", () => { - const missing = parseArgv(["screen", "screenshot"]); - assert.deepEqual(missing.command, ["screen", "screenshot"]); - assert.equal(missing.positionals[2], undefined); - assert.equal(missing.flags.output, undefined); + assert.throws(() => parseArgv(["screen", "screenshot"]), + (error: unknown) => error instanceof CliError && error.problem.code === "usage_error"); const defaults = parseArgv(["screen", "screenshot", SCREEN_ID]); assert.equal(defaults.positionals[2], SCREEN_ID); diff --git a/src/validate-invocation.test.ts b/src/validate-invocation.test.ts index 8cb9b915..bfe0b7e5 100644 --- a/src/validate-invocation.test.ts +++ b/src/validate-invocation.test.ts @@ -4,7 +4,6 @@ import { parseArgv } from "./argv.js"; import { dispatch } from "./commands.js"; import { CliError } from "./problems.js"; import type { CliRuntime } from "./runtime.js"; -import { validateInvocation } from "./validate-invocation.js"; const invalid = [ ["screen", "update", "scr_TEST", "--name", "Lobby", "--if-match", "1", "--dry-run"], @@ -48,12 +47,12 @@ test("supported values, aliases, empty byte payload, and help paths remain valid ["kv", "set", "key", "--application-id", "app_TEST", "--value-base64=", "--content-type", "text/plain"], ["--help"], ["--version"], ["screen"], ["comment", "show"], ["screen", "assign", "--help"], ["help", "comment", "show", "screen"], ["help", "help"], - ]) assert.doesNotThrow(() => validateInvocation(parseArgv(argv)), JSON.stringify(argv)); + ]) assert.doesNotThrow(() => parseArgv(argv), JSON.stringify(argv)); assert.equal(parseArgv(["app", "pack", "--", "--directory"]).positionals[2], "--directory"); }); test("unknown option names and invalid values never appear in usage errors", () => { for (const argv of [["screen", "list", "--private-secret=value"], ["screen", "list", "--timeout=private-secret"]]) { - assert.throws(() => validateInvocation(parseArgv(argv)), (error: unknown) => error instanceof CliError && !JSON.stringify(error.problem).includes("private-secret")); + assert.throws(() => parseArgv(argv), (error: unknown) => error instanceof CliError && !JSON.stringify(error.problem).includes("private-secret")); } }); diff --git a/src/validate-invocation.ts b/src/validate-invocation.ts deleted file mode 100644 index fbf086b1..00000000 --- a/src/validate-invocation.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { ParsedArgs } from "./argv.js"; -import { BOOLEAN_FLAGS, COMMAND_SPECS, GLOBAL_FLAGS } from "./command-spec.js"; -import { quotedRevision } from "./if-match.js"; -import { usageError } from "./problems.js"; - -const NUMERIC_FLAGS = new Set(["timeout", "poll-ms", "target-width", "target-height", "limit", "max-fps", "max-edge", "webp-quality", "duration-ms", "concurrency", "frame-ms"]); - -/** Validate before config reads, enrollment, local writes, or transport calls. */ -export function validateInvocation(args: ParsedArgs): void { - const flags = args.flags; - let words = args.positionals[0] === "help" ? args.positionals.slice(1) : args.positionals; - const alias = COMMAND_SPECS.find((candidate) => candidate.aliases?.some((path) => path.every((word, index) => words[index] === word))); - if (alias) words = [...alias.path, ...words.slice(alias.path.length)]; - if (words[0] === "screen" && words[1] === "revoke-credential") throw usageError("screen revoke-credential is retired. Archive the screen instead.", { command: "screenrig --json screen archive <id> --if-match REVISION", reason: "Archive hides the screen; it does not unbind the player." }); - const subtree = COMMAND_SPECS.some((candidate) => words.every((word, index) => candidate.path[index] === word)); - const help = flags.help === true || args.positionals[0] === "help" || words.length === 0 || (subtree && !COMMAND_SPECS.some((candidate) => candidate.path.length === words.length && candidate.path.every((word, index) => words[index] === word))); - const matches = COMMAND_SPECS.filter((spec) => spec.path.every((word, index) => words[index] === word)); - const spec = matches.sort((a, b) => b.path.length - a.path.length)[0]; - if (words.length === 1 && words[0] === "help" && help) words = []; - if (!spec && !(help && (subtree || words.length === 0))) throw usageError(words[0] === "comment" ? "comment commands require screen <id> or playlist <id>." : "Unknown command. Use --help to discover supported commands."); - const allowed = new Set<string>([...GLOBAL_FLAGS, ...(spec?.flags ?? [])]); - for (const [name, value] of Object.entries(flags)) { - if (!allowed.has(name)) { - if (words[0] === "comment" && name === "value-base64") throw usageError("comment set requires --json-value or --file."); - if (words[0] === "comment" && name === "if-match") throw usageError("comment commands do not take --if-match; last write wins and does not bump revision."); - if (words[0] === "comment" && name === "page") throw usageError("comment screen commands do not take --page; use comment playlist <id> --page PAGE_ID."); - if (words[0] === "media" && words[1] === "list" && name === "kind") throw usageError("media list uses --primitive image|video, not --kind."); - throw usageError("Unsupported option for this command. Use --help to discover supported options."); - } - if (BOOLEAN_FLAGS.has(name)) { - if (value !== true) throw usageError(`--${name} takes no value.`); - } else { - if (typeof value !== "string" || (!value.length && name !== "value-base64")) throw usageError(`--${name} requires a value.`); - if (NUMERIC_FLAGS.has(name)) { - const number = Number(value); - if (name === "duration-ms" && (!Number.isInteger(number) || number < 2000 || number > 60000)) throw usageError("--duration-ms must be a whole number between 2000 and 60000."); - if (!value.trim() || !Number.isFinite(number) || number < 0 || (name !== "max-fps" && !Number.isSafeInteger(number))) { - throw usageError(`--${name} requires a nonnegative ${name === "max-fps" ? "number" : "whole number"}.`); - } - if (!["frame-ms", "timeout"].includes(name) && number === 0) throw usageError(`--${name} must be greater than zero.`); - } - } - } - if (spec && words.length > spec.path.length + spec.maxArgs) throw usageError(spec.maxArgs === 0 ? `${spec.path.join(" ")} does not accept positional arguments.` : `${spec.path.join(" ")} does not accept extra arguments; expected at most ${spec.maxArgs}.`); - if (help) return; - if (spec && words.length < spec.path.length + spec.minArgs) throw usageError(words[0] === "screen" && words[1] === "set-timezone" ? "screen set-timezone requires <id> --timezone --if-match." : `${spec.path.join(" ")} requires ${words[0] === "screen" || words[0] === "comment" ? "<id>" : `${spec.minArgs} positional argument(s)`}.`); - if (typeof flags["if-match"] === "string") quotedRevision(flags["if-match"]); - const has = (name: string): boolean => Object.hasOwn(flags, name); - const exclusive = (names: string[]): void => { - if (names.filter(has).length > 1) throw usageError(`Use only one of ${names.map((name) => `--${name}`).join(", ")}.`); - }; - exclusive(["after", "cursor"]); - exclusive(["tag", "clear-tag"]); - exclusive(["body", "body-file"]); - if (words[0] === "comment" && has("json-value") && has("file")) throw usageError("comment set requires exactly one of --json-value or --file."); - exclusive(["json-value", "file", "value-base64"]); - if (has("target-width") !== has("target-height")) throw usageError("Provide both --target-width and --target-height."); - if (has("json-value") && has("content-type")) throw usageError("--json-value always uses application/json; omit --content-type."); - if (has("no-transcode") && ["codec", "max-fps", "max-edge", "webp-quality", "preset", "no-audio"].some(has)) { - throw usageError("Transcode options require transcoding; remove --no-transcode."); - } - if (spec?.path.join(" ") === "playlist import" && has("update") !== has("if-match")) throw usageError("playlist import --update and --if-match must be supplied together."); -}