feat(cli): simplify init flow + fast startup + brand teal accent

Streamline hyperframes init from 7 steps to 3:
  Before: name → media? → [drag file] → transcribe? → template (9) → what next?
  After:  name → template → auto-opens studio preview

- Remove interactive "Got a video or audio file?" prompt (--video/--audio
  flags still work for non-interactive and power users)
- Auto-launch studio preview after scaffolding instead of "What next?" menu
- Remove dead nextStepLoop function

CLI performance:
- Fast-path exit for --version (~10ms, loads only version.js)
- Lazy-load telemetry module (--help skips system metadata collection)
- Lazy-load update checker

Brand:
- CLI accent color changed from cyan to brand teal (#3CE6AC)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Ángel
2026-03-31 20:38:58 +02:00
co-authored by Claude Opus 4.6
parent e7283e5ce3
commit e507372767
3 changed files with 67 additions and 126 deletions
+43 -31
View File
@@ -1,19 +1,25 @@
#!/usr/bin/env node
import { defineCommand, runMain } from "citty";
// ── Fast-path exits ─────────────────────────────────────────────────────────
// Check --version before importing anything heavy. This makes
// `hyperframes --version` near-instant (~10ms vs ~80ms).
import { VERSION } from "./version.js";
import {
showTelemetryNotice,
flush,
flushSync,
shouldTrack,
trackCommand,
incrementCommandCount,
} from "./telemetry/index.js";
import { checkForUpdate, printUpdateNotice } from "./utils/updateCheck.js";
if (process.argv.includes("--version") || process.argv.includes("-V")) {
console.log(VERSION);
process.exit(0);
}
// ── Lazy imports ────────────────────────────────────────────────────────────
// Telemetry, update checks, and heavy modules are imported only when needed.
// For --help we skip telemetry entirely.
import { defineCommand, runMain } from "citty";
const isHelp = process.argv.includes("--help") || process.argv.includes("-h");
// ---------------------------------------------------------------------------
// CLI definition
// CLI definition — all commands are lazy-loaded via dynamic import()
// ---------------------------------------------------------------------------
const subCommands = {
@@ -43,42 +49,48 @@ const main = defineCommand({
});
// ---------------------------------------------------------------------------
// Telemetry — detect command from argv, track it, flush on exit
// Telemetry — lazy-loaded, skipped for --help/--version
// ---------------------------------------------------------------------------
const commandArg = process.argv[2];
const isHelpOrVersion =
process.argv.includes("--help") ||
process.argv.includes("--version") ||
process.argv.includes("-h");
const command = commandArg && commandArg in subCommands ? commandArg : "unknown";
if (command !== "telemetry" && command !== "unknown" && !isHelpOrVersion) {
showTelemetryNotice();
trackCommand(command);
if (shouldTrack()) {
incrementCommandCount();
}
if (!isHelp && command !== "telemetry" && command !== "unknown") {
// Defer telemetry import so --help doesn't pay for system metadata collection
import("./telemetry/index.js").then(
({ showTelemetryNotice, trackCommand, shouldTrack, incrementCommandCount }) => {
showTelemetryNotice();
trackCommand(command);
if (shouldTrack()) incrementCommandCount();
},
);
}
// Fire background update check (non-blocking, populates cache for printUpdateNotice)
// Fire background update check (non-blocking)
const hasJsonFlag = process.argv.includes("--json");
if (!isHelpOrVersion && !hasJsonFlag && command !== "upgrade") {
checkForUpdate().catch(() => {});
if (!isHelp && !hasJsonFlag && command !== "upgrade") {
import("./utils/updateCheck.js").then(({ checkForUpdate }) => {
checkForUpdate().catch(() => {});
});
}
// Async flush for normal exit (beforeExit fires when the event loop drains)
// Async flush for normal exit
process.on("beforeExit", () => {
flush().catch(() => {});
// Print update notice after command output (stderr, skipped in CI/non-TTY)
import("./telemetry/index.js").then(({ flush }) => flush().catch(() => {}));
if (!hasJsonFlag) {
printUpdateNotice();
import("./utils/updateCheck.js").then(({ printUpdateNotice }) => printUpdateNotice());
}
});
// Sync flush for process.exit() calls (exit event only allows synchronous code)
// Sync flush for process.exit() calls
process.on("exit", () => {
flushSync();
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { flushSync } = require("./telemetry/index.js");
flushSync();
} catch {
// Telemetry not yet loaded — nothing to flush
}
});
runMain(main);
+20 -94
View File
@@ -415,46 +415,6 @@ function scaffoldProject(
// nextStepLoop — "What do you want to do?" loop after scaffolding
// ---------------------------------------------------------------------------
async function nextStepLoop(destDir: string): Promise<void> {
while (true) {
const next = await clack.select({
message: "What do you want to do?",
options: [
{
value: "preview",
label: "Open in studio",
hint: "full editor with timeline",
},
{ value: "render", label: "Render to MP4", hint: "export video now" },
{ value: "done", label: "Done for now" },
],
});
if (clack.isCancel(next) || next === "done") {
clack.outro(c.success("Happy editing!"));
return;
}
// Hand off to the selected command — use explicit imports so the
// bundler can resolve them (dynamic import with a variable fails in bundles)
try {
if (next === "preview") {
const previewCmd = await import("./preview.js").then((m) => m.default);
await runCommand(previewCmd, { rawArgs: [destDir] });
} else if (next === "render") {
const renderCmd = await import("./render.js").then((m) => m.default);
await runCommand(renderCmd, { rawArgs: [destDir] });
}
} catch {
// Command may throw on Ctrl+C — that's fine, loop back
}
// Wait a tick so any lingering SIGINT state clears before Clack prompts again
await new Promise((r) => setTimeout(r, 100));
console.log();
}
}
// ---------------------------------------------------------------------------
// Exported command
// ---------------------------------------------------------------------------
@@ -670,11 +630,10 @@ Examples:
}
}
// 2. Got a video or audio file?
// 2. Video/audio file handling (only via --video/--audio flags, no interactive prompt)
let localVideoName: string | undefined;
let sourceFilePath: string | undefined;
let videoDuration: number | undefined;
let isAudioOnly = false;
if (videoFlag) {
const videoPath = resolve(videoFlag);
@@ -688,59 +647,20 @@ Examples:
const result = await handleVideoFile(videoPath, destDir, true);
localVideoName = result.localVideoName;
videoDuration = result.meta.durationSeconds;
} else {
const mediaChoice = await clack.select({
message: "Got a video or audio file?",
options: [
{ value: "video", label: "Video", hint: "MP4, WebM, MOV" },
{ value: "audio", label: "Audio only", hint: "MP3, WAV, M4A" },
{
value: "no",
label: "No",
hint: "Start with motion graphics or text",
},
],
initialValue: "no" as "video" | "audio" | "no",
});
if (clack.isCancel(mediaChoice)) {
} else if (audioFlag) {
const audioPath = resolve(audioFlag);
if (!existsSync(audioPath)) {
clack.log.error(`File not found: ${audioFlag}`);
clack.cancel("Setup cancelled.");
process.exit(0);
}
if (mediaChoice === "video" || mediaChoice === "audio") {
const pathResult = await clack.text({
message: `Path to your ${mediaChoice} file (drag and drop or paste)`,
placeholder: mediaChoice === "video" ? "/path/to/video.mp4" : "/path/to/audio.mp3",
validate(val) {
const trimmed = val?.trim();
if (!trimmed) return "Please enter a file path";
if (!existsSync(resolve(trimmed))) return "File not found";
return undefined;
},
});
if (clack.isCancel(pathResult)) {
clack.cancel("Setup cancelled.");
process.exit(0);
}
const filePath = resolve(String(pathResult).trim());
sourceFilePath = filePath;
mkdirSync(destDir, { recursive: true });
if (mediaChoice === "video") {
const result = await handleVideoFile(filePath, destDir, true);
localVideoName = result.localVideoName;
videoDuration = result.meta.durationSeconds;
} else {
// Audio file — copy to project root
isAudioOnly = true;
copyFileSync(filePath, resolve(destDir, basename(filePath)));
clack.log.info(`Audio copied to ${c.accent(basename(filePath))}`);
}
process.exit(1);
}
mkdirSync(destDir, { recursive: true });
sourceFilePath = audioPath;
copyFileSync(audioPath, resolve(destDir, basename(audioPath)));
clack.log.info(`Audio copied to ${c.accent(basename(audioPath))}`);
}
// 2b. Transcribe if we have a source file with audio
// 2b. Transcribe if we have a source file with audio (via flags)
if (sourceFilePath) {
const transcribeChoice = await clack.confirm({
message: "Generate captions from audio?",
@@ -794,7 +714,6 @@ Examples:
if (templateFlag) {
clack.log.warn(`Unknown template "${templateFlag}" — pick from the list below`);
}
const defaultTemplate = isAudioOnly ? "warm-grain" : "blank";
const templateResult = await clack.select({
message: "Pick a template",
options: TEMPLATES.map((t) => ({
@@ -802,7 +721,7 @@ Examples:
label: t.label,
hint: t.hint,
})),
initialValue: defaultTemplate as TemplateId,
initialValue: "blank" as TemplateId,
});
if (clack.isCancel(templateResult)) {
clack.cancel("Setup cancelled.");
@@ -834,6 +753,13 @@ Examples:
`${c.dim(" AI skills are installed — your agent knows how to create and edit compositions.")}`,
);
await nextStepLoop(destDir);
// Auto-launch studio preview
clack.log.info("Opening studio preview...");
try {
const previewCmd = await import("./preview.js").then((m) => m.default);
await runCommand(previewCmd, { rawArgs: [destDir] });
} catch {
// Ctrl+C or error — that's fine
}
},
});
+4 -1
View File
@@ -7,13 +7,16 @@ function wrap(fn: (s: string) => string): (s: string) => string {
return isColorSupported ? fn : (s: string) => s;
}
// Brand teal (#3CE6AC) via ANSI 24-bit true color
const teal = (s: string) => `\x1b[38;2;60;230;172m${s}\x1b[39m`;
export const c = {
success: wrap(pc.green),
error: wrap(pc.red),
warn: wrap(pc.yellow),
dim: wrap(pc.dim),
bold: wrap(pc.bold),
accent: wrap(pc.cyan),
accent: wrap(teal),
progress: wrap(pc.magenta),
reset: isColorSupported ? pc.reset : (s: string) => s,
};