From d9e4f1eb05b72d308ce43bd2914646a0e1724872 Mon Sep 17 00:00:00 2001 From: Kurtis Jantzen Date: Thu, 10 Sep 2026 14:52:41 -0700 Subject: [PATCH] Default operational output to JSON and streamline agent guidance --- AGENTS.md | 2 +- README.md | 17 ++++-- src/cli-commands/globals.ts | 3 +- src/cli-errors.ts | 2 +- src/cli.test.ts | 36 ++++++------- src/command-tree.ts | 3 ++ src/commands.ts | 28 +++++----- src/compose/cli.test.ts | 2 +- src/help.test.ts | 2 +- src/main.ts | 15 ++++-- src/media-generate.test.ts | 2 +- src/media-upload.ts | 2 +- src/output-mode.test.ts | 95 ++++++++++++++++++++++++++++++++++ src/pack/limits.ts | 2 +- src/playlist-bundle.test.ts | 2 +- src/playlist-bundle.ts | 2 +- src/playlist-preview.test.ts | 2 +- src/playlist-templates.test.ts | 4 +- src/playlist-templates.ts | 10 ++-- src/problems.test.ts | 2 +- src/problems.ts | 4 +- src/program.ts | 2 +- src/screenshot.test.ts | 2 +- 23 files changed, 178 insertions(+), 63 deletions(-) create mode 100644 src/output-mode.test.ts diff --git a/AGENTS.md b/AGENTS.md index 9e021641..1d1c4b58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,7 +107,7 @@ To execute this checkout: ```sh npm run build -node ./dist/bin.js --json version +node ./dist/bin.js version ``` Do not PATH-swap a local checkout into an installed agent. Do not diff --git a/README.md b/README.md index 2b1f5441..5a5cc1d0 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,11 @@ published. ```sh npm install --global screenrig@ -screenrig --json version +screenrig version ``` Node.js 20.11 or newer is required. `media upload` additionally requires ffmpeg -and ffprobe; the other commands do not. Run `screenrig --json doctor` to inspect +and ffprobe; the other commands do not. Run `screenrig doctor` to inspect the optional media toolchain before an upload. This global package is the official developer-shell distribution. It is not the @@ -29,8 +29,17 @@ agent install. Do not substitute it for the plugin launcher. ## What it does -Commands return JSON envelopes an agent can branch on. Customer surface is -content (`app`, `media`, `compose`), playlists, and screens. Full reference: +Operational commands return JSON envelopes by default, including errors with +nonzero exit codes. `--json` remains accepted for compatibility. Use `--human` +for explicit human-readable output; it cannot be combined with `--json`. +Output format never changes based on terminal detection. Progress goes to stderr; +`events follow` writes NDJSON (one envelope per line) without a trailing summary. +An empty follow session returns one envelope with `data.items: []`. + +Help, including bare command groups, stays human-readable by default; use +`--json --help` for structured discovery. Authoring files remain JSON. + +The 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`, diff --git a/src/cli-commands/globals.ts b/src/cli-commands/globals.ts index c915bb4c..a5b5ca24 100644 --- a/src/cli-commands/globals.ts +++ b/src/cli-commands/globals.ts @@ -3,7 +3,8 @@ import { nonnegativeInteger } from "./options.js"; export function registerGlobalOptions(root: Command): void { root - .option("--json", "Return a JSON envelope") + .addOption(new Option("--json", "Return JSON (default; also enables structured help)").conflicts("human")) + .addOption(new Option("--human", "Return human-readable output").conflicts("json")) .option("--api-url ", "Override the API origin") .option("--config ", "Use this credential/configuration file") .option("--request-id ", "Use a request correlation ID") diff --git a/src/cli-errors.ts b/src/cli-errors.ts index 70c9412a..eda0ef17 100644 --- a/src/cli-errors.ts +++ b/src/cli-errors.ts @@ -7,7 +7,7 @@ export 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 --if-match REVISION", reason: "Archive hides the screen; it does not unbind the player." }); + if (path === "screen" && command.args[0] === "revoke-credential") throw usageError("screen revoke-credential is retired. Archive the screen instead.", { command: "screenrig screen archive --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 or playlist ."); if (has("if-match")) throw usageError("comment commands do not take --if-match; last write wins and does not bump revision."); diff --git a/src/cli.test.ts b/src/cli.test.ts index 831c9d9d..306c3e41 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1096,7 +1096,7 @@ test("agent disconnect shares the last-agent guard and explicit allow-lockout ov test("screen pair normalizes safe lowercase input and reports canonical uppercase", async () => { const transport = memoryBackend(); - const result = await withAuthenticatedRuntime(["screen", "pair", "abc234", "--label", "Lobby"], transport); + const result = await withAuthenticatedRuntime(["--human", "screen", "pair", "abc234", "--label", "Lobby"], transport); assert.equal(result.code, 0, result.stdout); assert.match(result.stdout, /Screen paired/); assert.match(result.stdout, /code: ABC234/); @@ -1292,7 +1292,7 @@ test("dashboard mints one single-use link, opens it, and keeps the URL out of ev test("dashboard prints the link exactly once when no browser could be opened", async () => { const result = await withAuthenticatedRuntime( - ["dashboard"], + ["--human", "dashboard"], memoryBackend(), { openUrl: async () => false }, ); @@ -1957,7 +1957,7 @@ test("events list prints data or silence", async () => { { api_url: "https://api.screenrig.ai", token: "sr_live_tokidAAAAAAAAAAAAAAAA_secretsecretsecretsecretsecr" }, fsLike, ); - const printed = await withRuntime(["events", "list"], transport, { fs: fsLike }); + const printed = await withRuntime(["--human", "events", "list"], transport, { fs: fsLike }); assert.equal(printed.code, 0, printed.stdout); assert.equal( printed.stdout, @@ -1972,7 +1972,7 @@ test("events list prints data or silence", async () => { headers: { "x-request-id": "req_events_empty" }, body: { items: [], next_cursor: "" }, })); - const silent = await withRuntime(["events", "list"], emptyTransport, { fs: fsLike }); + const silent = await withRuntime(["--human", "events", "list"], emptyTransport, { fs: fsLike }); assert.equal(silent.code, 0, silent.stdout); assert.equal(silent.stdout, ""); await rm(configDir, { recursive: true, force: true }); @@ -2008,7 +2008,7 @@ test("events follow parses SSE frames from the transport stream", async () => { assert.equal(transport.calls.at(-1)?.query?.after, "ev1_0"); assert.equal(transport.calls.at(-1)?.query?.cursor, undefined); - const human = await withRuntime(["events", "follow", "--timeout", "50"], transport, { fs: fsLike }); + const human = await withRuntime(["--human", "events", "follow", "--timeout", "50"], transport, { fs: fsLike }); assert.equal(human.code, 0, human.stdout); assert.equal( human.stdout, @@ -2048,7 +2048,7 @@ test("events follow writes a human line before the stream closes", async () => { return origWrite(chunk as string | Buffer, encoding as BufferEncoding, cb); }) as typeof stdout.write; const code = await run({ - argv: ["events", "follow", "--timeout", "50"], + argv: ["--human", "events", "follow", "--timeout", "50"], env: fsLike.env, stdout, stderr: new PassThrough(), @@ -2074,7 +2074,7 @@ test("events follow is silent when no events arrive", async () => { { api_url: "https://api.screenrig.ai", token: "sr_live_tokidAAAAAAAAAAAAAAAA_secretsecretsecretsecretsecr" }, fsLike, ); - const human = await withRuntime(["events", "follow", "--timeout", "50"], transport, { fs: fsLike }); + const human = await withRuntime(["--human", "events", "follow", "--timeout", "50"], transport, { fs: fsLike }); assert.equal(human.code, 0, human.stdout); assert.equal(human.stdout, ""); const json = await withRuntime(["--json", "events", "follow", "--timeout", "50"], transport, { fs: fsLike }); @@ -2171,7 +2171,7 @@ test("events follow prints scalar details and skips empty frames", async () => { { api_url: "https://api.screenrig.ai", token: "sr_live_tokidAAAAAAAAAAAAAAAA_secretsecretsecretsecretsecr" }, fsLike, ); - const { code, stdout } = await withRuntime(["events", "follow", "--timeout", "50"], transport, { fs: fsLike }); + const { code, stdout } = await withRuntime(["--human", "events", "follow", "--timeout", "50"], transport, { fs: fsLike }); assert.equal(code, 0, stdout); assert.equal( stdout, @@ -2641,7 +2641,7 @@ test("doctor reports a configured credential as presence only", async () => { assert.doesNotMatch(json.stdout, pattern); } - const human = await withRuntime(["doctor"], memoryBackend(), { fs: fsLike, runProcess: probe }); + const human = await withRuntime(["--human", "doctor"], memoryBackend(), { fs: fsLike, runProcess: probe }); assert.match(human.stdout, /^PASS token: present$/m); for (const pattern of [/sr_live_/, /tokidAAAA/, /secretsecret/]) { assert.doesNotMatch(human.stdout, pattern); @@ -3054,7 +3054,7 @@ test("media download writes the verified original rendition to --output or ./ workDir }); + const human = await withRuntime(["--human", "media", "download", id, "--output", path.join(workDir, "again.webp")], transport, { fs: fsLike, cwd: () => workDir }); assert.equal(human.code, ExitCode.Success, human.stdout); assert.match(human.stdout, /Media downloaded/); assert.match(human.stdout, /size: 1920x1080/); @@ -3239,7 +3239,7 @@ test("a rate-limited submission surfaces Retry-After instead of a bare 429", asy assert.match(envelope.error.detail, /Retry-After is 180 seconds/); assert.match(String(envelope.error.next?.reason), /Wait 3 minutes/); - const human = await withRuntime(["feedback", "bug", "Title", "--body", "Body"], transport, { fs: fsLike }); + const human = await withRuntime(["--human", "feedback", "bug", "Title", "--body", "Body"], transport, { fs: fsLike }); assert.match(human.stderr, /retry_after_seconds: 180/); } finally { await rm(configDir, { recursive: true, force: true }); @@ -3315,7 +3315,7 @@ test("account show reports integer credit_remaining and warns when remaining is assert.match(warning.message, /below 1000 credits/); assert.doesNotMatch(json.stdout, /kCr|stripe|x402|mcr|millicredit|\$/i); - const human = await withTokenConfig(transport, ["account", "show"]); + const human = await withTokenConfig(transport, ["--human", "account", "show"]); assert.equal(human.code, 0, human.stdout); assert.match(human.stdout, /credit_remaining: 0/); assert.match(human.stdout, /^token: present$/m); @@ -3344,7 +3344,7 @@ test("remaining header 999 adds credits_low on JSON and human output", async () assert.match(warning.message, /\b999\b/); assert.doesNotMatch(json.stdout, /kCr|stripe|x402|mcr|millicredit|\$/i); - const human = await withTokenConfig(transport, ["account", "show"]); + const human = await withTokenConfig(transport, ["--human", "account", "show"]); assert.equal(human.code, 0, human.stdout); assert.match(human.stdout, /warning: Remaining prepaid credit is 999, below 1000 credits\./); }); @@ -3515,7 +3515,7 @@ test("a 402 with remaining header 0 includes payment_required and credits_low", assert.match(warning.message, /\b0\b/); assert.doesNotMatch(result.stdout, /stripe|x402|pay |kCr|mcr|millicredit|\$/i); - const human = await withRuntime(["media", "upload", file, "--no-transcode"], transport, { fs: fsLike }); + const human = await withRuntime(["--human", "media", "upload", file, "--no-transcode"], transport, { fs: fsLike }); assert.equal(human.code, 8, human.stderr); assert.match(human.stderr, /payment_required/); assert.match(human.stderr, /warning: Remaining prepaid credit is 0, below 1000 credits\./); @@ -3916,7 +3916,7 @@ test("screen toast posts the closed write body and does not echo the text", asyn ); const human = await withRuntime( - ["screen", "toast", "scr_PAIRINGAAAAAAAAAAAAAAAA", "--level", "error", "--text", "Offline"], + ["--human", "screen", "toast", "scr_PAIRINGAAAAAAAAAAAAAAAA", "--level", "error", "--text", "Offline"], transport, { fs: fsLike }, ); @@ -4612,7 +4612,7 @@ test("screen show prints optional player-reported observation", async () => { assert.deepEqual(envelope.data.observation, SAMPLE_OBSERVATION); const humanResult = await withRuntime( - ["screen", "show", "scr_PAIRINGAAAAAAAAAAAAAAAA"], + ["--human", "screen", "show", "scr_PAIRINGAAAAAAAAAAAAAAAA"], transport, { fs: fsLike }, ); @@ -4716,7 +4716,7 @@ test("screen show includes online and optional last_online_at and last_ip", asyn assert.equal(envelope.data.last_ip, DOCUMENTATION_IPV4); const humanResult = await withRuntime( - ["screen", "show", "scr_PAIRINGAAAAAAAAAAAAAAAA"], + ["--human", "screen", "show", "scr_PAIRINGAAAAAAAAAAAAAAAA"], transport, { fs: fsLike }, ); @@ -5067,7 +5067,7 @@ test("playlist create refuses a templated page that would emit text", async () = const envelope = JSON.parse(result.stdout) as { error: { code: string; detail: string; next?: { command: string } } }; assert.equal(envelope.error.code, "usage_error"); assert.match(envelope.error.detail, /compose render/); - assert.equal(envelope.error.next?.command, "screenrig --json compose catalog"); + assert.equal(envelope.error.next?.command, "screenrig compose catalog"); assert.equal(transport.calls.some((call) => call.method === "POST" && call.path === "/api/v1/playlists"), false); await rm(configDir, { recursive: true, force: true }); }); diff --git a/src/command-tree.ts b/src/command-tree.ts index 32b60641..f62a91b4 100644 --- a/src/command-tree.ts +++ b/src/command-tree.ts @@ -48,6 +48,9 @@ export function createCommandTree( ): CommandTree { const end = argv.indexOf("--"); const tokens = end < 0 ? argv : argv.slice(0, end); + if (tokens.includes("--json") && tokens.includes("--human")) { + throw usageError("--json and --human are mutually exclusive."); + } for (const spellings of [["--help", "-h"], ["--version", "-V"]]) { if (tokens.filter((arg) => spellings.includes(arg)).length > 1) throw usageError(`${spellings[0]} may be supplied only once.`); } diff --git a/src/commands.ts b/src/commands.ts index 3b65657c..ec1f3982 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -145,6 +145,8 @@ export interface CommandResult { envelope: ReturnType>; exitCode: ExitCode; human: string; + /** Help defaults to text; streams have already emitted their output. */ + output?: "help" | "stream"; } function nonemptyEnv(value: string | undefined): string | undefined { @@ -177,7 +179,7 @@ function rethrowCompose(err: unknown): never { } if (err instanceof Error && (err as { code?: string }).code === "usage_error") { throw usageError(err.message, { - command: "screenrig --json compose catalog", + command: "screenrig compose catalog", reason: "Inspect the fail-closed compose catalog, then compose render a spec.", }); } @@ -188,7 +190,7 @@ async function composeRender(args: ParsedArgs, runtime: CliRuntime): Promise & { vers test("root help is compact and every command is discoverable through immediate children", () => { const root = commandHelp(); const commanderHelp = root.usage.split(/\n\nAll commands:\n/)[0]!; - assert.ok(commanderHelp.split("\n").length < 35); + assert.ok(commanderHelp.split("\n").length < 36); assert.doesNotMatch(root.usage, /localhost|log_socket|--token/); const discovered: string[] = []; function visit(path: string[]): void { diff --git a/src/main.ts b/src/main.ts index fe03d891..11d8036c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -28,15 +28,20 @@ function applyLogSinkToSuccess { - const json = runtime.argv.includes("--json"); + // Only switches before the end-of-options marker select presentation. + const end = runtime.argv.indexOf("--"); + const options = end < 0 ? runtime.argv : runtime.argv.slice(0, end); + const explicitJson = options.includes("--json"); + const json = explicitJson || !options.includes("--human"); try { const dispatched = applyCreditsLowToSuccess(await executeCommand(runtime.argv, runtime), observedCreditsRemaining(runtime)); runtime.logger?.endRun(); const result = applyLogSinkToSuccess(dispatched, runtime.logger?.droppedLines() ?? 0); - if (json) { - if (result.human) { - runtime.stdout.write(`${JSON.stringify(result.envelope)}\n`); - } + if (result.output === "stream") { + return result.exitCode; + } + if (json && (result.output !== "help" || explicitJson)) { + runtime.stdout.write(`${JSON.stringify(result.envelope)}\n`); } else if (result.human) { runtime.stdout.write(`${result.human}\n`); } diff --git a/src/media-generate.test.ts b/src/media-generate.test.ts index 515e27bd..94c2734b 100644 --- a/src/media-generate.test.ts +++ b/src/media-generate.test.ts @@ -458,7 +458,7 @@ test("a generate that times out says the still may exist and names the command t assert.equal(envelope.error.code, "timeout"); assert.match(envelope.error.detail, /may or may not have been created/); assert.match(envelope.error.detail, /same idempotency key/); - assert.equal(envelope.error.next?.command, "screenrig --json media list --tag MenuBoard"); + assert.equal(envelope.error.next?.command, "screenrig media list --tag MenuBoard"); assert.doesNotMatch(result.stdout, new RegExp(PROMPT.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); } finally { await rm(configDir, { recursive: true, force: true }); diff --git a/src/media-upload.ts b/src/media-upload.ts index 2e8334f5..d5f89d81 100644 --- a/src/media-upload.ts +++ b/src/media-upload.ts @@ -14,7 +14,7 @@ import { CliError, networkError, usageError } from "./problems.js"; import { fetchSignedRawPut, type CliRuntime, type SignedRawPut } from "./runtime.js"; const MEDIA_PUT_NOT_READY = - "Private media upload did not complete because the service is not ready. Run screenrig --json doctor and check the ready result before retrying."; + "Private media upload did not complete because the service is not ready. Run screenrig doctor and check the ready result before retrying."; export const SUPPORTED_MEDIA_CONTENT_TYPES = [ "image/png", diff --git a/src/output-mode.test.ts b/src/output-mode.test.ts new file mode 100644 index 00000000..9cb0c147 --- /dev/null +++ b/src/output-mode.test.ts @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Writable } from "node:stream"; +import { test } from "node:test"; +import { run, processRuntime } from "./main.js"; +import { FakeTransport } from "./transport/fake.js"; + +async function invoke(argv: string[], transport = new FakeTransport(), authenticated = false, tty = false) { + const directory = await mkdtemp(path.join(tmpdir(), "screenrig-output-")); + const config = path.join(directory, "config.json"); + if (authenticated) await writeFile(config, JSON.stringify({ api_url: "https://api.screenrig.ai", token: "test-only-token" }), { mode: 0o600 }); + let stdout = "", stderr = ""; + try { + const code = await run({ ...processRuntime(), argv: ["--config", config, ...argv], env: {}, transport, + isStderrTty: () => tty, + stdout: new Writable({ write(chunk, _encoding, done) { stdout += chunk; done(); } }), + stderr: new Writable({ write(chunk, _encoding, done) { stderr += chunk; done(); } }), + }); + return { code, stdout, stderr }; + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +test("operations default to JSON regardless of terminal; --json remains compatible", async () => { + const expected = await invoke(["--json", "version"]); + assert.equal(expected.code, 0); + assert.equal(JSON.parse(expected.stdout).ok, true); + for (const argv of [["version"], ["--version"], ["-V"], ["version", "--json"]]) { + for (const tty of [false, true]) assert.deepEqual(await invoke(argv, undefined, false, tty), expected); + } + const human = await invoke(["version", "--human"]); + assert.match(human.stdout, /^screenrig \d/); + assert.equal(human.stderr, ""); +}); + +test("help stays readable by default and offers structured discovery", async () => { + for (const argv of [[], ["--help"], ["screen"], ["help", "screen"], ["screen", "assign", "--help"]]) { + assert.match((await invoke(argv)).stdout, /^Usage:/); + const structured = await invoke(["--json", ...argv]); + assert.equal(JSON.parse(structured.stdout).ok, true); + assert.ok(Array.isArray(JSON.parse(structured.stdout).data.options)); + } +}); + +test("usage and auth failures default to JSON, with prose only on explicit request", async () => { + for (const argv of [["screen", "show"], ["screen", "list", "--bogus"], ["screen", "list"]]) { + const json = await invoke(argv); + assert.notEqual(json.code, 0); + assert.equal(JSON.parse(json.stdout).ok, false); + assert.equal(json.stderr, ""); + const human = await invoke(["--human", ...argv]); + assert.equal(human.code, json.code); + assert.equal(human.stdout, ""); + assert.notEqual(human.stderr, ""); + } +}); + +test("output flags conflict across command levels, including help", async () => { + for (const argv of [["--json", "version", "--human"], ["--human", "screen", "--json", "list"], ["--human", "--json", "--help"]]) { + const result = await invoke(argv); + assert.equal(result.code, 2); + assert.equal(JSON.parse(result.stdout).error.code, "usage_error"); + } + const literal = await invoke(["playlist", "validate", "--", "--human"]); + assert.equal(literal.code, 2); + assert.equal(JSON.parse(literal.stdout).ok, false); +}); + +test("empty event lists emit one default JSON result without a human-text sentinel", async () => { + const transport = new FakeTransport().on("GET", "/api/v1/events", () => ({ status: 200, headers: {}, body: { items: [], next_cursor: null } })); + const result = await invoke(["events", "list"], transport, true); + assert.equal(result.code, 0); + assert.deepEqual(JSON.parse(result.stdout).data, { items: [], next_cursor: null }); + assert.equal(result.stdout.trim().split("\n").length, 1); + const human = await invoke(["--human", "events", "list"], transport, true); + assert.equal(human.stdout, ""); +}); + +test("default event streams emit NDJSON without an extra final result", async () => { + const transport = new FakeTransport(); + for (let i = 1; i <= 2; i++) transport.pushStream(`id: ev1_${i}\ndata: ${JSON.stringify({ type: "account.created", at: "2026-09-10T00:00:00Z", severity: "info", message: `event ${i}` })}\n\n`); + const result = await invoke(["events", "follow", "--timeout", "30"], transport, true); + assert.equal(result.code, 0); + const lines = result.stdout.trim().split("\n").map((line) => JSON.parse(line)); + assert.equal(lines.length, 2); + assert.deepEqual(lines.map((line) => line.data.message), ["event 1", "event 2"]); + assert.ok(lines.every((line) => line.ok)); + assert.equal(result.stderr, ""); + const empty = await invoke(["events", "follow", "--timeout", "20"], new FakeTransport(), true); + assert.equal(empty.code, 0); + assert.deepEqual(JSON.parse(empty.stdout).data, { items: [] }); +}); diff --git a/src/pack/limits.ts b/src/pack/limits.ts index e8d68e42..78b15cc0 100644 --- a/src/pack/limits.ts +++ b/src/pack/limits.ts @@ -8,7 +8,7 @@ export function packError(code: string, detail: string): CliError { return new CliError( makeProblem(code, "Application archive rejected", 400, detail, { next: { - command: "screenrig app pack --json", + command: "screenrig app pack ", reason: "Fix the named path or ignore rule, then rebuild the archive locally.", }, }), diff --git a/src/playlist-bundle.test.ts b/src/playlist-bundle.test.ts index 21597bce..e88d506e 100644 --- a/src/playlist-bundle.test.ts +++ b/src/playlist-bundle.test.ts @@ -806,7 +806,7 @@ test("import of a bundle whose playlist name is taken points at --name, and --na assert.equal(error.problem.code, "resource_conflict"); assert.match(error.problem.detail, /playlist name is already in use/); assert.match(error.problem.detail, /unique per account/); - assert.equal(error.problem.next?.command, `screenrig --json playlist import ${dir} --name NAME`); + assert.equal(error.problem.next?.command, `screenrig playlist import ${dir} --name NAME`); assert.match(error.problem.next?.reason ?? "", /--update ID --if-match REVISION/); return true; }, diff --git a/src/playlist-bundle.ts b/src/playlist-bundle.ts index f21cc1fb..7e5fa477 100644 --- a/src/playlist-bundle.ts +++ b/src/playlist-bundle.ts @@ -999,7 +999,7 @@ function rethrowNameConflict(error: unknown, state: { ...error.problem, detail: `${error.problem.detail} Playlist names are unique per account, and this bundle's name is already taken.`, next: { - command: `screenrig --json playlist import ${state.directory} --name NAME`, + command: `screenrig playlist import ${state.directory} --name NAME`, reason: "Import as a new playlist under a different name, or replace the existing one with --update ID --if-match REVISION.", }, }, diff --git a/src/playlist-preview.test.ts b/src/playlist-preview.test.ts index 4a662081..090c2153 100644 --- a/src/playlist-preview.test.ts +++ b/src/playlist-preview.test.ts @@ -160,7 +160,7 @@ test("playlist preview command writes files, lint, and the contact-sheet sentenc } } const human = await withRuntime( - ["playlist", "preview", file, "--output", path.join(cwdDir, "human")], + ["--human", "playlist", "preview", file, "--output", path.join(cwdDir, "human")], { cwdDir }, ); assert.equal(human.code, ExitCode.Success, human.stdout); diff --git a/src/playlist-templates.test.ts b/src/playlist-templates.test.ts index c8ec2701..efbe9087 100644 --- a/src/playlist-templates.test.ts +++ b/src/playlist-templates.test.ts @@ -144,7 +144,7 @@ test("templates with copy fail expansion and point at compose render", () => { assert.equal(err.problem.code, "usage_error"); assert.match(err.problem.detail, /compose render/); assert.match(err.problem.detail, /text, box, or line/); - assert.equal(err.problem.next?.command, "screenrig --json compose catalog"); + assert.equal(err.problem.next?.command, "screenrig compose catalog"); return true; }, ); @@ -302,7 +302,7 @@ test("unknown and retired template ids name playlist templates", () => { assert.ok(err instanceof CliError); assert.equal(err.problem.code, "usage_error"); assert.match(err.problem.detail, new RegExp(`Unknown template ${id}`)); - assert.equal(err.problem.next?.command, "screenrig --json playlist templates"); + assert.equal(err.problem.next?.command, "screenrig playlist templates"); return true; }, ); diff --git a/src/playlist-templates.ts b/src/playlist-templates.ts index 698d8921..0754b372 100644 --- a/src/playlist-templates.ts +++ b/src/playlist-templates.ts @@ -571,8 +571,8 @@ export interface TemplateCatalog { export function playlistTemplateCatalog(): TemplateCatalog { return { compose: { - catalog_command: "screenrig --json compose catalog", - render_command: "screenrig --json compose render ", + catalog_command: "screenrig compose catalog", + render_command: "screenrig compose render ", wire_primitives: [...WIRE_PRIMITIVES], }, canvas: { @@ -644,7 +644,7 @@ function vectorChromeError(label: string): never { throw usageError( `${label} would emit native text, box, or line primitives. Compose a still with compose render, upload it as image, and use that image primitive on the page.`, { - command: "screenrig --json compose catalog", + command: "screenrig compose catalog", reason: "List the local compose catalog, then run compose render and media upload.", }, ); @@ -664,7 +664,7 @@ function assertWirePrimitives(page: unknown, index: number): void { throw usageError( `${label} primitives[${primitiveIndex}].primitive must be ${WIRE_PRIMITIVES.join("|")}. Compose copy and chrome locally.`, { - command: "screenrig --json compose catalog", + command: "screenrig compose catalog", reason: "List the local compose catalog, then run compose render and media upload.", }, ); @@ -763,7 +763,7 @@ function resolveTemplate(value: unknown): SlideTemplateDef { if (typeof value !== "string" || !TEMPLATE_BY_ID.has(value)) { const named = typeof value === "string" && value.length > 0 ? ` ${value}` : ""; throw usageError(`Unknown template${named}. Run playlist templates for the closed catalog.`, { - command: "screenrig --json playlist templates", + command: "screenrig playlist templates", reason: "List the closed slide templates and their slots.", }); } diff --git a/src/problems.test.ts b/src/problems.test.ts index 2b96d1a0..c4688ce2 100644 --- a/src/problems.test.ts +++ b/src/problems.test.ts @@ -45,7 +45,7 @@ test("payment_required guidance names credit_remaining and not millicredits", () code: "payment_required", errors: [], }); - assert.equal(problem.next?.command, "screenrig --json account show"); + assert.equal(problem.next?.command, "screenrig account show"); assert.match(problem.next?.reason ?? "", /credit_remaining/); assert.doesNotMatch(problem.next?.reason ?? "", /mcr|millicredit|kCr|stripe|x402|\$/i); }); diff --git a/src/problems.ts b/src/problems.ts index fc5c4590..0c36a687 100644 --- a/src/problems.ts +++ b/src/problems.ts @@ -212,7 +212,7 @@ export function withQuotaGuidance(problem: NormalizedProblem): NormalizedProblem return { ...problem, next: { - command: "screenrig --json account show", + command: "screenrig account show", reason: "Read used_bytes and content_limit_bytes, then free space or upload a smaller file.", }, }; @@ -230,7 +230,7 @@ export function withPaymentGuidance(problem: NormalizedProblem): NormalizedProbl return { ...problem, next: { - command: "screenrig --json account show", + command: "screenrig account show", reason: "Read credit_remaining. Remaining prepaid credit of zero rejects costly operations.", }, }; diff --git a/src/program.ts b/src/program.ts index c874a781..04fd13e2 100644 --- a/src/program.ts +++ b/src/program.ts @@ -19,7 +19,7 @@ export async function executeCommand(argv: string[], runtime: CliRuntime): Promi } if (tree.helpRequested()) { const help = describeHelp(tree.selected()); - return { envelope: successEnvelope(help), exitCode: ExitCode.Success, human: help.usage }; + return { envelope: successEnvelope(help), exitCode: ExitCode.Success, human: help.usage, output: "help" }; } if (tree.versionRequested()) return handleVersion({ command: ["version"], positionals: ["version"], flags: {} }, runtime); if (!result) throw new Error("Command completed without a result."); diff --git a/src/screenshot.test.ts b/src/screenshot.test.ts index dcf4011e..7385e706 100644 --- a/src/screenshot.test.ts +++ b/src/screenshot.test.ts @@ -346,7 +346,7 @@ test("screen screenshot success envelope has metadata only and never prints pixe assert.equal(json.stderr.includes(IMAGE_MARK), false); const human = await withRuntime( - ["screen", "screenshot", SCREEN_ID, "--poll-ms", "1"], + ["--human", "screen", "screenshot", SCREEN_ID, "--poll-ms", "1"], screenshotTransport({ statuses: [readyStatus()] }), { fs: fsLike, configDir, cwdDir }, );