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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-10-28 06:28:44 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 815ff9ec8d
commit 22e6da0233
77 changed files with 486 additions and 110 deletions
+18
View File
@@ -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."
}
]
}
]
}
}
]
}
+4
View File
@@ -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
+155
View File
@@ -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> | (() => CommandDef | Promise<CommandDef>);
async function resolveCommand(entry: Resolvable): Promise<CommandDef> {
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<void> {
out.push({ path, command });
const subCommands = (await resolveCommand(
(command.subCommands ?? {}) as Resolvable,
)) as unknown as Record<string, Resolvable>;
for (const [name, sub] of Object.entries(subCommands)) {
await walk([...path, name], await resolveCommand(sub), out);
}
}
async function registeredCommands(): Promise<Registered[]> {
const out: Registered[] = [];
for (const { name, file, exportName } of registeredModules()) {
const mod = (await import(`./commands/${file}.ts`)) as Record<string, CommandDef>;
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<Run> {
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);
});
});
+18 -2
View File
@@ -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<void> {
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<void> {
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();
+1 -1
View File
@@ -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[] = [
+1 -1
View File
@@ -1,5 +1,5 @@
import { join, resolve } from "node:path";
import { defineCommand } from "citty";
import { defineCommand } from "../utils/defineCommand.js";
import type {
AnalysisQuality,
AudioCapability,
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+6 -2
View File
@@ -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();
});
+4 -4
View File
@@ -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 <path>, or --video <project> for video download.",
);
failCommand();
failUsage();
}
try {
new URL(url);
+1 -1
View File
@@ -1,4 +1,4 @@
import { defineCommand } from "citty";
import { defineCommand } from "../utils/defineCommand.js";
import type { Example } from "./_examples.js";
export const examples: Example[] = [
+1 -1
View File
@@ -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 {
+2 -1
View File
@@ -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";
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
@@ -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);
});
});
+4 -4
View File
@@ -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();
}
// ---------------------------------------------------------------------------
+9 -9
View File
@@ -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<string, unknown>): Promise<void> {
const project = args.project as string | undefined;
if (!project) {
console.error("[cloudrun deploy] --project <gcp-project-id> 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<string, unknown>): Promise<void> {
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<string, unknown>): Promise<void> {
const projectDir = args.extra as string | undefined;
if (!projectDir) {
console.error("[cloudrun sites create] usage: hyperframes cloudrun sites create <projectDir>");
failCommand();
failUsage();
}
const state = readState(args);
const { deploySite } = await loadCloudRunAdapter();
@@ -538,7 +538,7 @@ async function runRender(args: Record<string, unknown>): Promise<void> {
console.error(
"[cloudrun render] usage: hyperframes cloudrun render <projectDir> --width <px> --height <px>",
);
failCommand();
failUsage();
}
const width = parsePositiveInt(args.width, "--width");
const height = parsePositiveInt(args.height, "--height");
@@ -604,7 +604,7 @@ async function runProgress(args: Record<string, unknown>): Promise<void> {
const executionName = args.target as string | undefined;
if (!executionName) {
console.error("[cloudrun progress] usage: hyperframes cloudrun progress <executionName>");
failCommand();
failUsage();
}
const { getRenderProgress } = await loadCloudRunAdapter();
const progress = await getRenderProgress({ executionName });
@@ -644,7 +644,7 @@ async function runRenderBatch(args: Record<string, unknown>): Promise<void> {
console.error(
"[cloudrun render-batch] usage: hyperframes cloudrun render-batch <projectDir> --batch <file.jsonl> --width <px> --height <px>",
);
failCommand();
failUsage();
}
const width = parsePositiveInt(args.width, "--width");
const height = parsePositiveInt(args.height, "--height");
@@ -774,7 +774,7 @@ async function runDestroy(args: Record<string, unknown>): Promise<void> {
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",
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -6,7 +6,7 @@
* component-import time, Phase 3).
*/
import { defineCommand } from "citty";
import { defineCommand } from "../../utils/defineCommand.js";
import {
createFigmaClient,
FigmaClientError,
+1 -1
View File
@@ -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";
+2 -1
View File
@@ -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,
+1 -1
View File
@@ -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";
+2 -2
View File
@@ -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 {
+4 -3
View File
@@ -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();
}
}
+1 -1
View File
@@ -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";
+8 -8
View File
@@ -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 <projectDir>",
);
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 <projectDir> --width <px> --height <px>",
);
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 <projectDir> --batch <path.jsonl> --width <px> --height <px>",
);
failCommand();
failUsage();
}
const batch = args.batch as string | undefined;
if (!batch) {
console.error(
"[lambda render-batch] --batch <path.jsonl> 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 <renderId | executionArn>",
);
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 <role|user|validate> [args]`,
);
failCommand();
failUsage();
}
const { runPolicies } = await import("./lambda/policies.js");
await runPolicies({
+3 -4
View File
@@ -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<void> {
"[lambda policies validate] usage: hyperframes lambda policies validate <policy.json>";
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 {
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
@@ -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();
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -1,4 +1,4 @@
import { defineCommand } from "citty";
import { defineCommand } from "../utils/defineCommand.js";
import {
writeConfigWithResult,
readConfigFresh,
+2 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+2 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -1,4 +1,4 @@
import { defineCommand } from "citty";
import { defineCommand } from "../utils/defineCommand.js";
import { readHarnessUsage } from "../utils/harnessUsage.js";
export default defineCommand({
+1 -1
View File
@@ -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";
@@ -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);
});
});
@@ -42,6 +42,8 @@ interface VendorRule {
name: Exclude<AgentRuntime, null>;
/** 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.
+5 -1
View File
@@ -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;
@@ -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<typeof import("../telemetry/agent_runtime.js")>();
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);
});
});
+17 -1
View File
@@ -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;
}
@@ -88,7 +88,7 @@ function guardLeafFlags(
ctx: CommandContext<any>,
rawArgs: string[],
): void {
assertKnownFlags(cmd, rawArgs);
assertKnownFlags(cmd, rawArgs, path);
const guarded = guardSwallowedFlagValues(cmd, path, rawArgs);
if (!guarded.rewritten) return;
ctx.rawArgs = guarded.rawArgs;
+7 -3
View File
@@ -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 });
}
+18
View File
@@ -0,0 +1,18 @@
import { defineCommand as defineCittyCommand } from "citty";
import type { ArgsDef, CommandDef } from "citty";
const definedCommands = new WeakSet<object>();
// 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<const T extends ArgsDef = ArgsDef>(
def: CommandDef<T>,
): CommandDef<T> {
const command = defineCittyCommand(def);
definedCommands.add(command);
return command;
}
export function isDefinedCommand(command: unknown): boolean {
return typeof command === "object" && command !== null && definedCommands.has(command);
}
@@ -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("");
});
@@ -70,7 +70,7 @@ function unknownFlagIn(tok: string, known: Set<string>): 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<ArgsDef>, rawArgs: string[]): void {
export function assertKnownFlags(cmd: CommandDef<ArgsDef>, rawArgs: string[], path = ""): void {
if (!Array.isArray(rawArgs)) return;
// citty types `args` as Resolvable<ArgsDef> (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<ArgsDef>, 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 });
}
}
}