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 }); + } } }