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
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,17 @@ 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.
Commander 14 supports Node 20.11 and owns parsing, command selection, and help.
Add commands in `src/cli-commands/`: each group registers native Commander commands
with their arguments, options, descriptions, validation hooks, and bound handlers.
There is no separate command schema or path-based dispatcher. Shared option
parsers live beside these modules; human and JSON help read the registered tree.

`src/program.ts` awaits Commander actions with `parseAsync`. The output boundary
keeps one CLI envelope per invocation, and shared handler setup handles config,
authentication, and logging. Only explicitly supplied options reach handlers,
including the existing `no-*` switches. `parseArgv` is an inspection helper for
tests; execution runs through Commander actions.

```sh
npm ci
Expand Down
59 changes: 11 additions & 48 deletions src/argv.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,23 @@
import { CommanderError } from "commander";
import { commandPath, createCommandTree, invocationFlags } from "./command-tree.js";
import { commandInput, createCommandTree } from "./command-tree.js";
import type { ParsedArgs } from "./command-input.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 { flagString, flagBool, flagNumber, type ParsedArgs } from "./command-input.js";

export function parseArgv(argv: string[]): ParsedArgs {
// 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.`);
}
const tree = createCommandTree(options);
/** Inspect the same native command tree without executing an application handler. */
export function parseArgv(argv: string[]): ParsedArgs & { help?: HelpDocument } {
const tree = createCommandTree(argv);
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;
const args = commandInput(tree.selected());
if (tree.versionRequested()) args.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 {
const value = flags[name];
return typeof value === "string" && value.length > 0 ? value : undefined;
}

export function flagBool(flags: Record<string, string | boolean>, name: string): boolean {
return flags[name] === true;
}

export function flagNumber(flags: Record<string, string | boolean>, name: string): number | undefined {
const value = flagString(flags, name);
if (value === undefined) {
return undefined;
const help = describeHelp(tree.selected());
return { command: help.path.slice(0, 2), positionals: help.path, flags: { ...args.flags, help: true }, help };
}
const n = Number(value);
return Number.isFinite(n) ? n : undefined;
return args;
}
12 changes: 12 additions & 0 deletions src/cli-commands/account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { CommandActionBinder } from "./types.js";
import { handleAccountShow } from "../commands.js";
import type { Command } from "commander";
import { addCommandNotes } from "./notes.js";
import { CREDIT_HELP } from "../help-text.js";

export function registerAccountCommands(root: Command, bind: CommandActionBinder): void {
const account = root.command("account").description("Inspect your account and credits");

addCommandNotes(account.command("show").description("Inspect your account and credits")
.action(bind(handleAccountShow)), CREDIT_HELP);
}
27 changes: 27 additions & 0 deletions src/cli-commands/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { CommandActionBinder } from "./types.js";
import { handleAgentEnroll, handleAgentConnect, handleAgentStatus, handleAgentDisconnect } from "../commands.js";
import type { Command } from "commander";
import { addCommandNotes } from "./notes.js";

export function registerAgentCommands(root: Command, bind: CommandActionBinder): void {
const agent = root.command("agent").description("Enroll, connect, and manage this agent");

agent.command("enroll").description("Create the first agent with an email address")
.option("--email <ADDRESS>", "Set the account contact email")
.option("--name <NAME>", "Set this agent installation name")
.option("--open-dashboard", "Open the dashboard after enrolling")
.action(bind(handleAgentEnroll));

addCommandNotes(agent.command("connect").description("Connect this installation with approval")
.option("--name <NAME>", "Set this agent installation name")
.option("--print-url", "Return the browser handoff URL")
.action(bind(handleAgentConnect)), "Approval expires after 24 hours; --timeout defaults to 86400000 ms. Retry agent connect to resume after an interrupted wait.");

agent.command("status").description("Inspect this agent's connection")
.action(bind(handleAgentStatus));

agent.command("disconnect").description("Disconnect this agent")
.option("--yes", "Confirm this agent's disconnection")
.option("--allow-lockout", "Allow disconnecting the last agent")
.action(bind(handleAgentDisconnect));
}
35 changes: 35 additions & 0 deletions src/cli-commands/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { positiveInteger, revision } from "./options.js";
import type { CommandActionBinder } from "./types.js";
import { handleAppPack, handleAppUpload, handleAppUpdate, handleAppList, handleAppShow } from "../commands.js";
import type { Command } from "commander";

export function registerAppCommands(root: Command, bind: CommandActionBinder): void {
const app = root.command("app").description("Pack, upload, and inspect applications");

app.command("pack").description("Pack a local application directory")
.argument("<directory>", "Local directory")
.option("--output <PATH>", "Write the application package to this file")
.action(bind(handleAppPack));

app.command("upload").description("Upload an application directory")
.argument("<directory>", "Local directory")
.option("--name <NAME>", "Set the application name")
.option("--no-wait", "Return after acceptance without waiting for processing")
.option("--poll-ms <MS>", "Set the operation polling interval", positiveInteger("poll-ms"))
.action(bind(handleAppUpload));

app.command("update").description("Update an application")
.argument("<id>", "Application identifier")
.argument("<directory>", "Local directory")
.requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision)
.option("--no-wait", "Return after acceptance without waiting for processing")
.option("--poll-ms <MS>", "Set the operation polling interval", positiveInteger("poll-ms"))
.action(bind(handleAppUpdate));

app.command("list").description("List applications")
.action(bind(handleAppList));

app.command("show").description("Inspect an application")
.argument("<id>", "Application identifier")
.action(bind(handleAppShow));
}
12 changes: 12 additions & 0 deletions src/cli-commands/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { CommandActionBinder } from "./types.js";
import { handleBrowserSetup } from "../commands.js";
import type { Command } from "commander";

export function registerBrowserCommands(root: Command, bind: CommandActionBinder): void {
const browser = root.command("browser").description("Complete browser Player setup");

browser.command("setup").description("Complete browser setup from a code")
.requiredOption("--code <CODE>", "Use the browser setup code (required)")
.option("--open", "Open the result in its viewer")
.action(bind(handleBrowserSetup));
}
44 changes: 44 additions & 0 deletions src/cli-commands/comment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { CommandActionBinder } from "./types.js";
import { handleCommentShowScreen, handleCommentShowPlaylist, handleCommentSetScreen, handleCommentSetPlaylist, handleCommentDeleteScreen, handleCommentDeletePlaylist } from "../commands.js";
import { type Command, Option } from "commander";

export function registerCommentCommands(root: Command, bind: CommandActionBinder): void {
const comment = root.command("comment").description("Read and manage screen and playlist comments");

const commentShow = comment.command("show").description("Read screen or playlist comments");

commentShow.command("screen").description("Read screen comments")
.argument("<id>", "Screen or playlist identifier")
.action(bind(handleCommentShowScreen));

commentShow.command("playlist").description("Read playlist comments")
.argument("<id>", "Screen or playlist identifier")
.option("--page <PAGE_ID>", "Select playlist page comments")
.action(bind(handleCommentShowPlaylist));

const commentSet = comment.command("set").description("Replace screen or playlist comments");

commentSet.command("screen").description("Replace screen comments")
.argument("<id>", "Screen or playlist identifier")
.addOption(new Option("--json-value <JSON>", "Supply a JSON value").conflicts(["file"]))
.addOption(new Option("--file <FILE>", "Read the value from a file").conflicts(["jsonValue"]))
.action(bind(handleCommentSetScreen));

commentSet.command("playlist").description("Replace playlist comments")
.argument("<id>", "Screen or playlist identifier")
.option("--page <PAGE_ID>", "Select playlist page comments")
.addOption(new Option("--json-value <JSON>", "Supply a JSON value").conflicts(["file"]))
.addOption(new Option("--file <FILE>", "Read the value from a file").conflicts(["jsonValue"]))
.action(bind(handleCommentSetPlaylist));

const commentDelete = comment.command("delete").description("Delete screen or playlist comments");

commentDelete.command("screen").description("Delete screen comments")
.argument("<id>", "Screen or playlist identifier")
.action(bind(handleCommentDeleteScreen));

commentDelete.command("playlist").description("Delete playlist comments")
.argument("<id>", "Screen or playlist identifier")
.option("--page <PAGE_ID>", "Select playlist page comments")
.action(bind(handleCommentDeletePlaylist));
}
43 changes: 43 additions & 0 deletions src/cli-commands/compose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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 { LOOK_AT_THE_CONTACT_SHEET } from "../playlist-preview.js";

export function registerComposeCommands(root: Command, bind: CommandActionBinder): void {
const compose = root.command("compose").description("Render and inspect local page compositions");

compose.command("catalog").description("Browse local composition capabilities and examples")
.action(bind(handleComposeCatalog));

addCommandNotes(compose.command("batch").description("Render a batch of compositions")
.argument("<file>", "Local input file")
.requiredOption("--output <PATH>", "Write rendered files to this directory (required)")
.option("--only <ID>", "Render only this page")
.option("--target-width <PX>", "Set the physical content width", positiveInteger("target-width"))
.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")
.argument("<file>", "Local input file")
.option("--output <PATH>", "Write rendered files to this directory")
.option("--combined", "Also render combined page images")
.option("--target-width <PX>", "Set the physical content width", positiveInteger("target-width"))
.option("--target-height <PX>", "Set the physical content height", positiveInteger("target-height"))
.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.");
}
9 changes: 9 additions & 0 deletions src/cli-commands/dashboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { CommandActionBinder } from "./types.js";
import { handleDashboard } from "../commands.js";
import type { Command } from "commander";

export function registerDashboardCommands(root: Command, bind: CommandActionBinder): void {
root.command("dashboard").description("Open the account dashboard")
.option("--print-url", "Return the browser handoff URL")
.action(bind(handleDashboard));
}
9 changes: 9 additions & 0 deletions src/cli-commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { CommandActionBinder } from "./types.js";
import { handleDoctor } from "../commands.js";
import type { Command } from "commander";

export function registerDoctorCommands(root: Command, bind: CommandActionBinder): void {
root.command("doctor").description("Check this CLI installation and configuration")
.option("--repair-config", "Repair configuration permissions")
.action(bind(handleDoctor));
}
19 changes: 19 additions & 0 deletions src/cli-commands/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { positiveInteger } from "./options.js";
import type { CommandActionBinder } from "./types.js";
import { handleEventsList, handleEventsFollow } from "../commands.js";
import { type Command, Option } from "commander";

export function registerEventsCommands(root: Command, bind: CommandActionBinder): void {
const events = root.command("events").description("List or follow account events");

events.command("list").description("List account events")
.addOption(new Option("--after <CURSOR>", "Read events after this cursor").conflicts(["cursor"]))
.addOption(new Option("--cursor <CURSOR>", "Alias for --after").conflicts(["after"]))
.option("--limit <N>", "Limit returned events", positiveInteger("limit"))
.action(bind(handleEventsList));

events.command("follow").description("Stream account events")
.addOption(new Option("--after <CURSOR>", "Read events after this cursor").conflicts(["cursor"]))
.addOption(new Option("--cursor <CURSOR>", "Alias for --after").conflicts(["after"]))
.action(bind(handleEventsFollow));
}
27 changes: 27 additions & 0 deletions src/cli-commands/feedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { CommandActionBinder } from "./types.js";
import { handleFeedbackBug, handleFeedbackFeature, handleFeedbackList } from "../commands.js";
import { type Command, Option } from "commander";

export function registerFeedbackCommands(root: Command, bind: CommandActionBinder): void {
const feedback = root.command("feedback").description("Submit bugs and feature requests");

feedback.command("bug").description("Submit a bug report")
.argument("<title>", "Feedback title")
.addOption(new Option("--body <TEXT>", "Supply feedback details").conflicts(["bodyFile"]))
.addOption(new Option("--body-file <FILE>", "Read feedback details from a file").conflicts(["body"]))
.option("--command <GROUP ACTION>", "Identify the command involved")
.option("--no-context", "Omit diagnostic context")
.action(bind(handleFeedbackBug));

feedback.command("feature").description("Submit a feature request")
.argument("<title>", "Feedback title")
.addOption(new Option("--body <TEXT>", "Supply feedback details").conflicts(["bodyFile"]))
.addOption(new Option("--body-file <FILE>", "Read feedback details from a file").conflicts(["body"]))
.option("--command <GROUP ACTION>", "Identify the command involved")
.option("--no-context", "Omit diagnostic context")
.action(bind(handleFeedbackFeature));

feedback.command("list").description("List submitted feedback")
.option("--kind <bug|feature>", "Filter feedback by kind")
.action(bind(handleFeedbackList));
}
14 changes: 14 additions & 0 deletions src/cli-commands/globals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { type Command, Option } from "commander";
import { nonnegativeInteger } from "./options.js";

export function registerGlobalOptions(root: Command): void {
root
.option("--json", "Return a JSON envelope")
.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")
.option("--idempotency-key <KEY>", "Reuse a key when retrying the same write")
.option("--timeout <MS>", "Set the command timeout in milliseconds", nonnegativeInteger("timeout"))
.option("--beta-key <KEY>", "Supply an enrollment beta key")
.addOption(new Option("--token <TOKEN>", "Unsupported credential override; use agent enrollment or connection").hideHelp());
}
Loading
Loading