#!/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 exit // should NOT score as success:true in telemetry. let commandFailed = false; for (const stream of [process.stdout, process.stderr]) { stream.on("error", (err) => { if ((err as NodeJS.ErrnoException).code === "EPIPE") { commandFailed = true; process.exit(0); } }); } // ── 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 // inlined into `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 { dirname, join } 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"; const 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); } // ── Load .env from CWD ───────────────────────────────────────────────────── // Agents run from the project directory where .env holds API keys (Gemini, // HeyGen, ElevenLabs). Load it automatically so they don't need `source .env`. try { const { readFileSync } = await import("node:fs"); const { resolve } = await import("node:path"); const envPath = resolve(process.cwd(), ".env"); const envContent = readFileSync(envPath, "utf-8"); for (const rawLine of envContent.split("\n")) { let line = rawLine.trim(); if (!line || line.startsWith("#")) continue; // Tolerate `export FOO=bar` (common in dotfile-style .env files). if (line.startsWith("export ")) line = line.slice(7).trim(); const eqIdx = line.indexOf("="); if (eqIdx < 1) continue; const key = line.slice(0, eqIdx).trim(); let val = line.slice(eqIdx + 1).trim(); if (val.startsWith('"') || val.startsWith("'")) { // Quoted value: take until the matching closing quote; leave the rest. // Anything after a closing quote (including `# comment`) is dropped. const quote = val.charAt(0); const end = val.indexOf(quote, 1); if (end > 0) val = val.slice(1, end); else val = val.slice(1); // unterminated quote — best-effort, strip opener } else { // Unquoted value: strip inline `# comment` (requires whitespace before # // to avoid eating `pass#word` style values). const commentMatch = val.match(/\s+#/); if (commentMatch?.index !== undefined) val = val.slice(0, commentMatch.index).trim(); } if (key && !(key in process.env)) process.env[key] = val; } } catch { /* .env not present — fine, env vars may be set another way */ } // ── Lazy imports ──────────────────────────────────────────────────────────── // Telemetry, update checks, and heavy modules are imported only when needed. // For --help we skip telemetry entirely. import { defineCommand, runMain } from "citty"; import type { ArgsDef, CommandDef } from "citty"; import { reportCommandFailure, trackCommandFailures } from "./utils/command-failure-tracking.js"; const isHelp = process.argv.includes("--help") || process.argv.includes("-h"); // --------------------------------------------------------------------------- // 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), play: () => import("./commands/play.js").then((m) => m.default), present: () => import("./commands/present.js").then((m) => m.default), preview: () => import("./commands/preview.js").then((m) => m.default), publish: () => import("./commands/publish.js").then((m) => m.default), render: () => import("./commands/render.js").then((m) => m.default), lint: () => import("./commands/lint.js").then((m) => m.default), beats: () => import("./commands/beats.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), 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), 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), 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), 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), }; // Wrap each command's run() so a thrown failure reports its reason to telemetry // before citty catches the error and exits 1. The error is re-thrown unchanged, // preserving citty's print + exit-1 behavior. Commands that call process.exit() // themselves (e.g. `browser path`) bypass this and report inline. const subCommands = Object.fromEntries( Object.entries(commandLoaders).map(([name, load]) => [ name, trackCommandFailures(load, (err) => reportCommandFailure(command, err)), ]), ); 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"); // 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 _flush: (() => Promise) | undefined; 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 }) => void) | undefined; let _printUpdateNotice: (() => void) | undefined; let _printSkillsUpdateNotice: (() => void) | undefined; // `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 !== "unknown") { import("./telemetry/index.js").then((mod) => { _flush = mod.flush; _flushSync = mod.flushSync; _trackCliError = mod.trackCliError; _trackCommandResult = mod.trackCommandResult; mod.showTelemetryNotice(); mod.trackCommand(command); 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. if (!isHelp && !hasJsonFlag && command !== "upgrade" && command !== "events") { // 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; const result = await mod.checkForUpdate().catch(() => null); if (result?.updateAvailable) { const auto = await import("./utils/autoUpdate.js").catch(() => null); auto?.scheduleBackgroundInstall(result.latest, result.current); } }); // Skills freshness nudge — same gating as the CLI self-update notice. The // check is cached (24h) and best-effort: it never blocks or fails the command. import("./utils/skillsUpdateCheck.js").then(async (mod) => { _printSkillsUpdateNotice = mod.printSkillsUpdateNotice; await mod.checkSkillsForUpdate().catch(() => null); }); } const commandStart = Date.now(); // Async flush for normal exit. `beforeExit` re-fires every time the // event loop drains, and the async `_flush()` itself schedules new // work — so a plain `on` listener would print the update notice (and // re-flush) once per drain (the user-reported double-print). `once` // detaches after first invocation, which is what we want for both. process.once("beforeExit", () => { _flush?.().catch(() => {}); if (!hasJsonFlag) { _printUpdateNotice?.(); _printSkillsUpdateNotice?.(); } }); // 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", (code) => { _trackCommandResult?.({ command, success: code === 0 && !commandFailed, exitCode: code, durationMs: Date.now() - commandStart, }); _flushSync?.(); }); process.on("uncaughtException", (error) => { if ((error as NodeJS.ErrnoException).code === "EPIPE") { commandFailed = true; process.exit(0); } commandFailed = true; _trackCliError?.({ error_name: error.name, error_message: error.message, stack_trace: error.stack, command, kind: "uncaught_exception", }); _flushSync?.(); process.exit(1); }); // 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) => { commandFailed = true; const error = reason instanceof Error ? reason : new Error(String(reason)); _trackCliError?.({ error_name: error.name, error_message: error.message, stack_trace: error.stack, command, kind: "unhandled_rejection", }); }); // Lazy-load help renderer — avoids allocating help data on non-help invocations async function showUsage( cmd: CommandDef, parent?: CommandDef, ): Promise { const { showUsage: impl } = await import("./help.js"); return impl(cmd as CommandDef, parent as CommandDef | undefined); } runMain(main, { showUsage });