Files
hyperframes/packages/cli/src/cli.ts
T
WaterrrForeverandClaude Opus 5.5 2daedcdafc feat(skills): style templates on the HyperFrames plan (#6842)
* fix(cli,skills): say a step's credits are its total, and keep cost out of retries

An out-of-credits refusal named what the step needs, and an agent read
that as the shortfall. It now says the number is the step's total.

After its last try, an agent told the person what was spent and what
another try would cost. The HeyGen reference now says not to.

* fix(cli): plan avatar credits from the HyperFrames price rows

An avatar's credits came from its old API price, about five times what
the plan charges, so --max-credits refused calls that fit and a top-up
asked for too much. They now use the Avatar IV photo row plus the
general avatar video row, 950 credits a minute.

* fix(cli): pin the avatar price test and keep pricing under the comment share

* fix(cli): plan avatar seconds on the Avatar IV row alone

The general avatar row is only checked at admission and billed on
seconds left after Avatar IV, so adding it to every second made a 15 s
script plan at 238 instead of 200 and a 220 limit refuse a call that fits.

* feat(skills): creator templates on the HyperFrames plan

A Pro account asks for a video in a creator's format and the agent pulls that
template on demand. The packs sit in the private bucket behind the plan-gated
bundle route, so a Free account gets a 402 and the upgrade step. A pull costs
no credits; making the film spends them as Reverse does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(skills): make creator-remix the one template skill

SKILL.md holds the catalog, the Pro openings, the pull and the credit
rules. references/remix.md is how to make the episode, from Desktop's
creator-remix with only what the app alone has swapped for the CLI.
The packs now hold only the template, so their hashes change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(skills): the creator-remix skill and its packs

SKILL.md holds the catalog, the Pro openings, the pull and the credit
rules. references/remix.md is how to make the episode, from Desktop's
creator-remix with only what the app alone has swapped for the CLI.
The packs now hold only the template, so their hashes change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(skills): word creator-remix's sign-in steps the way media-use does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(skills): a creator-remix presenter runs on the plan by default

The person's own HeyGen avatar is recommended, not required: without
one, 小Lin's presenter is made with HeyGen Video 1 from their photo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(cli): pin template packs whose voice bills the plan first

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): offload media named inside template tags

References in a composition's <template> were never rewritten, because the DOM
query stops at the tag and template.content does not serialize, so the file stayed
in the zip. A stem matched as a plain word and a same-named file elsewhere counted
as a mention too. A 5-minute presented film went from 196 MB to 71 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(cli): lipsync, and avatars that speak a finished voice

Video 1 takes audio as a reference and re-paces it, so a shot's lips follow its
own track and drift up to a second from the voice. generate --type lipsync redoes
the mouth to the exact voice. generate --type avatar takes --audio, and --avatar
for the person's own look, so the lips land on the voice from one job.

Also moves the plan rows to the 2026-10-30 price book, estimates a voice's credits
so --max-credits can hold it, logs jobs from a folder with no project, and describes
the resolve flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(skills): presenters go avatar first, and a voice can be stock

The person's own avatar, then Avatar IV from their photo, then Video 1 with
lipsync. With no clone or recording, a stock voice named in the credits. Pins the
five rebuilt packs. The archive test keeps its ../ under GNU tar with -P.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cli): fix avatar price and sfx provider tests, trim comments

A wav gives the avatar price test an exact length on every platform. The
sfx tests now expect HeyGen first. Comment share is back under the base.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(core): read a font shorthand whose size nests parentheses

`font: calc(var(--size) * 1px)/1 "X"` cut at the first closing parenthesis, so a
bad family name failed the render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): one ledger row per job, and search skips a template's own pictures

A job done at once logs one row, transcribe logs to the project, and resolve no
longer offers a component's file for a search. --reuse and --provider get help text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(skills): pin the tested template packs, and a board needs no voice

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cli): read the transcribe ledger row as JSON, for Windows paths

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(cli): pin the Big Questions pack without the film length check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(skills): call them style templates, and the docs example pulls a real slug

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(cli): pin the cleaned template packs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(skills): drop the stock-presenter ban and the no-spend-before-storyboard rule

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(cli): pin the Paper Trail and Long Story Short packs without the stock-presenter ban

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(skills): rename creator-remix to style-remix, and the five templates to names that say the look

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(skills): style-remix lists the five templates when none is named

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(cli): pin the Finance Explainer pack that varies its motion graphics

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(cli): media-use generate --type cutout takes a shot out of its background on the plan

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(cli): pin the Finance Explainer pack without the provisional variety rule

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(cli): pin the five style packs with variety rules measured on real films

Animated Science gets a worlds-and-boards budget, 3D True Crime a main board and a
brief corner note, 3D Brand caps by measured rate, Finance and Collage set pieces and
more than one look.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(skills): media-use names the on-plan cutout and lipsync commands, not the heygen CLI ones

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): cutout retries dedupe on request_id, uploads carry their real type; style-remix is public

The background-removal route dedupes on the body's request_id and ignores the Idempotency-Key
header, so a retry after a lost answer started a second job: runJob now also sends the key in the
body for such a route. Uploads typed every non-MP4 file as audio, so WebM and MOV inputs were
refused: they now take their type from the extension. Fails without the fix: the new cutout test
in generate.test.ts sees audio/mpeg for WebM and MOV.

style-remix drops metadata.internal and is listed with the other published skills.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(cli): keep the comment share of the touched files at main's

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: count style-remix in the README and CLAUDE.md skill lists

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(skills): rename style-remix to style-sampling

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:07:02 +00:00

654 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// ── EPIPE suppression (must run before ANY stdout/stderr write) ────────────
// When the CLI runs inside a piped agent environment (Claude Code, Codex,
// Cursor, etc.), the reader may close the pipe before we finish writing.
// Node treats EPIPE on stdout/stderr as an uncaughtException, which crashes
// the process. This is a normal lifecycle event — suppress it.
//
// commandFailed must be declared here (before the handlers) so the EPIPE
// stream-error path can set it before process.exit(0). The telemetry exit
// handler reads this flag to determine success/failure — an EPIPE that
// interrupts a command should NOT score as success:true, but one that
// arrives after the render artifact was validated is the normal agent-pipe
// teardown and must stay success:true (see handleStreamEpipe).
let commandFailed = false;
for (const stream of [process.stdout, process.stderr]) {
stream.on("error", (err) => {
if ((err as NodeJS.ErrnoException).code === "EPIPE") {
handleStreamEpipe();
}
});
}
// ── Worker entry path bootstrap (must run before any producer/engine load) ──
// The shaderTransitionWorkerPool lives in the producer package and resolves
// its worker entry by probing for a sibling `.js` file next to
// `import.meta.url`. When this CLI is bundled by tsup, the producer code is
// bundled into chunks beside cli.js, but `import.meta.url` resolves to the producer's
// own dist path (NOT cli.js) on some module-graph layouts — so the sibling
// probe lands in a directory that does not contain the bundled worker.
// We emit the worker entry next to cli.js (see tsup.config.ts) and tell
// the pool where to find it via the published env-var override.
import "./utils/hideChildConsoles.js";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync } from "node:fs";
(() => {
const here = dirname(fileURLToPath(import.meta.url));
const shader = join(here, "shaderTransitionWorker.js");
if (!process.env.HF_SHADER_WORKER_ENTRY && existsSync(shader)) {
process.env.HF_SHADER_WORKER_ENTRY = shader;
}
})();
// ── Fast-path exits ─────────────────────────────────────────────────────────
// Check --version before importing anything heavy. This makes
// `hyperframes --version` near-instant (~10ms vs ~80ms).
import { VERSION } from "./version.js";
let argv = process.argv.slice(2);
if (argv[0] === "help") {
process.argv = [...process.argv.slice(0, 2), ...argv.slice(1), "--help"];
argv = process.argv.slice(2);
}
const commandArg = argv[0];
const rootVersionRequested =
commandArg === "--version" ||
commandArg === "-V" ||
(commandArg === undefined && (argv.includes("--version") || argv.includes("-V")));
if (rootVersionRequested) {
console.log(VERSION);
process.exit(0);
}
// The working directory is project data: load only the settings a project `.env` may supply,
// before browser selection can observe the environment.
const { loadProjectDotEnv } = await import("./utils/dotEnv.js");
const captureEnv = argv[commandWordIndex(argv)] === "capture";
if (!captureEnv || process.env.HYPERFRAMES_NO_CAPTURE_DOTENV !== "1") {
loadProjectDotEnv(process.cwd(), process.env, (line) => console.error(line));
}
// ── Lazy imports ────────────────────────────────────────────────────────────
// Telemetry, update checks, and heavy modules are imported only when needed.
// For --help we skip telemetry entirely.
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";
import {
isRenderSucceeded,
isRunDelivered,
markCommandDelivered,
} from "./utils/render-success-state.js";
import { resolveCommandUsage, type ResolvedCommandUsage } from "./utils/commandUsageResolution.js";
import { closestMatch, fileHint, synonymMatch } from "./utils/didYouMean.js";
import { jsonErrorBody, watchOutput, writeJsonFailure } from "./utils/jsonError.js";
import { isDevMode } from "./utils/env.js";
import {
CliResultSignal,
CliRuntimeError,
CliUsageError,
USAGE_EXIT_CODE,
consumeCommandResult,
endProcess,
outputWritten,
registerRootExitCodeSanitizer,
registerRootExitRequester,
type CommandResult,
} from "./utils/commandResult.js";
import { registerRunningCli } from "./utils/runningCli.js";
import { commandWordIndex, openShortcutArgv } from "./utils/projectShortcut.js";
registerRunningCli();
const isHelp = process.argv.includes("--help") || process.argv.includes("-h");
// Runs before commands/preview.js is imported, so a missing or stale
// package is named here instead of crashing deep inside that import.
async function assertStudioWorkspaceBuilt(): Promise<void> {
if (!isDevMode()) return;
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const { checkStudioWorkspaceBuild, formatWorkspaceBuildProblems } =
await import("./utils/workspaceBuildCheck.js");
const problems = checkStudioWorkspaceBuild(repoRoot);
if (problems.length === 0) return;
console.error(formatWorkspaceBuildProblems(problems));
throw new CliRuntimeError("Studio workspace build check failed", {
exitCode: 1,
presented: true,
});
}
// ---------------------------------------------------------------------------
// CLI definition — all commands are lazy-loaded via dynamic import()
// ---------------------------------------------------------------------------
const commandLoaders = {
init: () => import("./commands/init.js").then((m) => m.default),
add: () => import("./commands/add.js").then((m) => m.default),
catalog: () => import("./commands/catalog.js").then((m) => m.default),
"media-use": () => import("./commands/media-use.js").then((m) => m.default),
play: () => import("./commands/play.js").then((m) => m.default),
present: () => import("./commands/present.js").then((m) => m.default),
preview: () =>
assertStudioWorkspaceBuilt().then(() => import("./commands/preview.js").then((m) => m.default)),
publish: () => import("./commands/publish.js").then((m) => m.default),
open: () => import("./commands/open.js").then((m) => m.default),
"catch-up": () => import("./commands/catch-up.js").then((m) => m.default),
render: () => import("./commands/render.js").then((m) => m.default),
lint: () => import("./commands/lint.js").then((m) => m.default),
check: () => import("./commands/check.js").then((m) => m.default),
beats: () => import("./commands/beats.js").then((m) => m.default),
audio: () => import("./commands/audio.js").then((m) => m.default),
"normalize-audio": () => import("./commands/normalize-audio.js").then((m) => m.default),
inspect: () => import("./commands/inspect.js").then((m) => m.default),
keyframes: () => import("./commands/keyframes.js").then((m) => m.default),
layout: () => import("./commands/layout.js").then((m) => m.default),
info: () => import("./commands/info.js").then((m) => m.default),
compositions: () => import("./commands/compositions.js").then((m) => m.default),
timeline: () => import("./commands/timeline.js").then((m) => m.default),
history: () => import("./commands/history.js").then((m) => m.default),
benchmark: () => import("./commands/benchmark.js").then((m) => m.default),
browser: () => import("./commands/browser.js").then((m) => m.default),
"remove-background": () => import("./commands/remove-background.js").then((m) => m.default),
transcribe: () => import("./commands/transcribe.js").then((m) => m.default),
usage: () => import("./commands/usage.js").then((m) => m.default),
models: () => import("./commands/models.js").then((m) => m.default),
tts: () => import("./commands/tts.js").then((m) => m.default),
docs: () => import("./commands/docs.js").then((m) => m.default),
doctor: () => import("./commands/doctor.js").then((m) => m.default),
clean: () => import("./commands/clean.js").then((m) => m.default),
upgrade: () => import("./commands/upgrade.js").then((m) => m.default),
skills: () => import("./commands/skills.js").then((m) => m.default),
feedback: () => import("./commands/feedback.js").then((m) => m.default),
telemetry: () => import("./commands/telemetry.js").then((m) => m.default),
events: () => import("./commands/events.js").then((m) => m.default),
validate: () => import("./commands/validate.js").then((m) => m.default),
snapshot: () => import("./commands/snapshot.js").then((m) => m.default),
"media-treatment": () =>
import("./commands/media-treatment.js").then((m) => m.mediaTreatmentCommand),
"grade-compare": () => import("./commands/grade-compare.js").then((m) => m.default),
compare: () => import("./commands/compare.js").then((m) => m.default),
diff: () => import("./commands/diff.js").then((m) => m.default),
measure: () => import("./commands/measure.js").then((m) => m.default),
reverse: () => import("./commands/reverse.js").then((m) => m.default),
billing: () => import("./commands/billing.js").then((m) => m.default),
templates: () => import("./commands/templates.js").then((m) => m.default),
capture: () => import("./commands/capture.js").then((m) => m.default),
lambda: () => import("./commands/lambda.js").then((m) => m.default),
cloudrun: () => import("./commands/cloudrun.js").then((m) => m.default),
cloud: () => import("./commands/cloud.js").then((m) => m.default),
auth: () => import("./commands/auth.js").then((m) => m.default),
figma: () => import("./commands/figma.js").then((m) => m.default),
};
// `hyperframes <dir>` is `hyperframes open <dir>`; every later read of the command sees `open`.
process.argv = [
...process.argv.slice(0, 2),
...openShortcutArgv(argv, new Set(Object.keys(commandLoaders))),
];
argv = process.argv.slice(2);
const subCommands = Object.fromEntries(
Object.entries(commandLoaders).map(([name, load]) => [name, trackCommandFailures(load)]),
);
const main = defineCommand({
meta: {
name: "hyperframes",
version: VERSION,
description: "Create and render HTML video compositions",
},
subCommands,
});
// ---------------------------------------------------------------------------
// Telemetry — lazy-loaded, captured references for exit handlers
// ---------------------------------------------------------------------------
const cliCommandArg = process.argv[2];
// Explicit annotation breaks a type cycle: `subCommands` references `command`
// (in the failure reporter) and `command` references `subCommands` (the `in`
// check), so its type can't be inferred from its own initializer.
const command: string = cliCommandArg && cliCommandArg in subCommands ? cliCommandArg : "unknown";
const hasJsonFlag = process.argv.includes("--json");
// Under --json a failure always answers on stdout with the shared envelope (see executeCli).
const jsonOutput = hasJsonFlag ? watchOutput() : undefined;
// Captured references — populated when the lazy imports resolve.
// Used in exit handlers where dynamic import() is unsafe (beforeExit loops,
// exit handler is synchronous-only).
let _flushSync: (() => void) | undefined;
let _trackCliError:
| ((props: {
error_name: string;
error_message: string;
stack_trace?: string;
command?: string;
kind: "uncaught_exception" | "unhandled_rejection" | "command_error";
}) => void)
| undefined;
let _trackCommandResult:
| ((props: {
command: string;
success: boolean;
exitCode: number;
durationMs: number;
runId?: string;
}) => void)
| undefined;
let _printUpdateNotice: (() => void) | undefined;
let _printStalePinNotice: (() => void) | undefined;
let _printSkillsUpdateNotice: (() => void) | undefined;
let telemetryReady: Promise<void> = Promise.resolve();
// `events` is a telemetry-internal beacon: it self-tracks + self-flushes, so it
// skips the per-command wrapper (no duplicate cli_command, no first-run notice
// printed into a skill's captured output).
if (
!isHelp &&
command !== "telemetry" &&
command !== "events" &&
command !== "usage" &&
command !== "unknown"
) {
telemetryReady = import("./telemetry/index.js").then((mod) => {
_flushSync = mod.flushSync;
_trackCliError = mod.trackCliError;
_trackCommandResult = mod.trackCommandResult;
mod.showTelemetryNotice();
mod.trackCommand(command, runId);
if (mod.shouldTrack()) mod.incrementCommandCount();
});
}
// `events` skips the update check too — a skill-usage beacon must not add
// network latency or trigger a background self-upgrade on the calling skill.
// `telemetry` skips it because update metadata must never race the command
// that changes the user's telemetry preference.
// `skills` is excluded from the SKILLS nudge for the same reason `upgrade` is
// excluded from the self-update notice: a command that is itself actively
// checking/reconciling skills (`skills check`, `skills update`) must not also
// tell the user to go run `skills update` — that's either redundant (it just
// did) or, worse, misleading (it printed a stale nudge count from the last
// cached check while reporting fresh results of its own).
if (
!isHelp &&
command !== "upgrade" &&
command !== "events" &&
command !== "telemetry" &&
command !== "skills" &&
command !== "usage"
) {
if (!hasJsonFlag) {
// Report any completed auto-install from the previous run first, before
// kicking off the next check — so the user sees "updated to vX" once and
// we don't over-print.
import("./utils/autoUpdate.js").then((mod) => mod.reportCompletedUpdate()).catch(() => {});
import("./utils/updateCheck.js")
.then(async (mod) => {
_printUpdateNotice = mod.printUpdateNotice;
_printStalePinNotice = mod.printStalePinNotice;
const result = mod.cachedUpdateCheck();
if (result.updateAvailable) {
const auto = await import("./utils/autoUpdate.js").catch(() => null);
auto?.scheduleBackgroundInstall(result.latest, result.current);
}
})
.catch(() => {});
}
// Skills freshness nudge: stderr only, so it also runs under --json, which agents pass.
import("./utils/skillsUpdateCheck.js")
.then((mod) => {
_printSkillsUpdateNotice = mod.printSkillsUpdateNotice;
})
.catch(() => {});
// The notices read the caches; a detached child refreshes them for the next run.
import("./utils/backgroundChecks.js").then((mod) => mod.launchBackgroundChecks()).catch(() => {});
}
// A command run on a project the desktop app chatted about since it was last caught up on ends by saying so.
let _appHistoryNotice: string | null = null;
if (!isHelp && !["unknown", "catch-up", "events", "telemetry"].includes(command)) {
import("./utils/appHistory.js")
.then((mod) => {
_appHistoryNotice = mod.appHistoryNotice(process.cwd(), process.argv.slice(3));
})
.catch(() => {});
}
const commandStart = Date.now();
const runId = getRunId();
let finalized = false;
// Root-only lifecycle fan-in: telemetry, notices, flushing, then exit code.
// fallow-ignore-next-line complexity
async function finalizeCli(result: CommandResult): Promise<void> {
if (finalized) return;
finalized = true;
// Once the artifact has validated and been committed to disk, the run
// delivered — anything recorded as a failure after that is teardown noise.
// The uncaughtException / unhandledRejection handlers already consult
// isRenderSucceeded(), but a post-render throw that the command wrapper
// CATCHES never reaches them: it becomes an ordinary non-zero
// CommandResult, and a valid render is reported as a failure. Sanitizing
// here, once, is what those handlers cannot cover, and it keeps the exit
// code and the telemetry record from disagreeing about the same run.
const exitCode = isRenderSucceeded() ? 0 : result.exitCode;
if (exitCode === 0) markCommandDelivered();
commandFailed ||= exitCode !== 0;
await telemetryReady.catch(() => {});
_trackCommandResult?.({
command,
success: exitCode === 0 && commandSucceededForTelemetry(),
exitCode,
durationMs: Date.now() - commandStart,
runId,
});
// No network wait: the exit handler's flushSync() delivers what is still queued.
if (result.kind !== "usage_error") printNotices();
process.exitCode = exitCode;
}
function printNotices(): void {
if (!hasJsonFlag) {
_printUpdateNotice?.();
_printStalePinNotice?.();
}
_printSkillsUpdateNotice?.();
if (_appHistoryNotice) process.stderr.write(`◇ ${_appHistoryNotice}\n`);
}
registerRootExitRequester((exitCode) => {
void finalizeCli({
exitCode,
kind: exitCode === 0 ? "success" : "runtime_error",
presented: true,
}).finally(async () => {
await outputWritten();
endProcess(exitCode, {
exit: (code) => process.exit(code),
setExitCode: (code) => {
process.exitCode = code;
},
later: setTimeout,
});
});
});
registerRootExitCodeSanitizer(() => {
if (process.exitCode !== undefined && process.exitCode !== 0) {
process.exitCode = 0;
}
});
// Sync-only: exit handlers cannot await promises or drain microtasks.
// _trackCommandResult / _trackCliError are captured references resolved
// at init time, so they're callable synchronously here.
process.on(
"exit",
// fallow-ignore-next-line complexity
(code) => {
if (!finalized) {
_trackCommandResult?.({
command,
success: code === 0 && commandSucceededForTelemetry(),
exitCode: code,
durationMs: Date.now() - commandStart,
runId,
});
}
// Unconditional: this is the exit-time delivery for every command (gating it on `finalized`
// was the 0.7.65 render_complete loss). Empty queue is a no-op; uuids make re-sends idempotent.
_flushSync?.();
},
);
// Report a CLI error event to telemetry. Extracted from the process-error
// handlers so their bodies stay simple linear branches (see fallow CRAP
// scoring — arrow handlers with inline telemetry calls tip past threshold).
function emitCliErrorEvent(kind: "uncaught_exception" | "unhandled_rejection", error: Error): void {
_trackCliError?.({
error_name: error.name,
error_message: error.message,
stack_trace: error.stack,
command,
kind,
});
}
// Handle a post-artifact-validated throw: record the diagnostic, emit the
// telemetry event, but do NOT mark the run as failed. Field signals:
// ts=1784169760, ts=1784171150, ts=1784172467 (all win32/x64, CLI 0.7.58,
// ffmpeg=no, 1080x1920, valid MP4s on disk).
// The render is valid — a worker teardown / browser shutdown / stray
// subprocess stream error after `renderSucceeded` was set must not flip
// exit code or telemetry success to failure.
function reportPostRenderTerminationEvent(
label: "uncaughtException" | "unhandledRejection",
kind: "uncaught_exception" | "unhandled_rejection",
error: Error,
): void {
process.stderr.write(
` [hyperframes] Late ${label} (the command already delivered its result): ${error.message}\n`,
);
emitCliErrorEvent(kind, error);
}
// Terminate the process after a post-artifact-validated throw. Wraps
// report + flush + exit(0) so the caller arrow handler doesn't accumulate
// optional-chain branches (fallow CRAP scoring on the arrow tips past
// threshold otherwise).
function exitAfterPostRenderTermination(
label: "uncaughtException" | "unhandledRejection",
kind: "uncaught_exception" | "unhandled_rejection",
error: Error,
): never {
reportPostRenderTerminationEvent(label, kind, error);
_flushSync?.();
process.exit(0);
}
// A closed pipe (EPIPE) is the NORMAL teardown when the CLI runs under a
// piped agent (Claude Code, Codex, …) — the reader may stop consuming as
// soon as it has what it needs. Exit cleanly, but only score the run as a
// failure when the pipe died BEFORE the render artifact was validated:
// unconditionally setting `commandFailed = true` here marked every piped
// successful render as success:false (0.7.65–0.7.90). Delivery of anything
// still queued (render_complete's eager flush() dies with the process) is
// owned by the unconditional flushSync() in the `exit` handler below.
function handleStreamEpipe(): never {
if (!isRunDelivered()) commandFailed = true;
process.exit(0);
}
// Success gate for the cli_command_result telemetry field. `commandFailed`
// can be set by pre-artifact noise — a stray unhandledRejection mid-render,
// or an EPIPE that fires before validation on a run that still completes.
// Once the render artifact has been validated (`isRenderSucceeded()`), that
// earlier noise must not score the run as a failure: the run delivered.
// Genuine failures keep a non-zero exit code and are caught by the
// `exitCode === 0 &&` half of the expression at both call sites.
function commandSucceededForTelemetry(): boolean {
return !commandFailed || isRenderSucceeded();
}
// Terminate the process after a genuine CLI failure — mark commandFailed,
// emit telemetry, flush, exit(1). Same rationale as above: keeps the arrow
// handler linear so fallow CRAP stays under threshold.
function exitAfterCliFailure(
kind: "uncaught_exception" | "unhandled_rejection",
error: Error,
): never {
commandFailed = true;
emitCliErrorEvent(kind, error);
_flushSync?.();
process.exit(1);
}
process.on("uncaughtException", (error) => {
if ((error as NodeJS.ErrnoException).code === "EPIPE") {
handleStreamEpipe();
}
// Post-artifact-validated shutdown throws must not turn a valid render
// into an exit-1 "no final error message" failure. The render command
// sets `renderSucceeded` right after the producer resolves and the
// artifact is committed.
if (isRunDelivered()) {
exitAfterPostRenderTermination("uncaughtException", "uncaught_exception", error);
}
exitAfterCliFailure("uncaught_exception", error);
});
// unhandledRejection does not call process.exit() — Node may continue
// running if the rejection is non-fatal (e.g. a fire-and-forget promise).
// The exit handler above will still fire with the real exit code.
process.on("unhandledRejection", (reason) => {
const error = reason instanceof Error ? reason : new Error(String(reason));
// Same rationale as the uncaughtException branch above: a stray promise
// rejection during post-artifact-validated cleanup must not mark a valid
// render as failed. `commandFailed` gates the success:true telemetry
// field — keep it false when the render actually succeeded.
if (isRunDelivered()) {
reportPostRenderTerminationEvent("unhandledRejection", "unhandled_rejection", error);
return;
}
commandFailed = true;
process.exitCode = 1;
emitCliErrorEvent("unhandled_rejection", error);
});
// Lazy-load help renderer — avoids allocating help data on non-help invocations
async function showUsage<T extends ArgsDef>(
cmd: CommandDef<T>,
parent: CommandDef<T> | undefined,
print: (text: string) => void,
): Promise<void> {
const { showUsage: impl } = await import("./help.js");
return impl(cmd as CommandDef, parent as CommandDef | undefined, print);
}
// Prints the suggestion and the group's usage on stderr; the caller throws the returned error.
async function unknownCommandError(requested: ResolvedCommandUsage): Promise<CliUsageError> {
const word = requested.unknownSubcommand ?? "";
const group = ["hyperframes", ...requested.path].join(" ");
const declared = requested.command.subCommands;
const subCommands = typeof declared === "function" ? await declared() : await declared;
const names = Object.keys(subCommands ?? {});
const match =
(requested.path.length === 0 ? synonymMatch(word, names) : undefined) ??
closestMatch(word, names);
const hint = match ? `Did you mean '${group} ${match}'?` : fileHint(word, requested.path, names);
console.error(`Unknown command: ${word}`);
if (hint) console.error(hint);
console.error("");
await showUsage(requested.command, requested.parent, console.error);
return new CliUsageError(`Unknown command: ${word}`, {
presented: true,
code: "unknown_command",
hint,
});
}
// A group like `timeline` also takes a project directory, so a word it does not know may be one.
function takesPositionals(command: CommandDef): boolean {
const args = command.args;
return (
typeof args === "object" &&
args !== null &&
Object.values(args).some((arg) => arg.type === "positional")
);
}
async function showRequestedUsage(print: (text: string) => void): Promise<void> {
const requested = await resolveCommandUsage(main as CommandDef, argv);
if (requested.unknownSubcommand && !takesPositionals(requested.command)) {
throw await unknownCommandError(requested);
}
return showUsage(requested.command, requested.parent, print);
}
// citty would load every command looking for an alias before it gives up on an unknown name.
async function rejectUnknownTopLevelCommand(): Promise<void> {
const word = argv[0];
if (!word || word.startsWith("-") || Object.hasOwn(subCommands, word)) return;
throw await unknownCommandError({
command: main as CommandDef,
path: [],
unknownSubcommand: word,
});
}
function isCittyError(error: unknown, code: string): boolean {
return (
error instanceof Error &&
error.name === "CLIError" &&
(error as { code?: unknown }).code === code
);
}
// 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 [...CITTY_USAGE_CODES].some((code) => isCittyError(error, 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" };
}
// Root-only command boundary; keeping every result path here prevents modules
// from bypassing output, telemetry, or finalizers.
// fallow-ignore-next-line complexity
async function executeCli(): Promise<void> {
let result: CommandResult = { exitCode: 0, kind: "success" };
let failure: unknown;
try {
if (isHelp) await showRequestedUsage(console.log);
else {
await rejectUnknownTopLevelCommand();
await runCommand(main, { rawArgs: argv });
}
} catch (caught) {
const error = isCittyError(caught, "E_UNKNOWN_COMMAND")
? await unknownCommandError(await resolveCommandUsage(main as CommandDef, argv))
: caught;
failure = error;
result = commandResultForError(error);
if (!(error instanceof CliResultSignal)) {
commandFailed = true;
await reportCommandFailure(command, error);
const typed = error instanceof CliUsageError || error instanceof CliRuntimeError;
const usage = error instanceof CliUsageError && !error.result.presented;
if (usage && !error.hint) await showRequestedUsage(console.error);
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();
if (pending.exitCode !== 0 || result.exitCode === 0) result = pending;
if (jsonOutput && result.exitCode !== 0 && !jsonOutput.wroteStdout()) {
writeJsonFailure(jsonErrorBody(failure, result, jsonOutput.stderrText()));
}
await finalizeCli(result);
}
}
await executeCli();