From 22e6da0233e905c39efc7a522e3a245f38decc2c Mon Sep 17 00:00:00 2001 From: James Russo Date: Wed, 28 Oct 2026 06:28:44 +0000 Subject: [PATCH] feat(cli): define every command through one factory and test them all as an agent (#6616) * feat(cli): define every command through one factory and test them all as an agent Every command now comes from defineCommand in utils/defineCommand.ts. A lint rule bans importing defineCommand from citty anywhere else, and a new contract test walks the command registry in cli.ts, checks each command was built by the factory, and runs it the way a coding agent does: no terminal, stdin closed, CI=1, CLAUDECODE=1 and a home folder of its own. Usage errors now exit 2: unknown flags, extra positionals, and citty's own parse errors (missing argument, bad value, unknown or missing subcommand). An unknown flag names the help command instead of printing nothing else. isUnattended() in utils/attendedTerminal.ts is the one answer to "can a person respond here". It is true without a TTY on stdin or stdout, under CI (any value except "false" or "0"), when the existing agent detection finds an agent, or with HYPERFRAMES_AGENT=1. Color is off when it is true. Co-Authored-By: Claude Opus 5.5 (1M context) * test(cli): spawn preview in the agent-mode test only when the studio packages are built From source, preview refuses to load until the studio workspace packages are built, and the CLI test jobs do not build them. preview is still checked for being defined with the shared factory. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(cli): count only agent-run shells as unattended, and exit 2 for missing arguments isUnattended() used the telemetry fingerprint detectAgentRuntime(), which also matches a person typing in Cursor's or Windsurf's terminal (TERM_PROGRAM) and any Replit shell (REPL_ID, REPLIT_USER). It now uses isAgentDriven(), which keeps only markers an agent sets on the shells it runs: CURSOR_AGENT counts, TERM_PROGRAM=cursor, TERM_PROGRAM=windsurf and the Replit variables do not. The hand-written argument checks in lambda, cloudrun, lambda policies, capture, remove-background, init (--resolution), tts (--lang) and cloud render (--idempotency-key, --dry-run) exited 1. They are usage errors and now exit 2. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .oxlintrc.json | 18 ++ docs/packages/cli.mdx | 4 + packages/cli/src/cli.agentMode.test.ts | 155 ++++++++++++++++++ packages/cli/src/cli.ts | 20 ++- packages/cli/src/commands/add.ts | 2 +- packages/cli/src/commands/audio.ts | 2 +- packages/cli/src/commands/auth.ts | 2 +- packages/cli/src/commands/auth/login.ts | 2 +- packages/cli/src/commands/auth/logout.ts | 2 +- packages/cli/src/commands/auth/refresh.ts | 2 +- packages/cli/src/commands/auth/status.ts | 2 +- packages/cli/src/commands/beats.ts | 2 +- packages/cli/src/commands/benchmark.ts | 2 +- packages/cli/src/commands/browser.ts | 2 +- packages/cli/src/commands/capture.test.ts | 8 +- packages/cli/src/commands/capture.ts | 8 +- packages/cli/src/commands/catalog.ts | 2 +- packages/cli/src/commands/catch-up.ts | 2 +- packages/cli/src/commands/check.ts | 3 +- packages/cli/src/commands/clean.ts | 2 +- packages/cli/src/commands/cloud.ts | 2 +- packages/cli/src/commands/cloud/delete.ts | 2 +- packages/cli/src/commands/cloud/get.ts | 2 +- packages/cli/src/commands/cloud/list.ts | 2 +- .../cli/src/commands/cloud/render.test.ts | 6 +- packages/cli/src/commands/cloud/render.ts | 8 +- packages/cli/src/commands/cloudrun.ts | 18 +- packages/cli/src/commands/compare.ts | 2 +- packages/cli/src/commands/compositions.ts | 2 +- packages/cli/src/commands/docs.ts | 2 +- packages/cli/src/commands/doctor.ts | 2 +- packages/cli/src/commands/events.ts | 2 +- packages/cli/src/commands/feedback.ts | 2 +- packages/cli/src/commands/figma.ts | 2 +- packages/cli/src/commands/figma/asset.ts | 2 +- packages/cli/src/commands/figma/component.ts | 2 +- packages/cli/src/commands/figma/tokens.ts | 2 +- packages/cli/src/commands/grade-compare.ts | 2 +- packages/cli/src/commands/history.ts | 3 +- packages/cli/src/commands/info.ts | 2 +- packages/cli/src/commands/init.test.ts | 4 +- packages/cli/src/commands/init.ts | 7 +- packages/cli/src/commands/keyframes.ts | 2 +- packages/cli/src/commands/lambda.ts | 16 +- packages/cli/src/commands/lambda/policies.ts | 7 +- packages/cli/src/commands/layout.ts | 2 +- packages/cli/src/commands/lint.ts | 2 +- packages/cli/src/commands/media-treatment.ts | 2 +- packages/cli/src/commands/media-use.ts | 2 +- packages/cli/src/commands/models.ts | 2 +- packages/cli/src/commands/normalize-audio.ts | 2 +- packages/cli/src/commands/open.ts | 2 +- packages/cli/src/commands/play.ts | 2 +- packages/cli/src/commands/present.ts | 2 +- packages/cli/src/commands/preview.ts | 2 +- packages/cli/src/commands/publish.ts | 2 +- .../cli/src/commands/remove-background.ts | 12 +- packages/cli/src/commands/render.ts | 2 +- packages/cli/src/commands/skills.ts | 2 +- packages/cli/src/commands/snapshot.ts | 2 +- packages/cli/src/commands/telemetry.ts | 2 +- packages/cli/src/commands/timeline.ts | 3 +- packages/cli/src/commands/transcribe.ts | 2 +- packages/cli/src/commands/tts.ts | 4 +- packages/cli/src/commands/upgrade.ts | 2 +- packages/cli/src/commands/usage.ts | 2 +- packages/cli/src/commands/validate.ts | 2 +- .../cli/src/telemetry/agent_runtime.test.ts | 16 ++ packages/cli/src/telemetry/agent_runtime.ts | 11 ++ packages/cli/src/ui/colors.ts | 6 +- .../cli/src/utils/attendedTerminal.test.ts | 104 ++++++++++++ packages/cli/src/utils/attendedTerminal.ts | 18 +- .../cli/src/utils/command-failure-tracking.ts | 2 +- packages/cli/src/utils/commandResult.ts | 10 +- packages/cli/src/utils/defineCommand.ts | 18 ++ .../src/utils/reject-unknown-flags.test.ts | 2 +- .../cli/src/utils/reject-unknown-flags.ts | 7 +- 77 files changed, 486 insertions(+), 110 deletions(-) create mode 100644 packages/cli/src/cli.agentMode.test.ts create mode 100644 packages/cli/src/utils/attendedTerminal.test.ts create mode 100644 packages/cli/src/utils/defineCommand.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index eb64dd1443..196dc68fee 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -87,6 +87,24 @@ } ] } + }, + { + "files": ["packages/cli/src/**/*.ts"], + "excludeFiles": ["**/*.test.ts", "packages/cli/src/utils/defineCommand.ts"], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "citty", + "importNames": ["defineCommand"], + "message": "Define commands with defineCommand from packages/cli/src/utils/defineCommand.ts so the shared guards and the agent-mode contract test cover them." + } + ] + } + ] + } } ] } diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 98f6ed2cb3..fcd2376970 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -71,6 +71,10 @@ example rather than waiting for input. Two commands are interactive on a TTY: `--non-interactive`, and `catalog --human-friendly` opens a picker. Nothing else prompts, apart from confirmations you can skip with `--yes` or `--no-confirm`. +**Exit codes.** `0` means the command succeeded, `1` means it ran and failed, and +`2` means the command line itself is wrong: an unknown command or flag, or a +missing or invalid value. + **Version notices.** An owned install (npm, bun, pnpm, brew) updates itself quietly in the background and prints one line on the next run: `hyperframes auto-updated to v0.1.5`. It never crosses a major version. Anywhere diff --git a/packages/cli/src/cli.agentMode.test.ts b/packages/cli/src/cli.agentMode.test.ts new file mode 100644 index 0000000000..ade63c7533 --- /dev/null +++ b/packages/cli/src/cli.agentMode.test.ts @@ -0,0 +1,155 @@ +import { spawn } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { CommandDef } from "citty"; +import { afterAll, describe, expect, it } from "vitest"; +import { isDefinedCommand } from "./utils/defineCommand.js"; +import { checkStudioWorkspaceBuild } from "./utils/workspaceBuildCheck.js"; + +// Runs every registered command the way a coding agent does: no terminal, stdin closed, CI set, +// an agent marker in the environment, and a home folder of its own. A new command is covered as +// soon as it is added to `commandLoaders` in cli.ts. + +const srcDir = dirname(fileURLToPath(import.meta.url)); +const cliEntry = join(srcDir, "cli.ts"); +const home = mkdtempSync(join(tmpdir(), "hf-agent-mode-")); +afterAll(() => rmSync(home, { recursive: true, force: true })); + +const ANSI = "\u001b["; +const PROMPT = /\(y\/N\)|Yes \/ No|[◆◇]\s+[^\n]*\?\s*$/m; +const NAG = /Update available|auto-updated to|skills? out of date or missing/; + +interface Registered { + path: string[]; + command: CommandDef; +} + +type Resolvable = CommandDef | Promise | (() => CommandDef | Promise); + +async function resolveCommand(entry: Resolvable): Promise { + return typeof entry === "function" ? await entry() : await entry; +} + +function registeredModules(): { name: string; file: string; exportName: string }[] { + const source = readFileSync(cliEntry, "utf8"); + const block = source.match(/const commandLoaders = \{([\s\S]*?)\n\};/)?.[1] ?? ""; + const entry = + /^\s+"?([\w-]+)"?:[\s\S]*?import\("\.\/commands\/([\w/-]+)\.js"\)\.then\(\(m\) => m\.(\w+)\)/gm; + return [...block.matchAll(entry)].map(([, name, file, exportName]) => ({ + name: name!, + file: file!, + exportName: exportName!, + })); +} + +async function walk(path: string[], command: CommandDef, out: Registered[]): Promise { + out.push({ path, command }); + const subCommands = (await resolveCommand( + (command.subCommands ?? {}) as Resolvable, + )) as unknown as Record; + for (const [name, sub] of Object.entries(subCommands)) { + await walk([...path, name], await resolveCommand(sub), out); + } +} + +async function registeredCommands(): Promise { + const out: Registered[] = []; + for (const { name, file, exportName } of registeredModules()) { + const mod = (await import(`./commands/${file}.ts`)) as Record; + await walk([name], mod[exportName]!, out); + } + return out; +} + +interface Run { + code: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; +} + +function runAsAgent(args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn("bun", ["run", cliEntry, ...args], { + cwd: home, + stdio: ["ignore", "pipe", "pipe"], + timeout: 15_000, + env: { + PATH: process.env.PATH, + HOME: home, + USERPROFILE: home, + CI: "1", + CLAUDECODE: "1", + }, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString())); + child.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString())); + child.on("error", reject); + child.on("close", (code, signal) => resolve({ code, signal, stdout, stderr })); + }); +} + +function expectQuiet(run: Run): void { + expect(run.signal, "finished before the timeout").toBeNull(); + const output = run.stdout + run.stderr; + expect(output).not.toContain(ANSI); + expect(output).not.toMatch(PROMPT); + expect(output).not.toMatch(NAG); +} + +const commands = await registeredCommands(); +// From source, `preview` refuses to load until the studio packages are built; the test job does +// not build them, so preview is only spawned where they are. +const studioBuilt = checkStudioWorkspaceBuild(join(srcDir, "..", "..", "..")).length === 0; +const spawnable = commands.filter((c) => studioBuilt || c.path[0] !== "preview"); + +describe("agent-mode contract", () => { + it("finds the registered commands", () => { + expect(commands.length).toBeGreaterThan(50); + }); + + it.each(commands.map((c) => [c.path.join(" "), c] as const))( + "%s is defined with the shared factory", + (_label, { command }) => { + expect(isDefinedCommand(command)).toBe(true); + }, + ); + + describe.concurrent("each command", () => { + it.each(spawnable.map((c) => [c.path.join(" "), c.path] as const))( + "%s --help prints plain help and exits 0", + async (_label, path) => { + const run = await runAsAgent([...path, "--help"]); + expectQuiet(run); + expect(run.code, run.stderr).toBe(0); + expect(run.stdout).toContain("USAGE"); + }, + ); + + it.each(spawnable.map((c) => [c.path.join(" "), c.path] as const))( + "%s rejects an unknown flag with exit 2", + async (_label, path) => { + const run = await runAsAgent([...path, "--not-a-real-flag"]); + expectQuiet(run); + expect(run.code, run.stderr).toBe(2); + expect(run.stdout).toBe(""); + }, + ); + }); + + it.each([ + ["lambda progress"], + ["cloudrun progress"], + ["capture"], + ["remove-background in.mp4"], + ["init demo --resolution huge --non-interactive"], + ])("`%s` is missing or misusing an argument, a usage error with exit 2", async (args) => { + const run = await runAsAgent(args.split(" ")); + expectQuiet(run); + expect(run.code, run.stderr).toBe(2); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index fbe31b2fcc..2c3638f68d 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -82,7 +82,8 @@ if (!captureEnv || process.env.HYPERFRAMES_NO_CAPTURE_DOTENV !== "1") { // Telemetry, update checks, and heavy modules are imported only when needed. // For --help we skip telemetry entirely. -import { defineCommand, runCommand } from "citty"; +import { runCommand } from "citty"; +import { defineCommand } from "./utils/defineCommand.js"; import type { ArgsDef, CommandDef } from "citty"; import { getRunId } from "./telemetry/runId.js"; import { reportCommandFailure, trackCommandFailures } from "./utils/command-failure-tracking.js"; @@ -93,6 +94,7 @@ import { CliResultSignal, CliRuntimeError, CliUsageError, + USAGE_EXIT_CODE, consumeCommandResult, outputWritten, registerRootExitCodeSanitizer, @@ -520,9 +522,21 @@ async function showRequestedUsage(): Promise { return showUsage(requested.command, requested.parent); } +// citty's own parse errors (missing argument, bad enum value, unknown or missing subcommand). +const CITTY_USAGE_CODES = new Set(["EARG", "E_UNKNOWN_COMMAND", "E_NO_COMMAND"]); + +function isCittyUsageError(error: unknown): boolean { + return ( + error instanceof Error && + error.name === "CLIError" && + CITTY_USAGE_CODES.has(String((error as { code?: unknown }).code)) + ); +} + function commandResultForError(error: unknown): CommandResult { if (error instanceof CliResultSignal) return error.result; if (error instanceof CliUsageError || error instanceof CliRuntimeError) return error.result; + if (isCittyUsageError(error)) return { exitCode: USAGE_EXIT_CODE, kind: "usage_error" }; return { exitCode: 1, kind: "runtime_error" }; } @@ -540,10 +554,12 @@ async function executeCli(): Promise { commandFailed = true; await reportCommandFailure(command, error); const typed = error instanceof CliUsageError || error instanceof CliRuntimeError; - if (error instanceof CliUsageError && !error.result.presented) await showRequestedUsage(); + const usage = error instanceof CliUsageError && !error.result.presented; + if (usage && !error.hint) await showRequestedUsage(); if (!typed || !error.result.presented) { console.error(error instanceof Error ? error.message : String(error)); } + if (usage && error.hint) console.error(error.hint); } } finally { const pending = consumeCommandResult(); diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 60cd514688..2269245d0f 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -1,5 +1,5 @@ import { failCommand } from "../utils/commandResult.js"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; export const examples: Example[] = [ diff --git a/packages/cli/src/commands/audio.ts b/packages/cli/src/commands/audio.ts index 609e72305b..11cd124adf 100644 --- a/packages/cli/src/commands/audio.ts +++ b/packages/cli/src/commands/audio.ts @@ -1,5 +1,5 @@ import { join, resolve } from "node:path"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { AnalysisQuality, AudioCapability, diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index 65ca5ca4f1..9424eb5184 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -11,7 +11,7 @@ * when the user is doing auth work. */ -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; import { c } from "../ui/colors.js"; diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index c65f37c85a..23613d77ea 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -23,7 +23,7 @@ import { envFlagEnabled, isAttendedTerminal } from "../../utils/attendedTerminal * the transient-blip rationale. */ -import { defineCommand } from "citty"; +import { defineCommand } from "../../utils/defineCommand.js"; import { stdin as input } from "node:process"; import { AuthClient, diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index a54cd2d62f..0c0b3d711c 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -8,7 +8,7 @@ import { failCommand } from "../../utils/commandResult.js"; * `HEYGEN_ACCESS_TOKEN`) can't be cleared by this command — we tell the user to unset them. */ -import { defineCommand } from "citty"; +import { defineCommand } from "../../utils/defineCommand.js"; import { ENV_CREDENTIAL_VAR, clearOAuth, diff --git a/packages/cli/src/commands/auth/refresh.ts b/packages/cli/src/commands/auth/refresh.ts index 860dfabfdf..a196ed5762 100644 --- a/packages/cli/src/commands/auth/refresh.ts +++ b/packages/cli/src/commands/auth/refresh.ts @@ -8,7 +8,7 @@ import { failCommand } from "../../utils/commandResult.js"; * job. Status's 401-retry path already does this automatically. */ -import { defineCommand } from "citty"; +import { defineCommand } from "../../utils/defineCommand.js"; import { assertOAuthConfiguredOrExit, isAuthError, diff --git a/packages/cli/src/commands/auth/status.ts b/packages/cli/src/commands/auth/status.ts index 0597df8488..a99f410cbb 100644 --- a/packages/cli/src/commands/auth/status.ts +++ b/packages/cli/src/commands/auth/status.ts @@ -15,7 +15,7 @@ import { failCommand, setCommandExitCode } from "../../utils/commandResult.js"; * place instead of each workflow improvising its own. */ -import { defineCommand } from "citty"; +import { defineCommand } from "../../utils/defineCommand.js"; import { ENV_CREDENTIAL_VAR, AuthClient, diff --git a/packages/cli/src/commands/beats.ts b/packages/cli/src/commands/beats.ts index 450d2340d7..0be4a54dd2 100644 --- a/packages/cli/src/commands/beats.ts +++ b/packages/cli/src/commands/beats.ts @@ -1,5 +1,5 @@ import { failCommand } from "../utils/commandResult.js"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import { existsSync, readFileSync, mkdirSync, statSync, writeFileSync } from "node:fs"; import { join, dirname, resolve } from "node:path"; import { diff --git a/packages/cli/src/commands/benchmark.ts b/packages/cli/src/commands/benchmark.ts index 79014df9d2..8e5576a516 100644 --- a/packages/cli/src/commands/benchmark.ts +++ b/packages/cli/src/commands/benchmark.ts @@ -1,5 +1,5 @@ import { failCommand, setCommandExitCode } from "../utils/commandResult.js"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; diff --git a/packages/cli/src/commands/browser.ts b/packages/cli/src/commands/browser.ts index 8fb89c19f7..7ef5a16d35 100644 --- a/packages/cli/src/commands/browser.ts +++ b/packages/cli/src/commands/browser.ts @@ -1,5 +1,5 @@ import { failCommand } from "../utils/commandResult.js"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; import * as clack from "@clack/prompts"; import { c } from "../ui/colors.js"; diff --git a/packages/cli/src/commands/capture.test.ts b/packages/cli/src/commands/capture.test.ts index 1b8547bc99..2099acdb3f 100644 --- a/packages/cli/src/commands/capture.test.ts +++ b/packages/cli/src/commands/capture.test.ts @@ -12,7 +12,11 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; -import { CliRuntimeError, registerRootExitRequester } from "../utils/commandResult.js"; +import { + CliRuntimeError, + CliUsageError, + registerRootExitRequester, +} from "../utils/commandResult.js"; import { CAPTURE_PHASE_SCHEMA } from "../capture/types.js"; const { captureWebsiteMock } = vi.hoisted(() => ({ @@ -228,7 +232,7 @@ describe("capture command — vision control", () => { captureCommand.run!({ args: { ...target, output: "/tmp/hf-local-refused", json: true }, } as never), - ).rejects.toThrow(CliRuntimeError); + ).rejects.toThrow("local-root" in target ? CliUsageError : CliRuntimeError); expect(error.mock.calls.flat().join("\n")).toContain(message); expect(captureWebsiteMock).not.toHaveBeenCalled(); }); diff --git a/packages/cli/src/commands/capture.ts b/packages/cli/src/commands/capture.ts index 5e750d7838..4b809b3003 100644 --- a/packages/cli/src/commands/capture.ts +++ b/packages/cli/src/commands/capture.ts @@ -1,6 +1,6 @@ import { DEFAULT_MAX_SCREENSHOTS } from "../capture/types.js"; -import { failCommand, scheduleCliExit } from "../utils/commandResult.js"; -import { defineCommand } from "citty"; +import { failCommand, scheduleCliExit, failUsage } from "../utils/commandResult.js"; +import { defineCommand } from "../utils/defineCommand.js"; import { resolve } from "node:path"; import type { Example } from "./_examples.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js"; @@ -163,13 +163,13 @@ export default defineCommand({ } else { if (localRoot !== undefined) { console.error("--local-root requires --local-file."); - failCommand(); + failUsage(); } if (!url) { console.error( "Missing URL. Pass a website URL, --local-file , or --video for video download.", ); - failCommand(); + failUsage(); } try { new URL(url); diff --git a/packages/cli/src/commands/catalog.ts b/packages/cli/src/commands/catalog.ts index 3b4bf71363..be2408ce6e 100644 --- a/packages/cli/src/commands/catalog.ts +++ b/packages/cli/src/commands/catalog.ts @@ -1,4 +1,4 @@ -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; export const examples: Example[] = [ diff --git a/packages/cli/src/commands/catch-up.ts b/packages/cli/src/commands/catch-up.ts index 9f4ac3624f..8ccf60a480 100644 --- a/packages/cli/src/commands/catch-up.ts +++ b/packages/cli/src/commands/catch-up.ts @@ -1,4 +1,4 @@ -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; import { c } from "../ui/colors.js"; import { diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index e1d38999f1..7339037ce8 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -12,7 +12,8 @@ import { import { formatFindingTimes } from "../utils/checkFindings.js"; import { withProjectLock } from "../utils/projectLock.js"; import { readToolContext, reportArtifacts, type ToolContext } from "../utils/toolContext.js"; -import { defineCommand, parseArgs } from "citty"; +import { parseArgs } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { ArgsDef } from "citty"; import type { Example } from "./_examples.js"; import { parseAt } from "./layout.js"; diff --git a/packages/cli/src/commands/clean.ts b/packages/cli/src/commands/clean.ts index b23decc12a..20d7fffd74 100644 --- a/packages/cli/src/commands/clean.ts +++ b/packages/cli/src/commands/clean.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { isAbsolute, join, relative, resolve } from "node:path"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import { EXTRACT_CACHE_MIN_AGE_MS, directorySizeBytes, diff --git a/packages/cli/src/commands/cloud.ts b/packages/cli/src/commands/cloud.ts index 576f04bfa5..aec21fe637 100644 --- a/packages/cli/src/commands/cloud.ts +++ b/packages/cli/src/commands/cloud.ts @@ -11,7 +11,7 @@ * credentials store and no new env var. */ -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; import { c } from "../ui/colors.js"; diff --git a/packages/cli/src/commands/cloud/delete.ts b/packages/cli/src/commands/cloud/delete.ts index 3b0f1de249..69a6bcde24 100644 --- a/packages/cli/src/commands/cloud/delete.ts +++ b/packages/cli/src/commands/cloud/delete.ts @@ -6,7 +6,7 @@ import { failCommand } from "../../utils/commandResult.js"; * shortly after. There's no undo from the CLI side. */ -import { defineCommand } from "citty"; +import { defineCommand } from "../../utils/defineCommand.js"; import { createCloudClient } from "../../cloud/index.js"; import { reportApiError } from "../../cloud/errors.js"; import { withMeta } from "../../utils/updateCheck.js"; diff --git a/packages/cli/src/commands/cloud/get.ts b/packages/cli/src/commands/cloud/get.ts index edcd17766e..d1869d20da 100644 --- a/packages/cli/src/commands/cloud/get.ts +++ b/packages/cli/src/commands/cloud/get.ts @@ -6,7 +6,7 @@ * docs / chat — fetch them on demand. */ -import { defineCommand } from "citty"; +import { defineCommand } from "../../utils/defineCommand.js"; import { createCloudClient } from "../../cloud/index.js"; import { reportApiError } from "../../cloud/errors.js"; import { colorStatus } from "../../cloud/statusColor.js"; diff --git a/packages/cli/src/commands/cloud/list.ts b/packages/cli/src/commands/cloud/list.ts index 51117eb556..ee00b66ae2 100644 --- a/packages/cli/src/commands/cloud/list.ts +++ b/packages/cli/src/commands/cloud/list.ts @@ -7,7 +7,7 @@ import { failCommand } from "../../utils/commandResult.js"; * mirrors the API default (10). */ -import { defineCommand } from "citty"; +import { defineCommand } from "../../utils/defineCommand.js"; import { createCloudClient } from "../../cloud/index.js"; import { padEndVisible } from "../../cloud/ansi.js"; import { reportApiError } from "../../cloud/errors.js"; diff --git a/packages/cli/src/commands/cloud/render.test.ts b/packages/cli/src/commands/cloud/render.test.ts index 8118c02aee..959af83f03 100644 --- a/packages/cli/src/commands/cloud/render.test.ts +++ b/packages/cli/src/commands/cloud/render.test.ts @@ -11,7 +11,7 @@ import { validateDryRunSource, type ProjectInputSource, } from "./render.js"; -import { CliRuntimeError } from "../../utils/commandResult.js"; +import { CliRuntimeError, CliUsageError } from "../../utils/commandResult.js"; const cliEntry = resolve(fileURLToPath(import.meta.url), "..", "..", "..", "cli.ts"); @@ -39,11 +39,11 @@ describe("validateDryRunSource", () => { it("accepts a local directory and rejects already-uploaded sources", () => { expect(() => validateDryRunSource({ kind: "dir", dir: "." }, true)).not.toThrow(); expect(() => validateDryRunSource({ kind: "asset_id", assetId: "asst_123" }, true)).toThrow( - CliRuntimeError, + CliUsageError, ); expect(() => validateDryRunSource({ kind: "url", url: "https://example.com/project.zip" }, true), - ).toThrow(CliRuntimeError); + ).toThrow(CliUsageError); }); }); diff --git a/packages/cli/src/commands/cloud/render.ts b/packages/cli/src/commands/cloud/render.ts index 8307870f2a..1b2a8a9f23 100644 --- a/packages/cli/src/commands/cloud/render.ts +++ b/packages/cli/src/commands/cloud/render.ts @@ -1,4 +1,4 @@ -import { failCommand } from "../../utils/commandResult.js"; +import { failCommand, failUsage } from "../../utils/commandResult.js"; /** * `hyperframes cloud render` — orchestrate a cloud-rendered HyperFrames * composition end-to-end: @@ -28,7 +28,7 @@ import { failCommand } from "../../utils/commandResult.js"; * HeyGen's external API OpenAPI spec; never hand-edit it. */ -import { defineCommand } from "citty"; +import { defineCommand } from "../../utils/defineCommand.js"; import { detectAspectRatioFromHtml, @@ -384,7 +384,7 @@ function validateIdempotencyKey(key: string | undefined): void { if (key === undefined) return; if (!IDEMPOTENCY_KEY_RE.test(key)) { errorBox("Invalid --idempotency-key", `Got "${key}". Must be 1-255 chars from [A-Za-z0-9_:.-]`); - failCommand(); + failUsage(); } } @@ -394,7 +394,7 @@ export function validateDryRunSource(source: ProjectInputSource, dryRun: boolean "Invalid --dry-run input", "--dry-run inspects a local project directory and cannot be combined with --asset-id or --url.", ); - failCommand(); + failUsage(); } // --------------------------------------------------------------------------- diff --git a/packages/cli/src/commands/cloudrun.ts b/packages/cli/src/commands/cloudrun.ts index 8973b640b4..6527c87428 100644 --- a/packages/cli/src/commands/cloudrun.ts +++ b/packages/cli/src/commands/cloudrun.ts @@ -1,5 +1,5 @@ // fallow-ignore-file code-duplication -import { failCommand } from "../utils/commandResult.js"; +import { failCommand, failUsage } from "../utils/commandResult.js"; /** * `hyperframes cloudrun` — deploy + drive distributed renders on Google * Cloud Run + Cloud Workflows. @@ -18,7 +18,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import { type CanvasResolution } from "@hyperframes/core"; import { parseOutputResolutionFlag } from "../utils/parseOutputResolution.js"; import type { Example } from "./_examples.js"; @@ -327,7 +327,7 @@ async function runDeploy(args: Record): Promise { const project = args.project as string | undefined; if (!project) { console.error("[cloudrun deploy] --project is required."); - failCommand(); + failUsage(); } const region = (args.region as string | undefined) ?? "us-central1"; const repo = (args.repo as string | undefined) ?? "hyperframes"; @@ -355,7 +355,7 @@ async function runDeploy(args: Record): Promise { console.error( "[cloudrun deploy] --image is required when not running from a hyperframes checkout (no Dockerfile context found).", ); - failCommand(); + failUsage(); } // Ensure the Artifact Registry repo exists. const exists = @@ -497,7 +497,7 @@ async function runSites(args: Record): Promise { const projectDir = args.extra as string | undefined; if (!projectDir) { console.error("[cloudrun sites create] usage: hyperframes cloudrun sites create "); - failCommand(); + failUsage(); } const state = readState(args); const { deploySite } = await loadCloudRunAdapter(); @@ -538,7 +538,7 @@ async function runRender(args: Record): Promise { console.error( "[cloudrun render] usage: hyperframes cloudrun render --width --height ", ); - failCommand(); + failUsage(); } const width = parsePositiveInt(args.width, "--width"); const height = parsePositiveInt(args.height, "--height"); @@ -604,7 +604,7 @@ async function runProgress(args: Record): Promise { const executionName = args.target as string | undefined; if (!executionName) { console.error("[cloudrun progress] usage: hyperframes cloudrun progress "); - failCommand(); + failUsage(); } const { getRenderProgress } = await loadCloudRunAdapter(); const progress = await getRenderProgress({ executionName }); @@ -644,7 +644,7 @@ async function runRenderBatch(args: Record): Promise { console.error( "[cloudrun render-batch] usage: hyperframes cloudrun render-batch --batch --width --height ", ); - failCommand(); + failUsage(); } const width = parsePositiveInt(args.width, "--width"); const height = parsePositiveInt(args.height, "--height"); @@ -774,7 +774,7 @@ async function runDestroy(args: Record): Promise { const image = (args.image as string | undefined) ?? "unused:latest"; if (!project) { console.error("[cloudrun destroy] --project is required (or deploy first to cache it)."); - failCommand(); + failUsage(); } const vars = [ "-var", diff --git a/packages/cli/src/commands/compare.ts b/packages/cli/src/commands/compare.ts index c01360ed46..0812b44993 100644 --- a/packages/cli/src/commands/compare.ts +++ b/packages/cli/src/commands/compare.ts @@ -12,7 +12,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, extname, join } from "node:path"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import { createContactSheet } from "../capture/contactSheet.js"; import { openSettledCompositionPage, diff --git a/packages/cli/src/commands/compositions.ts b/packages/cli/src/commands/compositions.ts index 4e2242820a..eeead49e37 100644 --- a/packages/cli/src/commands/compositions.ts +++ b/packages/cli/src/commands/compositions.ts @@ -1,4 +1,4 @@ -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; import { readFileSync } from "node:fs"; import { readProjectFile } from "@hyperframes/parsers/asset-resolution"; diff --git a/packages/cli/src/commands/docs.ts b/packages/cli/src/commands/docs.ts index 0af91d8e81..64a473b269 100644 --- a/packages/cli/src/commands/docs.ts +++ b/packages/cli/src/commands/docs.ts @@ -1,5 +1,5 @@ import { failCommand } from "../utils/commandResult.js"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; import { readFileSync, existsSync } from "node:fs"; diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index e15fe20008..97411cee31 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,5 +1,5 @@ // fallow-ignore-file complexity -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import { execFileSync, execSync } from "node:child_process"; import * as fs from "node:fs"; import { existsSync } from "node:fs"; diff --git a/packages/cli/src/commands/events.ts b/packages/cli/src/commands/events.ts index 421725cc71..37d68790f9 100644 --- a/packages/cli/src/commands/events.ts +++ b/packages/cli/src/commands/events.ts @@ -1,4 +1,4 @@ -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import { trackEvent, flushSync } from "../telemetry/client.js"; import { SKILL_SLUG } from "../telemetry/skill.js"; diff --git a/packages/cli/src/commands/feedback.ts b/packages/cli/src/commands/feedback.ts index 6dcaa926cf..66ee522d25 100644 --- a/packages/cli/src/commands/feedback.ts +++ b/packages/cli/src/commands/feedback.ts @@ -1,7 +1,7 @@ import { failCommand } from "../utils/commandResult.js"; import { randomUUID } from "node:crypto"; import { resolve } from "node:path"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import * as clack from "@clack/prompts"; import open from "open"; import type { Example } from "./_examples.js"; diff --git a/packages/cli/src/commands/figma.ts b/packages/cli/src/commands/figma.ts index e7f3da0eac..3697a072c5 100644 --- a/packages/cli/src/commands/figma.ts +++ b/packages/cli/src/commands/figma.ts @@ -6,7 +6,7 @@ * Requires FIGMA_TOKEN (personal access token from figma.com/settings). */ -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; import { c } from "../ui/colors.js"; diff --git a/packages/cli/src/commands/figma/asset.ts b/packages/cli/src/commands/figma/asset.ts index 84f0fcc970..a47150475c 100644 --- a/packages/cli/src/commands/figma/asset.ts +++ b/packages/cli/src/commands/figma/asset.ts @@ -4,7 +4,7 @@ * provenance in the shared manifest, print a composition snippet. */ -import { defineCommand } from "citty"; +import { defineCommand } from "../../utils/defineCommand.js"; import { appendRecord, buildAssetSnippet, diff --git a/packages/cli/src/commands/figma/component.ts b/packages/cli/src/commands/figma/component.ts index 5dd6f3b1b6..8c6d982af0 100644 --- a/packages/cli/src/commands/figma/component.ts +++ b/packages/cli/src/commands/figma/component.ts @@ -4,7 +4,7 @@ * Phase-1 asset import, packaged as a registry item. */ -import { defineCommand } from "citty"; +import { defineCommand } from "../../utils/defineCommand.js"; import { createFigmaClient, FigmaClientError, diff --git a/packages/cli/src/commands/figma/tokens.ts b/packages/cli/src/commands/figma/tokens.ts index 87ac8a8d99..c784dfd0f8 100644 --- a/packages/cli/src/commands/figma/tokens.ts +++ b/packages/cli/src/commands/figma/tokens.ts @@ -6,7 +6,7 @@ * component-import time, Phase 3). */ -import { defineCommand } from "citty"; +import { defineCommand } from "../../utils/defineCommand.js"; import { createFigmaClient, FigmaClientError, diff --git a/packages/cli/src/commands/grade-compare.ts b/packages/cli/src/commands/grade-compare.ts index 3d3cc144f3..eea2446451 100644 --- a/packages/cli/src/commands/grade-compare.ts +++ b/packages/cli/src/commands/grade-compare.ts @@ -18,7 +18,7 @@ import { serializeHfColorGrading, } from "@hyperframes/core"; import { gsapCdnDist } from "@hyperframes/core/gsap-cdn"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; import { openSettledCompositionPage, runFfmpegOnce } from "../capture/captureCompositionFrame.js"; import { findFFmpeg } from "../browser/ffmpeg.js"; diff --git a/packages/cli/src/commands/history.ts b/packages/cli/src/commands/history.ts index e157688e3d..d11f07926e 100644 --- a/packages/cli/src/commands/history.ts +++ b/packages/cli/src/commands/history.ts @@ -1,4 +1,5 @@ -import { defineCommand, type ArgsDef } from "citty"; +import { type ArgsDef } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import { HISTORY_START, HistoryBusyError, diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts index d57661b976..6e86049240 100644 --- a/packages/cli/src/commands/info.ts +++ b/packages/cli/src/commands/info.ts @@ -1,4 +1,4 @@ -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; import { readFileSync, readdirSync, statSync } from "node:fs"; diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index 1632dc6097..0248495ae3 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -100,7 +100,7 @@ describe("hyperframes init flag rename", () => { const target = join(dir, "proj"); try { const res = runInit([target, "--example", "--non-interactive"]); - expect(res.status).toBe(1); + expect(res.status).toBe(2); expect(res.stderr).toContain("Missing value for --example"); expect(existsSync(target)).toBe(false); } finally { @@ -682,7 +682,7 @@ describe("applyResolutionPreset", () => { "--non-interactive", "--skip-skills", ]); - expect(res.status).toBe(1); + expect(res.status).toBe(2); expect(res.stderr).toContain("Invalid --resolution"); expect(existsSync(target)).toBe(false); } finally { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index ebf1f641e6..72100465ae 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -3,9 +3,10 @@ // This branch only repointed the scaffolded npm scripts; the refactor is its // own task. // fallow-ignore-file complexity -import { failCommand, finishCommand } from "../utils/commandResult.js"; +import { failCommand, finishCommand, failUsage } from "../utils/commandResult.js"; import { writeNewFileSync } from "../utils/writeNewFile.js"; -import { defineCommand, runCommand } from "citty"; +import { runCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; import { patchMediaPlaceholders, type InitMediaOptions } from "./initMedia.js"; @@ -837,7 +838,7 @@ export default defineCommand({ `(or aliases 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square).`, ), ); - failCommand(); + failUsage(); } } diff --git a/packages/cli/src/commands/keyframes.ts b/packages/cli/src/commands/keyframes.ts index ae08e9184b..fbe3478523 100644 --- a/packages/cli/src/commands/keyframes.ts +++ b/packages/cli/src/commands/keyframes.ts @@ -1,4 +1,4 @@ -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import { existsSync, readFileSync } from "node:fs"; import { readProjectFile } from "@hyperframes/parsers/asset-resolution"; import { resolve, dirname, basename, join, relative, sep } from "node:path"; diff --git a/packages/cli/src/commands/lambda.ts b/packages/cli/src/commands/lambda.ts index 004d57ed03..7cfed00481 100644 --- a/packages/cli/src/commands/lambda.ts +++ b/packages/cli/src/commands/lambda.ts @@ -1,4 +1,4 @@ -import { failCommand } from "../utils/commandResult.js"; +import { failCommand, failUsage } from "../utils/commandResult.js"; /** * `hyperframes lambda` — top-level dispatcher for AWS Lambda subcommands. * @@ -9,7 +9,7 @@ import { failCommand } from "../utils/commandResult.js"; * lives in `@hyperframes/aws-lambda/sdk`. */ -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { DistributedFormat } from "@hyperframes/aws-lambda/sdk"; import { type CanvasResolution, DISTRIBUTED_FPS, isDistributedFps } from "@hyperframes/core"; import { parseOutputResolutionFlag } from "../utils/parseOutputResolution.js"; @@ -296,7 +296,7 @@ export default defineCommand({ console.error( "[lambda sites create] usage: hyperframes lambda sites create ", ); - failCommand(); + failUsage(); } const { runSitesCreate } = await import("./lambda/sites.js"); await runSitesCreate({ @@ -313,7 +313,7 @@ export default defineCommand({ console.error( "[lambda render] usage: hyperframes lambda render --width --height ", ); - failCommand(); + failUsage(); } const width = parsePositiveInt(args.width, "--width"); const height = parsePositiveInt(args.height, "--height"); @@ -363,14 +363,14 @@ export default defineCommand({ console.error( "[lambda render-batch] usage: hyperframes lambda render-batch --batch --width --height ", ); - failCommand(); + failUsage(); } const batch = args.batch as string | undefined; if (!batch) { console.error( "[lambda render-batch] --batch is required. Each line is a JSON object with at least { outputKey: '...' }.", ); - failCommand(); + failUsage(); } const width = parsePositiveInt(args.width, "--width"); const height = parsePositiveInt(args.height, "--height"); @@ -417,7 +417,7 @@ export default defineCommand({ console.error( "[lambda progress] usage: hyperframes lambda progress ", ); - failCommand(); + failUsage(); } const { runProgress } = await import("./lambda/progress.js"); await runProgress({ target, stackName, json: Boolean(args.json) }); @@ -434,7 +434,7 @@ export default defineCommand({ console.error( `[lambda policies] usage: hyperframes lambda policies [args]`, ); - failCommand(); + failUsage(); } const { runPolicies } = await import("./lambda/policies.js"); await runPolicies({ diff --git a/packages/cli/src/commands/lambda/policies.ts b/packages/cli/src/commands/lambda/policies.ts index 863c9f6fc7..83d2aa95d8 100644 --- a/packages/cli/src/commands/lambda/policies.ts +++ b/packages/cli/src/commands/lambda/policies.ts @@ -1,4 +1,4 @@ -import { setCommandExitCode } from "../../utils/commandResult.js"; +import { CliUsageError, failUsage, setCommandExitCode } from "../../utils/commandResult.js"; /** * `hyperframes lambda policies role|user|validate` — IAM bootstrap. * @@ -265,10 +265,9 @@ export async function runPolicies(args: PoliciesArgs): Promise { "[lambda policies validate] usage: hyperframes lambda policies validate "; if (args.json) { console.log(JSON.stringify({ ok: false, error: msg }, null, 2)); - setCommandExitCode(1); - return; + failUsage(); } - throw new Error(msg); + throw new CliUsageError(msg); } let result: ValidateResult; try { diff --git a/packages/cli/src/commands/layout.ts b/packages/cli/src/commands/layout.ts index 0376966065..1d5a167e26 100644 --- a/packages/cli/src/commands/layout.ts +++ b/packages/cli/src/commands/layout.ts @@ -1,6 +1,6 @@ import { launchManagedBrowser, resolveManagedGpuMode } from "../browser/launch.js"; import { failCommand, setCommandExitCode } from "../utils/commandResult.js"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 5a009e4854..b317917cc5 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -1,5 +1,5 @@ import { setCommandExitCode } from "../utils/commandResult.js"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; import { c } from "../ui/colors.js"; diff --git a/packages/cli/src/commands/media-treatment.ts b/packages/cli/src/commands/media-treatment.ts index 79e6fa9a16..1fe3a861f2 100644 --- a/packages/cli/src/commands/media-treatment.ts +++ b/packages/cli/src/commands/media-treatment.ts @@ -24,7 +24,7 @@ import { parseSourceDocument as parseMutableSource, } from "@hyperframes/studio-server/source-mutation"; import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import { lintHyperframeHtml } from "@hyperframes/lint"; import { parseHTML } from "linkedom"; import type { Example } from "./_examples.js"; diff --git a/packages/cli/src/commands/media-use.ts b/packages/cli/src/commands/media-use.ts index 0c222ee341..121dcc3bb3 100644 --- a/packages/cli/src/commands/media-use.ts +++ b/packages/cli/src/commands/media-use.ts @@ -1,4 +1,4 @@ -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; diff --git a/packages/cli/src/commands/models.ts b/packages/cli/src/commands/models.ts index 8a7a88fb5e..2ea79fcb53 100644 --- a/packages/cli/src/commands/models.ts +++ b/packages/cli/src/commands/models.ts @@ -1,4 +1,4 @@ -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import * as clack from "@clack/prompts"; import type { Example } from "./_examples.js"; import { c } from "../ui/colors.js"; diff --git a/packages/cli/src/commands/normalize-audio.ts b/packages/cli/src/commands/normalize-audio.ts index 1c70320107..746ff806ce 100644 --- a/packages/cli/src/commands/normalize-audio.ts +++ b/packages/cli/src/commands/normalize-audio.ts @@ -15,7 +15,7 @@ import { type LoudnessLimit, } from "@hyperframes/studio-server/loudness"; import { resolveAuthoredAssetFile } from "@hyperframes/parsers/asset-resolution"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import { c } from "../ui/colors.js"; import { projectCompositionFiles } from "../utils/mountedCompositions.js"; import { resolveProject } from "../utils/project.js"; diff --git a/packages/cli/src/commands/open.ts b/packages/cli/src/commands/open.ts index 71573fd409..3533c0aa8b 100644 --- a/packages/cli/src/commands/open.ts +++ b/packages/cli/src/commands/open.ts @@ -1,4 +1,4 @@ -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; import { c } from "../ui/colors.js"; import { setCommandExitCode } from "../utils/commandResult.js"; diff --git a/packages/cli/src/commands/play.ts b/packages/cli/src/commands/play.ts index 72a654c472..0daf00e401 100644 --- a/packages/cli/src/commands/play.ts +++ b/packages/cli/src/commands/play.ts @@ -1,5 +1,5 @@ import { setCommandExitCode } from "../utils/commandResult.js"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; import { existsSync, readFileSync } from "node:fs"; diff --git a/packages/cli/src/commands/present.ts b/packages/cli/src/commands/present.ts index 32b628900e..9f0172f45b 100644 --- a/packages/cli/src/commands/present.ts +++ b/packages/cli/src/commands/present.ts @@ -1,5 +1,5 @@ import { setCommandExitCode } from "../utils/commandResult.js"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; import { existsSync, readFileSync } from "node:fs"; diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 6ca95326ef..cbf06e0212 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -1,6 +1,6 @@ import { setCommandExitCode, requestCliExit } from "../utils/commandResult.js"; // fallow-ignore-file code-duplication -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; import { spawn, type ChildProcessByStdio } from "node:child_process"; import type { Readable } from "node:stream"; diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index f5a8a027c2..fb79c5da1c 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -1,7 +1,7 @@ import { join, posix, relative, resolve } from "node:path"; import { failCommand, setCommandExitCode } from "../utils/commandResult.js"; import { existsSync } from "node:fs"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import * as clack from "@clack/prompts"; import type { Example } from "./_examples.js"; diff --git a/packages/cli/src/commands/remove-background.ts b/packages/cli/src/commands/remove-background.ts index 7d0d5fd50f..f56ead42f5 100644 --- a/packages/cli/src/commands/remove-background.ts +++ b/packages/cli/src/commands/remove-background.ts @@ -1,5 +1,5 @@ -import { failCommand } from "../utils/commandResult.js"; -import { defineCommand } from "citty"; +import { failCommand, failUsage } from "../utils/commandResult.js"; +import { defineCommand } from "../utils/defineCommand.js"; import { existsSync } from "node:fs"; import * as clack from "@clack/prompts"; import { c } from "../ui/colors.js"; @@ -102,23 +102,23 @@ export default defineCommand({ "Input file is required. Run `hyperframes remove-background --info` for providers.", ), ); - failCommand(); + failUsage(); } if (!args.output) { console.error(c.error("--output (-o) is required. Use a .webm, .mov, or .png path.")); - failCommand(); + failUsage(); } if (!isDevice(args.device)) { console.error( c.error(`Invalid --device '${String(args.device)}'. Use: ${DEVICES.join(", ")}.`), ); - failCommand(); + failUsage(); } if (!isQuality(args.quality)) { console.error( c.error(`Invalid --quality '${String(args.quality)}'. Use: ${QUALITIES.join(", ")}.`), ); - failCommand(); + failUsage(); } const spin = args.json ? null : clack.spinner(); diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 6064bae9eb..728bfaf861 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -1,7 +1,7 @@ import { captureArtifactRevision } from "../utils/artifactCapture.js"; import { recordRenderSuccess, registerRenderOutput } from "@hyperframes/studio-server/history"; import { failCommand, requestCliExit, scheduleCliExit } from "../utils/commandResult.js"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; import { mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs"; import { createRenderPlan, resolveBrowserGpuForCli, type RenderFormat } from "./render/plan.js"; diff --git a/packages/cli/src/commands/skills.ts b/packages/cli/src/commands/skills.ts index f8d744840d..7960bef65f 100644 --- a/packages/cli/src/commands/skills.ts +++ b/packages/cli/src/commands/skills.ts @@ -1,5 +1,5 @@ import { setCommandExitCode, CliResultSignal } from "../utils/commandResult.js"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import { execFileSync, spawn } from "node:child_process"; import * as clack from "@clack/prompts"; import { c } from "../ui/colors.js"; diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 4719d8a9f9..80789c4bc0 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -31,7 +31,7 @@ import { pngDimensions } from "../utils/pngDimensions.js"; import { runWithConcurrencyLimit } from "../utils/concurrency.js"; import { CliRuntimeError, failCommand } from "../utils/commandResult.js"; // fallow-ignore-file complexity -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import { existsSync, mkdtempSync, diff --git a/packages/cli/src/commands/telemetry.ts b/packages/cli/src/commands/telemetry.ts index 0b24e5ec14..6b9e17e8cc 100644 --- a/packages/cli/src/commands/telemetry.ts +++ b/packages/cli/src/commands/telemetry.ts @@ -1,4 +1,4 @@ -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import { writeConfigWithResult, readConfigFresh, diff --git a/packages/cli/src/commands/timeline.ts b/packages/cli/src/commands/timeline.ts index 80bf990868..abe323be26 100644 --- a/packages/cli/src/commands/timeline.ts +++ b/packages/cli/src/commands/timeline.ts @@ -1,4 +1,5 @@ -import { defineCommand, type ArgDef } from "citty"; +import { type ArgDef } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; import { describeProject } from "../timeline/describeProject.js"; import { formatTimeline } from "../timeline/formatTimeline.js"; diff --git a/packages/cli/src/commands/transcribe.ts b/packages/cli/src/commands/transcribe.ts index d0f14f60b9..51989ddf93 100644 --- a/packages/cli/src/commands/transcribe.ts +++ b/packages/cli/src/commands/transcribe.ts @@ -1,6 +1,6 @@ import { createProgressWriter } from "../whisper/progress.js"; import { failCommand, setCommandExitCode } from "../utils/commandResult.js"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; import * as clack from "@clack/prompts"; import { c } from "../ui/colors.js"; diff --git a/packages/cli/src/commands/tts.ts b/packages/cli/src/commands/tts.ts index 14b6b9f370..0f37d7dd4f 100644 --- a/packages/cli/src/commands/tts.ts +++ b/packages/cli/src/commands/tts.ts @@ -1,7 +1,7 @@ import { failCommand, failUsage } from "../utils/commandResult.js"; import { isTextFile } from "../utils/textFile.js"; // fallow-ignore-file code-duplication -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; import { readFileSync } from "node:fs"; @@ -134,7 +134,7 @@ export default defineCommand({ const requested = String(args.lang).toLowerCase(); if (!isSupportedLang(requested)) { errorBox("Invalid --lang", `Got "${args.lang}". Must be one of: ${langList}.`); - failCommand(); + failUsage(); } lang = requested; } diff --git a/packages/cli/src/commands/upgrade.ts b/packages/cli/src/commands/upgrade.ts index d38a993029..9546f251c2 100644 --- a/packages/cli/src/commands/upgrade.ts +++ b/packages/cli/src/commands/upgrade.ts @@ -1,5 +1,5 @@ import { setCommandExitCode } from "../utils/commandResult.js"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; import * as clack from "@clack/prompts"; import { execFileSync } from "node:child_process"; diff --git a/packages/cli/src/commands/usage.ts b/packages/cli/src/commands/usage.ts index 21edc89387..b8239f91ec 100644 --- a/packages/cli/src/commands/usage.ts +++ b/packages/cli/src/commands/usage.ts @@ -1,4 +1,4 @@ -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import { readHarnessUsage } from "../utils/harnessUsage.js"; export default defineCommand({ diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 899b52dbda..97964125d5 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -5,7 +5,7 @@ import { launchManagedBrowser, resolveManagedGpuMode } from "../browser/launch.j // window drifting as the file is edited, hence the file-level suppression. // fallow-ignore-file code-duplication import { failCommand, setCommandExitCode } from "../utils/commandResult.js"; -import { defineCommand } from "citty"; +import { defineCommand } from "../utils/defineCommand.js"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; diff --git a/packages/cli/src/telemetry/agent_runtime.test.ts b/packages/cli/src/telemetry/agent_runtime.test.ts index 6ee9ed9cda..29f130a51c 100644 --- a/packages/cli/src/telemetry/agent_runtime.test.ts +++ b/packages/cli/src/telemetry/agent_runtime.test.ts @@ -612,3 +612,19 @@ describe("detectSandboxRuntime — kernel-string path", () => { expect(detectSandboxRuntime()).not.toBe("gvisor"); }); }); + +describe("isAgentDriven", () => { + it.each([ + [{ CLAUDECODE: "1" }, true], + [{ CODEX_THREAD_ID: "t" }, true], + [{ CURSOR_AGENT: "1", TERM_PROGRAM: "cursor" }, true], + [{ TERM_PROGRAM: "cursor" }, false], + [{ TERM_PROGRAM: "windsurf" }, false], + [{ REPL_ID: "r" }, false], + [{ REPLIT_USER: "u" }, false], + [{}, false], + ])("%j -> %s", async (env, expected) => { + const { isAgentDriven } = await import("./agent_runtime.js"); + expect(isAgentDriven(env)).toBe(expected); + }); +}); diff --git a/packages/cli/src/telemetry/agent_runtime.ts b/packages/cli/src/telemetry/agent_runtime.ts index ddb1858877..24ff50aeed 100644 --- a/packages/cli/src/telemetry/agent_runtime.ts +++ b/packages/cli/src/telemetry/agent_runtime.ts @@ -42,6 +42,8 @@ interface VendorRule { name: Exclude; /** Check returns true when the named agent is driving the CLI. */ check: (env: NodeJS.ProcessEnv) => boolean; + /** Narrower than `check` when `check` also matches a person in that editor's terminal or host. */ + agentOnly?: (env: NodeJS.ProcessEnv) => boolean; } // Ordering matters: the FIRST rule that matches wins. Put more specific rules @@ -93,6 +95,7 @@ const VENDOR_RULES: VendorRule[] = [ { name: "cursor", check: (env) => Boolean(env["CURSOR_AGENT"]) || env["TERM_PROGRAM"] === "cursor", + agentOnly: (env) => Boolean(env["CURSOR_AGENT"]), }, // Windsurf (Codeium) integrated terminal — exports TERM_PROGRAM=windsurf. // Attested across many independent detectors (nx @@ -105,6 +108,7 @@ const VENDOR_RULES: VendorRule[] = [ { name: "windsurf", check: (env) => env["TERM_PROGRAM"]?.toLowerCase() === "windsurf", + agentOnly: () => false, }, // GitHub Copilot Coding Agent — runs inside GitHub Actions and the // workflow injects an additional marker to distinguish from generic CI. @@ -123,6 +127,7 @@ const VENDOR_RULES: VendorRule[] = [ { name: "replit", check: (env) => typeof env["REPL_ID"] === "string" || typeof env["REPLIT_USER"] === "string", + agentOnly: () => false, }, // Nous Research Hermes Agent — cli.py:50 unconditionally executes // os.environ["HERMES_QUIET"] = "1" @@ -253,6 +258,12 @@ export function detectAgentRuntime(): AgentRuntime { return null; } +/** An agent is running this shell, not a person typing in an editor's terminal or a hosted workspace. */ +export function isAgentDriven(env: NodeJS.ProcessEnv = process.env): boolean { + if (isGeminiManagedAgent()) return true; + return VENDOR_RULES.some((rule) => (rule.agentOnly ?? rule.check)(env)); +} + /** Observed harness marker, not proof of a vendor, benchmark, or human user. */ export function detectExecutionHarnessHint(): "harbor" | null { // Read presence only. Preserve this context even when the child agent is known. diff --git a/packages/cli/src/ui/colors.ts b/packages/cli/src/ui/colors.ts index 719d25ab2f..135f2edf3f 100644 --- a/packages/cli/src/ui/colors.ts +++ b/packages/cli/src/ui/colors.ts @@ -1,7 +1,11 @@ import pc from "picocolors"; +import { isUnattended } from "../utils/attendedTerminal.js"; const isColorSupported = - process.stdout.isTTY === true && !process.env["NO_COLOR"] && process.env["FORCE_COLOR"] !== "0"; + process.stdout.isTTY === true && + !process.env["NO_COLOR"] && + process.env["FORCE_COLOR"] !== "0" && + !isUnattended(); function wrap(fn: (s: string) => string): (s: string) => string { return isColorSupported ? fn : (s: string) => s; diff --git a/packages/cli/src/utils/attendedTerminal.test.ts b/packages/cli/src/utils/attendedTerminal.test.ts new file mode 100644 index 0000000000..9da0c664d7 --- /dev/null +++ b/packages/cli/src/utils/attendedTerminal.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// `driven: undefined` runs the real detection against process.env. +const agent = vi.hoisted(() => ({ driven: false as boolean | undefined })); +vi.mock("../telemetry/agent_runtime.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, isAgentDriven: () => agent.driven ?? actual.isAgentDriven() }; +}); + +const ttys = { stdin: process.stdin.isTTY, stdout: process.stdout.isTTY }; + +function setTty(stdin: boolean, stdout: boolean): void { + Object.defineProperty(process.stdin, "isTTY", { value: stdin, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: stdout, configurable: true }); +} + +async function load() { + vi.resetModules(); + return import("./attendedTerminal.js"); +} + +describe("isCI", () => { + afterEach(() => vi.unstubAllEnvs()); + + it.each([ + ["true", true], + ["1", true], + ["github-actions", true], + ["false", false], + ["0", false], + ["", false], + ])("CI=%j is CI: %s", async (value, expected) => { + vi.stubEnv("CI", value); + expect((await load()).isCI()).toBe(expected); + }); +}); + +describe("isUnattended", () => { + beforeEach(() => { + vi.stubEnv("CI", ""); + vi.stubEnv("HYPERFRAMES_AGENT", ""); + agent.driven = false; + setTty(true, true); + }); + afterEach(() => { + vi.unstubAllEnvs(); + setTty(ttys.stdin === true, ttys.stdout === true); + }); + + it("is attended at a terminal with no CI and no agent", async () => { + expect((await load()).isUnattended()).toBe(false); + }); + + it.each([ + [false, true], + [true, false], + ])("is unattended when stdin is a TTY: %s, stdout is a TTY: %s", async (stdin, stdout) => { + setTty(stdin, stdout); + expect((await load()).isUnattended()).toBe(true); + }); + + it("is unattended in CI", async () => { + vi.stubEnv("CI", "buildkite"); + expect((await load()).isUnattended()).toBe(true); + }); + + it("is unattended when a coding agent is detected", async () => { + agent.driven = true; + expect((await load()).isUnattended()).toBe(true); + }); + + it.each([ + ["TERM_PROGRAM", "cursor"], + ["TERM_PROGRAM", "windsurf"], + ["REPL_ID", "abc"], + ])( + "a person in an editor or hosted terminal (%s=%s) at a TTY is attended", + async (name, value) => { + for (const key of Object.keys(process.env)) { + if ( + /^(CLAUDE|CODEX_|CURSOR_|BOBSHELL|GEMINI_CLI|CLINE_|HERMES_|PI_CODING|CRUSH|OPENCLAW)/.test( + key, + ) + ) { + vi.stubEnv(key, undefined); + } + } + vi.stubEnv(name, value); + agent.driven = undefined; + expect((await load()).isUnattended()).toBe(false); + }, + ); + + it("Cursor's agent shell is unattended", async () => { + vi.stubEnv("CURSOR_AGENT", "1"); + agent.driven = undefined; + expect((await load()).isUnattended()).toBe(true); + }); + + it("is unattended with HYPERFRAMES_AGENT=1", async () => { + vi.stubEnv("HYPERFRAMES_AGENT", "1"); + expect((await load()).isUnattended()).toBe(true); + }); +}); diff --git a/packages/cli/src/utils/attendedTerminal.ts b/packages/cli/src/utils/attendedTerminal.ts index f2aa9f568f..b219f61553 100644 --- a/packages/cli/src/utils/attendedTerminal.ts +++ b/packages/cli/src/utils/attendedTerminal.ts @@ -1,9 +1,25 @@ +import { isAgentDriven } from "../telemetry/agent_runtime.js"; + export function envFlagEnabled(name: string): boolean { const value = process.env[name]?.trim().toLowerCase(); return Boolean(value && value !== "0" && value !== "false" && value !== "no"); } +export function isCI(): boolean { + const value = process.env["CI"]?.trim().toLowerCase(); + return Boolean(value) && value !== "0" && value !== "false"; +} + /** A person can answer here: stdin and stdout are both terminals and this is not CI. */ export function isAttendedTerminal(): boolean { - return !envFlagEnabled("CI") && process.stdin.isTTY === true && process.stdout.isTTY === true; + return !isCI() && process.stdin.isTTY === true && process.stdout.isTTY === true; +} + +let agentDetected: boolean | undefined; + +/** Nobody is at the keyboard: no terminal, CI, a coding agent, or HYPERFRAMES_AGENT=1. Never prompt then. */ +export function isUnattended(): boolean { + if (!isAttendedTerminal() || process.env["HYPERFRAMES_AGENT"] === "1") return true; + agentDetected ??= isAgentDriven(); + return agentDetected; } diff --git a/packages/cli/src/utils/command-failure-tracking.ts b/packages/cli/src/utils/command-failure-tracking.ts index 6e7eada554..6c10f775cd 100644 --- a/packages/cli/src/utils/command-failure-tracking.ts +++ b/packages/cli/src/utils/command-failure-tracking.ts @@ -88,7 +88,7 @@ function guardLeafFlags( ctx: CommandContext, rawArgs: string[], ): void { - assertKnownFlags(cmd, rawArgs); + assertKnownFlags(cmd, rawArgs, path); const guarded = guardSwallowedFlagValues(cmd, path, rawArgs); if (!guarded.rewritten) return; ctx.rawArgs = guarded.rawArgs; diff --git a/packages/cli/src/utils/commandResult.ts b/packages/cli/src/utils/commandResult.ts index 84d685e8b9..e38ccb23d3 100644 --- a/packages/cli/src/utils/commandResult.ts +++ b/packages/cli/src/utils/commandResult.ts @@ -12,17 +12,21 @@ let pendingResult: CommandResult = SUCCESS_RESULT; let rootExitRequester: ((exitCode: number) => void) | undefined; let rootExitCodeSanitizer: (() => void) | undefined; +export const USAGE_EXIT_CODE = 2; + export class CliUsageError extends Error { readonly result: CommandResult; + readonly hint?: string; constructor( message = "Invalid command usage", - options: { exitCode?: number; presented?: boolean } = {}, + options: { exitCode?: number; presented?: boolean; hint?: string } = {}, ) { super(message); this.name = "CliUsageError"; + this.hint = options.hint; this.result = { - exitCode: options.exitCode ?? 1, + exitCode: options.exitCode ?? USAGE_EXIT_CODE, kind: "usage_error", presented: options.presented, }; @@ -61,7 +65,7 @@ export function failCommand(exitCode = 1, cause?: unknown): never { throw new CliRuntimeError("Command failed", { exitCode, presented: true, cause }); } -export function failUsage(exitCode = 1): never { +export function failUsage(exitCode = USAGE_EXIT_CODE): never { throw new CliUsageError("Invalid command usage", { exitCode, presented: true }); } diff --git a/packages/cli/src/utils/defineCommand.ts b/packages/cli/src/utils/defineCommand.ts new file mode 100644 index 0000000000..dac6f802d5 --- /dev/null +++ b/packages/cli/src/utils/defineCommand.ts @@ -0,0 +1,18 @@ +import { defineCommand as defineCittyCommand } from "citty"; +import type { ArgsDef, CommandDef } from "citty"; + +const definedCommands = new WeakSet(); + +// The one way to define a command: cli.ts wraps what it returns with the shared guards, +// and the agent-mode contract test rejects any registered command not built here. +export function defineCommand( + def: CommandDef, +): CommandDef { + const command = defineCittyCommand(def); + definedCommands.add(command); + return command; +} + +export function isDefinedCommand(command: unknown): boolean { + return typeof command === "object" && command !== null && definedCommands.has(command); +} diff --git a/packages/cli/src/utils/reject-unknown-flags.test.ts b/packages/cli/src/utils/reject-unknown-flags.test.ts index 16f061caad..acd8636799 100644 --- a/packages/cli/src/utils/reject-unknown-flags.test.ts +++ b/packages/cli/src/utils/reject-unknown-flags.test.ts @@ -272,7 +272,7 @@ describe("guardSwallowedFlagValues end-to-end (via citty's real runCommand + the it("leaves stdout genuinely empty on the real built CLI, not just presented:true internally", () => { const res = runCli(["catalog", "--query", "--json"]); - expect(res.status).toBe(1); + expect(res.status).toBe(2); expect(res.stderr).toContain("Missing value for --query"); expect(res.stdout).toBe(""); }); diff --git a/packages/cli/src/utils/reject-unknown-flags.ts b/packages/cli/src/utils/reject-unknown-flags.ts index 1f533292b5..df7eb86724 100644 --- a/packages/cli/src/utils/reject-unknown-flags.ts +++ b/packages/cli/src/utils/reject-unknown-flags.ts @@ -70,7 +70,7 @@ function unknownFlagIn(tok: string, known: Set): string | null { * + the global set). Only dash-prefixed tokens are inspected, so positionals and * flag values pass through untouched. Stops at `--`. */ -export function assertKnownFlags(cmd: CommandDef, rawArgs: string[]): void { +export function assertKnownFlags(cmd: CommandDef, rawArgs: string[], path = ""): void { if (!Array.isArray(rawArgs)) return; // citty types `args` as Resolvable (it may be a fn/promise); every // hyperframes command uses a static object, so treat anything else as "no @@ -81,7 +81,10 @@ export function assertKnownFlags(cmd: CommandDef, rawArgs: string[]): v for (const tok of rawArgs) { if (tok === "--") break; const bad = unknownFlagIn(tok, known); - if (bad) throw new Error(`Unknown flag: ${bad}`); + if (bad) { + const hint = `Run 'hyperframes ${path ? `${path} ` : ""}--help' to see its flags.`; + throw new CliUsageError(`Unknown flag: ${bad}`, { hint }); + } } }