Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,29 @@ published.

```sh
npm install --global screenrig@<YY.MM.SERIAL>
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
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`,
Expand Down
3 changes: 2 additions & 1 deletion src/cli-commands/globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <URL>", "Override the API origin")
.option("--config <PATH>", "Use this credential/configuration file")
.option("--request-id <ID>", "Use a request correlation ID")
Expand Down
2 changes: 1 addition & 1 deletion src/cli-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> --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 <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.");
Expand Down
36 changes: 18 additions & 18 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down Expand Up @@ -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 },
);
Expand Down Expand Up @@ -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,
Expand All @@ -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 });
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand All @@ -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 });
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -3054,7 +3054,7 @@ test("media download writes the verified original rendition to --output or ./<id
assert.equal(named.code, ExitCode.Success, named.stdout);
assert.deepEqual(await readFile(explicit), bytes);

const human = await withRuntime(["media", "download", id, "--output", path.join(workDir, "again.webp")], transport, { fs: fsLike, cwd: () => 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/);
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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\./);
});
Expand Down Expand Up @@ -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\./);
Expand Down Expand Up @@ -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 },
);
Expand Down Expand Up @@ -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 },
);
Expand Down Expand Up @@ -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 },
);
Expand Down Expand Up @@ -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 });
});
Expand Down
3 changes: 3 additions & 0 deletions src/command-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`);
}
Expand Down
28 changes: 15 additions & 13 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ export interface CommandResult {
envelope: ReturnType<typeof successEnvelope<unknown>>;
exitCode: ExitCode;
human: string;
/** Help defaults to text; streams have already emitted their output. */
output?: "help" | "stream";
}

function nonemptyEnv(value: string | undefined): string | undefined {
Expand Down Expand Up @@ -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.",
});
}
Expand All @@ -188,7 +190,7 @@ async function composeRender(args: ParsedArgs, runtime: CliRuntime): Promise<Com
const file = args.positionals[2];
if (!file) {
throw usageError("compose render requires a spec file.", {
command: "screenrig --json compose catalog",
command: "screenrig compose catalog",
reason: "Inspect the fail-closed compose catalog, then compose render a spec.",
});
}
Expand Down Expand Up @@ -698,7 +700,7 @@ async function agentEnroll(
}

function emitAgentApprovalUrl(args: ParsedArgs, runtime: CliRuntime, approvalUrl: string): void {
if (flagBool(args.flags, "json")) {
if (!flagBool(args.flags, "human")) {
runtime.stderr.write(`${JSON.stringify({ type: "agent_connection_approval", approval_url: approvalUrl })}\n`);
return;
}
Expand Down Expand Up @@ -1434,7 +1436,7 @@ async function enrollForCommand(
throw new CliError({
...err.problem,
next: err.problem.next ?? {
command: "screenrig --json --beta-key KEY agent enroll --email ADDRESS",
command: "screenrig --beta-key KEY agent enroll --email ADDRESS",
reason: "The control plane gates enrollment. Retry the same email with the enrollment beta key.",
},
}, err.exitCode, err.warnings);
Expand Down Expand Up @@ -1820,8 +1822,8 @@ function ambiguousGenerateError(error: unknown, options: { elapsedMs: number; ta
if (error.problem.code !== "timeout" && error.problem.code !== "transport_error") return error;
const seconds = Math.round(options.elapsedMs / 1000);
const listCommand = options.tag
? `screenrig --json media list --tag ${options.tag}`
: "screenrig --json media list --primitive image";
? `screenrig media list --tag ${options.tag}`
: "screenrig media list --primitive image";
return new CliError(
makeProblem(
error.problem.code,
Expand Down Expand Up @@ -1849,7 +1851,7 @@ function writeGenerateNotice(
): void {
if (flagBool(args.flags, "no-progress")) return;
const seconds = GENERATE_TYPICAL_SECONDS[quality];
if (flagBool(args.flags, "json")) {
if (!flagBool(args.flags, "human")) {
runtime.stderr.write(
`${JSON.stringify({
event: "media_generate_started",
Expand Down Expand Up @@ -2178,7 +2180,7 @@ function progressReporterFor(args: ParsedArgs, runtime: CliRuntime): ProgressRep
if (flagBool(args.flags, "no-progress")) {
return silentProgressReporter();
}
const json = flagBool(args.flags, "json");
const json = !flagBool(args.flags, "human");
return createProgressReporter({
stderr: runtime.stderr,
json,
Expand Down Expand Up @@ -2277,7 +2279,7 @@ async function mediaUploadBatch(
transcodeOptions,
noTranscode: flagBool(args.flags, "no-transcode"),
reporter: progressReporterFor(args, runtime),
json: flagBool(args.flags, "json"),
json: !flagBool(args.flags, "human"),
noProgress: flagBool(args.flags, "no-progress"),
timeoutMs: flagNumber(args.flags, "timeout") ?? 120_000,
pollMs: flagNumber(args.flags, "poll-ms") ?? 1000,
Expand Down Expand Up @@ -2716,7 +2718,7 @@ function scheduleZoneError(screenId: string): CliError {
return usageError(
`Screen ${screenId} has no timezone, and the playlist schedules pages with visibility. Page visibility rules are civil times, so the screen needs an IANA zone before it can run them.`,
{
command: `screenrig --json screen set-timezone ${screenId} --timezone America/Los_Angeles --if-match REVISION`,
command: `screenrig screen set-timezone ${screenId} --timezone America/Los_Angeles --if-match REVISION`,
reason: "Set the screen timezone first, then assign the playlist. Read the current revision from screen show.",
},
);
Expand Down Expand Up @@ -3610,8 +3612,7 @@ async function eventsList(args: ParsedArgs, runtime: CliRuntime, resolved: Await
return {
envelope: jsonBody({ ...response, body: safePage }, client.requestId),
exitCode: ExitCode.Success,
// main.ts writes JSON only when human is truthy; a space is a silent JSON gate.
human: human || (args.flags.json === true ? " " : ""),
human,
};
}

Expand Down Expand Up @@ -3664,7 +3665,7 @@ async function eventsFollow(args: ParsedArgs, runtime: CliRuntime, resolved: Awa
const token = requireToken(resolved.token);
const client = clientFor(runtime, args, resolved.apiUrl, token);
const transport = transportFor(runtime, resolved.apiUrl, token);
const json = args.flags.json === true;
const json = !flagBool(args.flags, "human");
let printed = 0;
let after = flagString(args.flags, "after") ?? flagString(args.flags, "cursor");
let delayMs = EVENT_STREAM_BACKOFF_MS;
Expand Down Expand Up @@ -3743,6 +3744,7 @@ async function eventsFollow(args: ParsedArgs, runtime: CliRuntime, resolved: Awa
envelope: successEnvelope({ items: [] }, { request_id: client.requestId }),
exitCode: ExitCode.Success,
human: "",
output: "stream",
};
}

Expand Down
2 changes: 1 addition & 1 deletion src/compose/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ test("compose render old Frame JSON is usage_error pointing at compose catalog",
assert.equal(frame.code, ExitCode.Usage, frame.stdout);
const body = JSON.parse(frame.stdout);
assert.match(body.error.detail, /old Frame\/recipe language|compose catalog/);
assert.equal(body.error.next?.command, "screenrig --json compose catalog");
assert.equal(body.error.next?.command, "screenrig compose catalog");
const font = await withRuntime(["--json", "compose", "render", "font.json"], { cwdDir });
assert.equal(font.code, ExitCode.Usage, font.stdout);
assert.match(JSON.parse(font.stdout).error.detail, /fontSize/);
Expand Down
Loading
Loading