mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
* 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>
154 lines
4.5 KiB
TypeScript
154 lines
4.5 KiB
TypeScript
export type CommandResultKind = "success" | "usage_error" | "runtime_error";
|
|
|
|
export interface CommandResult {
|
|
exitCode: number;
|
|
kind: CommandResultKind;
|
|
/** The command already emitted its structured/human output. */
|
|
presented?: boolean;
|
|
}
|
|
|
|
const SUCCESS_RESULT: CommandResult = { exitCode: 0, kind: "success" };
|
|
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; hint?: string } = {},
|
|
) {
|
|
super(message);
|
|
this.name = "CliUsageError";
|
|
this.hint = options.hint;
|
|
this.result = {
|
|
exitCode: options.exitCode ?? USAGE_EXIT_CODE,
|
|
kind: "usage_error",
|
|
presented: options.presented,
|
|
};
|
|
}
|
|
}
|
|
|
|
export class CliRuntimeError extends Error {
|
|
readonly result: CommandResult;
|
|
|
|
constructor(
|
|
message = "Command failed",
|
|
options: { exitCode?: number; presented?: boolean; cause?: unknown } = {},
|
|
) {
|
|
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
this.name = "CliRuntimeError";
|
|
this.result = {
|
|
exitCode: options.exitCode ?? 1,
|
|
kind: "runtime_error",
|
|
presented: options.presented,
|
|
};
|
|
}
|
|
}
|
|
|
|
/** Internal control-flow signal for an early, already-presented result. */
|
|
export class CliResultSignal extends Error {
|
|
readonly result: CommandResult;
|
|
|
|
constructor(result: CommandResult) {
|
|
super(`CLI result ${result.exitCode}`);
|
|
this.name = "CliResultSignal";
|
|
this.result = result;
|
|
}
|
|
}
|
|
|
|
export function failCommand(exitCode = 1, cause?: unknown): never {
|
|
throw new CliRuntimeError("Command failed", { exitCode, presented: true, cause });
|
|
}
|
|
|
|
export function failUsage(exitCode = USAGE_EXIT_CODE): never {
|
|
throw new CliUsageError("Invalid command usage", { exitCode, presented: true });
|
|
}
|
|
|
|
export function finishCommand(exitCode = 0): never {
|
|
throw new CliResultSignal({
|
|
exitCode,
|
|
kind: exitCode === 0 ? "success" : "runtime_error",
|
|
presented: true,
|
|
});
|
|
}
|
|
|
|
/** Record a non-fatal result while allowing output/finalizers to complete. */
|
|
export function setCommandResult(result: CommandResult): void {
|
|
if (pendingResult.exitCode !== 0 && result.exitCode === 0) return;
|
|
pendingResult = result;
|
|
}
|
|
|
|
export function setCommandExitCode(exitCode: number): void {
|
|
setCommandResult({
|
|
exitCode,
|
|
kind: exitCode === 0 ? "success" : "runtime_error",
|
|
presented: true,
|
|
});
|
|
}
|
|
|
|
export function consumeCommandResult(): CommandResult {
|
|
const result = pendingResult;
|
|
pendingResult = SUCCESS_RESULT;
|
|
return result;
|
|
}
|
|
|
|
/** Called only by cli.ts to retain ownership of forced process termination. */
|
|
export function registerRootExitRequester(requester: (exitCode: number) => void): void {
|
|
rootExitRequester = requester;
|
|
}
|
|
|
|
/** Called only by cli.ts to retain ownership of process exit-code mutation. */
|
|
export function registerRootExitCodeSanitizer(sanitizer: () => void): void {
|
|
rootExitCodeSanitizer = sanitizer;
|
|
}
|
|
|
|
/** Ask cli.ts to clear stray process exit state after a successful render. */
|
|
export function sanitizeSuccessfulExitCode(): void {
|
|
rootExitCodeSanitizer?.();
|
|
}
|
|
|
|
export async function outputWritten(): Promise<void> {
|
|
const written = (stream: NodeJS.WriteStream) =>
|
|
new Promise<void>((resolve) => stream.write("", () => resolve()));
|
|
await Promise.all([written(process.stdout), written(process.stderr)]);
|
|
}
|
|
|
|
/** Ask cli.ts to finalize telemetry/output and then terminate the process. */
|
|
export function requestCliExit(exitCode = 0): void {
|
|
if (!rootExitRequester) {
|
|
setCommandResult({
|
|
exitCode,
|
|
kind: exitCode === 0 ? "success" : "runtime_error",
|
|
presented: true,
|
|
});
|
|
return;
|
|
}
|
|
rootExitRequester(exitCode);
|
|
}
|
|
|
|
type UnrefableTimer = {
|
|
unref: () => void;
|
|
};
|
|
|
|
function isUnrefableTimer(
|
|
timer: ReturnType<typeof setTimeout>,
|
|
): timer is ReturnType<typeof setTimeout> & UnrefableTimer {
|
|
return (
|
|
typeof timer === "object" &&
|
|
timer !== null &&
|
|
"unref" in timer &&
|
|
typeof timer.unref === "function"
|
|
);
|
|
}
|
|
|
|
/** For a command whose result is out: ends the process if anything it started is still holding it. */
|
|
export function scheduleCliExit(exitCode = 0): void {
|
|
const timer = setTimeout(() => requestCliExit(exitCode), 100);
|
|
if (isUnrefableTimer(timer)) timer.unref();
|
|
}
|