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
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ The customer surface is content (`app`, `media`, `compose`), playlists, and scre
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;
Use `screenrig help --all` for the complete command inventory, or
`screenrig help --all screen` for one group's descendants. Normal help lists
immediate children. Add `--json` for structured command paths, positional arguments,
option choices and defaults, relationships, and examples;
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
Expand Down Expand Up @@ -199,7 +202,11 @@ repeat the identical command and input with the same config to resume. If playli
creation succeeded before assignment failed, the error identifies the created
playlist. Do not delete it or start another create to recover. A revision conflict
requires inspecting the screen and reconciling the intended assignment; an already
created playlist can be assigned explicitly with `screen assign`. Publishing never
created playlist can be assigned explicitly with `screen assign`. Assignment-conflict
guidance includes an inspection command and a separate assignment template with
`<REVIEWED_REVISION>`. Replace that placeholder only after inspecting the screen and
deciding the assignment is still intended. Structured argument arrays preserve the
selected configuration and API origin without requiring shell parsing. Publishing never
silently refreshes the expected revision. Unfinished recovery stops after the
24-hour server idempotency window; inspect and reconcile before making more writes.

Expand Down
2 changes: 1 addition & 1 deletion src/argv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function parseArgv(argv: string[]): ParsedArgs & { help?: HelpDocument }
const args = commandInput(tree.selected());
if (tree.versionRequested()) args.flags.version = true;
if (tree.helpRequested()) {
const help = describeHelp(tree.selected());
const help = describeHelp(tree.selected(), tree.inventoryRequested());
return { command: help.path.slice(0, 2), positionals: help.path, flags: { ...args.flags, help: true }, help };
}
return args;
Expand Down
14 changes: 4 additions & 10 deletions src/cli-commands/compose.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { usageError } from "../problems.js";
import { positiveInteger } from "./options.js";
import type { CommandActionBinder } from "./types.js";
import { handleComposeCatalog, handleComposeBatch, handleComposeRender } from "../commands.js";
import type { Command } from "commander";
import { addCommandNotes } from "./notes.js";
import { addCommandNotes, requireOptionGroup } from "./notes.js";
import { LOOK_AT_THE_CONTACT_SHEET } from "../playlist-preview.js";

export function registerComposeCommands(root: Command, bind: CommandActionBinder): void {
Expand All @@ -20,7 +19,6 @@ export function registerComposeCommands(root: Command, bind: CommandActionBinder
.option("--target-height <PX>", "Set the physical content height", positiveInteger("target-height"))
.option("--safe-area", "Apply the display safe area")
.option("--lint-only", "Run the command's local validation/lint mode")
.hook("preAction", requireTargetDimensions)
.action(bind(handleComposeBatch)), `Render 1 to 2000 pages. ${LOOK_AT_THE_CONTACT_SHEET}`);

addCommandNotes(compose.command("render").description("Render a composition locally")
Expand All @@ -32,12 +30,8 @@ export function registerComposeCommands(root: Command, bind: CommandActionBinder
.option("--safe-area", "Apply the display safe area")
.option("--open", "Open the result in its viewer")
.option("--lint-only", "Run the command's local validation/lint mode")
.hook("preAction", requireTargetDimensions)
.action(bind(handleComposeRender)), LOOK_AT_THE_CONTACT_SHEET);
}

function requireTargetDimensions(command: Command): void {
const width = command.getOptionValueSource("targetWidth") === "cli";
const height = command.getOptionValueSource("targetHeight") === "cli";
if (width !== height) throw usageError("Provide both --target-width and --target-height.");
for (const command of compose.commands.filter((command) => ["batch", "render"].includes(command.name()))) {
requireOptionGroup(command, "together", ["--target-width", "--target-height"]);
}
}
25 changes: 13 additions & 12 deletions src/cli-commands/media.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { usageError } from "../problems.js";
import { positiveInteger, positiveNumber, revision } from "./options.js";
import type { CommandActionBinder } from "./types.js";
import { handleMediaGenerate, handleMediaUpload, handleMediaUploadBatch, handleMediaShow, handleMediaDownload, handleMediaList, handleMediaUpdate, handleMediaDelete } from "../commands.js";
import { type Command, Option } from "commander";
import { addCommandNotes } from "./notes.js";
import { addCommandNotes, addCommandExamples, requireOptionGroup } from "./notes.js";
import { CREDIT_HELP } from "../help-text.js";

export function registerMediaCommands(root: Command, bind: CommandActionBinder): void {
Expand All @@ -12,14 +11,16 @@ export function registerMediaCommands(root: Command, bind: CommandActionBinder):
addCommandNotes(media.command("generate").description("Generate an image from a prompt")
.addOption(new Option("--prompt <TEXT>", "Describe the complete image to generate").conflicts("promptFile"))
.addOption(new Option("--prompt-file <FILE>", "Read the complete prompt from a file or stdin (-)").conflicts("prompt"))
.option("--aspect-ratio <RATIO>", "Choose the generated image aspect ratio")
.option("--quality <low|medium|high>", "Choose generation quality")
.addOption(new Option("--aspect-ratio <RATIO>", "Choose the generated image aspect ratio").choices(["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"]).default("16:9"))
.addOption(new Option("--quality <low|medium|high>", "Choose generation quality").choices(["low", "medium", "high"]).default("medium"))
.option("--tag <TAG>", "Set the media tag")
.option("--no-progress", "Suppress stderr progress")
.hook("preAction", (command) => {
if (command.getOptionValue("prompt") === undefined && command.getOptionValue("promptFile") === undefined) throw usageError("Provide --prompt TEXT or --prompt-file FILE.");
})
.action(bind(handleMediaGenerate)), CREDIT_HELP);
const generate = media.commands.find((command) => command.name() === "generate")!;
requireOptionGroup(generate, "exactlyOne", ["--prompt", "--prompt-file"]);
addCommandExamples(generate,
'screenrig media generate --prompt "A full-screen welcome sign"',
'screenrig media generate --prompt-file prompt.txt --aspect-ratio 9:16');

media.command("upload").description("Upload an image or video")
.argument("<file>", "Local input file")
Expand All @@ -28,12 +29,12 @@ export function registerMediaCommands(root: Command, bind: CommandActionBinder):
.option("--no-wait", "Return after acceptance without waiting for processing")
.option("--poll-ms <MS>", "Set the operation polling interval", positiveInteger("poll-ms"))
.addOption(new Option("--no-transcode", "Upload accepted source bytes unchanged").conflicts(["codec", "maxFps", "maxEdge", "webpQuality", "preset", "audio"]))
.addOption(new Option("--codec <h264|hevc>", "Choose the video codec (default: h264)").conflicts(["transcode"]))
.addOption(new Option("--codec <h264|hevc>", "Choose the video codec").choices(["h264", "hevc"]).default("h264").conflicts(["transcode"]))
.addOption(new Option("--max-fps <N>", "Limit video frames per second").argParser(positiveNumber("max-fps")).conflicts(["transcode"]))
.addOption(new Option("--max-edge <PIXELS>", "Limit the longest video edge").argParser(positiveInteger("max-edge")).conflicts(["transcode"]))
.addOption(new Option("--webp-quality <1-100>", "Set WebP quality").argParser(positiveInteger("webp-quality")).conflicts(["transcode"]))
.option("--no-progress", "Suppress stderr progress")
.addOption(new Option("--preset <PRESET>", "Use signage-1080p30 or signage-4k30").conflicts(["transcode"]))
.addOption(new Option("--preset <PRESET>", "Choose a transcoding preset").choices(["signage-1080p30", "signage-4k30"]).conflicts(["transcode"]))
.addOption(new Option("--no-audio", "Remove the audio track when transcoding").conflicts(["transcode"]))
.action(bind(handleMediaUpload));

Expand All @@ -45,11 +46,11 @@ export function registerMediaCommands(root: Command, bind: CommandActionBinder):
.option("--tag <TAG>", "Set the media tag")
.option("--no-progress", "Suppress stderr progress")
.option("--poll-ms <MS>", "Set the operation polling interval", positiveInteger("poll-ms"))
.addOption(new Option("--codec <h264|hevc>", "Choose the video codec (default: h264)").conflicts(["transcode"]))
.addOption(new Option("--codec <h264|hevc>", "Choose the video codec").choices(["h264", "hevc"]).default("h264").conflicts(["transcode"]))
.addOption(new Option("--max-fps <N>", "Limit video frames per second").argParser(positiveNumber("max-fps")).conflicts(["transcode"]))
.addOption(new Option("--max-edge <PIXELS>", "Limit the longest video edge").argParser(positiveInteger("max-edge")).conflicts(["transcode"]))
.addOption(new Option("--webp-quality <1-100>", "Set WebP quality").argParser(positiveInteger("webp-quality")).conflicts(["transcode"]))
.addOption(new Option("--preset <PRESET>", "Use signage-1080p30 or signage-4k30").conflicts(["transcode"]))
.addOption(new Option("--preset <PRESET>", "Choose a transcoding preset").choices(["signage-1080p30", "signage-4k30"]).conflicts(["transcode"]))
.addOption(new Option("--no-audio", "Remove the audio track when transcoding").conflicts(["transcode"]))
.action(bind(handleMediaUploadBatch));

Expand All @@ -64,7 +65,7 @@ export function registerMediaCommands(root: Command, bind: CommandActionBinder):

media.command("list").description("List media")
.option("--tag <TAG>", "Filter media by tag")
.option("--primitive <image|video>", "Filter by media primitive")
.addOption(new Option("--primitive <image|video>", "Filter by media primitive").choices(["image", "video"]))
.action(bind(handleMediaList));

media.command("update").description("Update a media tag")
Expand Down
39 changes: 39 additions & 0 deletions src/cli-commands/notes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Command } from "commander";
import { usageError } from "../problems.js";

const notes = new WeakMap<Command, string[]>();

Expand All @@ -11,3 +12,41 @@ export function addCommandNotes(command: Command, note: string): void {
export function commandNotes(command: Command): string[] {
return [...(notes.get(command) ?? [])];
}

export interface OptionRelationship {
kind: "exactlyOne" | "together";
options: string[];
}
const relationships = new WeakMap<Command, OptionRelationship[]>();
const examples = new WeakMap<Command, string[]>();

/** One definition drives both preflight validation and discovery. */
export function requireOptionGroup(command: Command, kind: OptionRelationship["kind"], names: string[]): void {
const options = names.map((name) => {
const option = command.options.find((candidate) => candidate.long === name);
if (!option) throw new Error(`Unknown option ${name}`);
return option;
});
relationships.set(command, [...(relationships.get(command) ?? []), { kind, options: names }]);
const message = kind === "exactlyOne"
? `Provide exactly one of ${names.join(" or ")}.`
: `Provide ${names.join(" and ")} together.`;
addCommandNotes(command, message);
command.hook("preAction", () => {
const count = options.filter((option) => command.getOptionValueSource(option.attributeName()) === "cli").length;
if (kind === "exactlyOne" ? count !== 1 : count !== 0 && count !== options.length) throw usageError(message);
});
}

export function commandRelationships(command: Command): OptionRelationship[] {
return relationships.get(command) ?? [];
}

export function addCommandExamples(command: Command, ...commands: string[]): void {
examples.set(command, [...(examples.get(command) ?? []), ...commands]);
command.addHelpText("after", `\nExamples:\n${commands.map((example) => ` ${example}`).join("\n")}`);
}

export function commandExamples(command: Command): string[] {
return examples.get(command) ?? [];
}
27 changes: 14 additions & 13 deletions src/cli-commands/playlist.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
import { usageError } from "../problems.js";
import { nonnegativeInteger, positiveInteger, revision } from "./options.js";
import type { CommandActionBinder } from "./types.js";
import { handlePlaylistInit, handlePlaylistValidate, handlePlaylistPreview, handlePlaylistTemplates, handlePlaylistCreate, handlePlaylistUpdate, handlePlaylistExport, handlePlaylistImport, handlePlaylistShow, handlePlaylistList, handlePlaylistDelete } from "../commands.js";
import type { Command } from "commander";
import { addCommandNotes } from "./notes.js";
import { type Command, Option } from "commander";
import { addCommandNotes, addCommandExamples, requireOptionGroup } from "./notes.js";
import { LOOK_AT_THE_CONTACT_SHEET } from "../playlist-preview.js";

export function registerPlaylistCommands(root: Command, bind: CommandActionBinder): void {
const playlist = root.command("playlist").description("Author, validate, preview, and manage playlists");

playlist.command("init").description("Prepare an editable full-screen playlist from ready media")
const init = playlist.command("init").description("Prepare an editable full-screen playlist from ready media")
.argument("<media-ids...>", "Media identifiers in playback order")
.requiredOption("--name <NAME>", "Playlist name")
.requiredOption("--output <FILE>", "Create an editable playlist file")
.option("--screen <ID>", "Use this screen's reported playback dimensions")
.option("--target-width <PX>", "Override canvas width", positiveInteger("target-width"))
.option("--target-height <PX>", "Override canvas height", positiveInteger("target-height"))
.option("--duration-ms <MS>", "Image page duration (default: 8000)", positiveInteger("duration-ms"))
.option("--fit <FIT>", "Content fit: contain (default), cover, or fill")
.addOption(new Option("--duration-ms <MS>", "Image page duration").argParser(positiveInteger("duration-ms")).default(8000))
.addOption(new Option("--fit <FIT>", "Content fit").choices(["contain", "cover", "fill"]).default("contain"))
.action(bind(handlePlaylistInit));
requireOptionGroup(init, "together", ["--target-width", "--target-height"]);
addCommandNotes(init, "Provide --screen for reported dimensions, or both target dimensions to set the canvas explicitly.");
addCommandExamples(init, 'screenrig playlist init med_IMAGE --name Lobby --screen scr_SCREEN --output lobby.json',
'screenrig playlist init med_IMAGE med_VIDEO --name Lobby --target-width 1920 --target-height 1080 --output lobby.json');

playlist.command("validate").description("Validate a playlist file")
.argument("<file>", "Local input file")
Expand Down Expand Up @@ -51,25 +54,23 @@ export function registerPlaylistCommands(root: Command, bind: CommandActionBinde
.requiredOption("--output <PATH>", "Write the exported bundle to this directory (required)")
.action(bind(handlePlaylistExport));

playlist.command("import").description("Import a playlist bundle")
const importCommand = playlist.command("import").description("Import a playlist bundle")
.argument("<directory>", "Local directory")
.option("--name <NAME>", "Set the imported playlist name")
.option("--update <ID>", "Update this playlist when importing")
.option("--expect-rev <REVISION>", "Require the current resource revision", revision)
.option("--poll-ms <MS>", "Set the operation polling interval", positiveInteger("poll-ms"))
.hook("preAction", (command) => {
const update = command.getOptionValueSource("update") === "cli";
const ifMatch = command.getOptionValueSource("expectRev") === "cli";
if (update !== ifMatch) throw usageError("playlist import --update and --expect-rev must be supplied together.");
})
.action(bind(handlePlaylistImport));
requireOptionGroup(importCommand, "together", ["--update", "--expect-rev"]);

playlist.command("show").description("Inspect a playlist or write an editable document")
const show = playlist.command("show").description("Inspect a playlist or write an editable document")
.option("--output <FILE>", "Create an editable playlist file; report its revision on stdout")
.option("--editable", "Return an editable document and revision in the JSON envelope")
.alias("get")
.argument("<id>", "Playlist identifier")
.action(bind(handlePlaylistShow));
addCommandExamples(show, 'screenrig playlist show pl_PLAYLIST --output playlist.json',
'screenrig playlist show pl_PLAYLIST --editable');

playlist.command("list").description("List playlists")
.action(bind(handlePlaylistList));
Expand Down
10 changes: 9 additions & 1 deletion src/cli-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ export function commandError(error: CommanderError, command: Command): never {
}
if (path === "media list" && has("kind")) throw usageError("media list uses --primitive image|video, not --kind.");
switch (error.code) {
case "commander.invalidArgument": {
const option = command.options.find((candidate) => candidate.argChoices && error.message.startsWith(`error: option '${candidate.flags}'`));
if (option?.argChoices) {
const choices = option.argChoices;
const allowed = choices.length > 2 ? `${choices.slice(0, -1).join(", ")}, or ${choices.at(-1)}` : choices.join(" or ");
throw usageError(`${option.long} must be ${allowed}.`);
}
throw usageError("Invalid argument. See command help for supported values.");
}
case "commander.missingArgument":
if (path === "screen set-timezone") throw usageError("screen set-timezone requires <id> --timezone --expect-rev.");
throw usageError(`${path} requires ${command.registeredArguments.filter((arg) => arg.required).map((arg) => `<${arg.name()}>`).join(" ")}.`);
Expand All @@ -38,4 +47,3 @@ export function commandError(error: CommanderError, command: Command): never {
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." });
}
}

10 changes: 7 additions & 3 deletions src/command-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface CommandTree {
selected: () => Command;
helpRequested: () => boolean;
versionRequested: () => boolean;
inventoryRequested: () => boolean;
}

export function commandInput(command: Command): ParsedArgs {
Expand Down Expand Up @@ -60,15 +61,18 @@ export function createCommandTree(
let selected = root;
let helpRequested = false;
let versionRequested = false;
let inventoryRequested = false;
const seen = new Set<string>();

registerCommands(root, (handler) => (...values: unknown[]) => {
selected = values[values.length - 1] as Command;
return execute?.(handler, commandInput(selected));
});
root.command("help [command...]")
.description("Discover commands and their options")
.action((path: string[]) => {
.description("Discover commands; --all lists every command")
.option("--all", "Include all descendant command paths")
.action((path: string[], options: { all?: boolean }) => {
inventoryRequested = options.all === true;
const target = findCommand(root, path);
if (!target) throw usageError("Unknown help topic.");
target.help();
Expand Down Expand Up @@ -96,5 +100,5 @@ export function createCommandTree(
command.commands.forEach(configure);
};
configure(root);
return { root, selected: () => selected, helpRequested: () => helpRequested, versionRequested: () => versionRequested };
return { root, selected: () => selected, helpRequested: () => helpRequested, versionRequested: () => versionRequested, inventoryRequested: () => inventoryRequested };
}
Loading
Loading