From c8acd8abd8e91ec401b31cf957d48d7642b0a642 Mon Sep 17 00:00:00 2001 From: James Russo Date: Mon, 13 Apr 2026 20:44:23 -0700 Subject: [PATCH] feat(cli)!: rename --template to --example (#255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What PR 4/17 of the catalog system rollout. **Single clean cut** — the old flag is gone, replaced by `--example`. Alias changes from `-t` to `-e`. Stacks on #254. - Rename `--template` → `--example` (alias `-e`) on `hyperframes init` - Accept `--template` as a recognized-but-errored flag so users get a clear rename hint instead of citty silently ignoring the flag and producing a blank project - Update all user-visible strings that referenced "template" as a user-facing concept in the init flow (picker prompt, step comments, offline-fallback suggestion) - New `init.test.ts` covering both the success case (`--example` scaffolds) and the error case (`--template` exits 1 with rename hint) Design doc: [Hyperframes Catalog System](https://www.notion.so/heygen/Hyperframes-Catalog-System-Design-Plan-341449792c69813f899dcd53b4c0383a). ## ⚠️ Breaking change `--template` is no longer accepted. Example: ```bash # before npx hyperframes init my-video --template warm-grain # after npx hyperframes init my-video --example warm-grain ``` Users who still type the old flag will see: ``` The --template flag was renamed to --example. Example: npx hyperframes init my-video --example warm-grain ``` and the command exits with code 1. This is **user guidance, not backwards compat** — the old flag's behavior is fully gone. ## Docs (bundled per the tracker principle) - `docs/templates.mdx` — every `--template` reference - `docs/quickstart.mdx` — agent-mode and video-mode examples - `docs/packages/cli.mdx` — prose, `--help` flag table, `-e` alias - `packages/cli/src/docs/templates.md` — CLI-embedded help topic - `README.md` and `CONTRIBUTING.md` — not affected (no flag references) User-facing renames of the `templates.mdx` page title, nav entry, and URL route are deferred to PR 11 (catalog discoverability UX) as planned. ## Why 1. **"examples"** matches shadcn + Remotion convention for full-project scaffolds and frees the word "template" for future parameterization work (string templating, placeholder substitution) 2. Once `hyperframes add` lands in PR 5, "template" vs "block" vs "component" would be three subtly different concepts sharing one word — renaming the old one to "example" makes the taxonomy self-explaining ## How - **citty silently ignores unknown flags.** Naively removing `--template` would cause `hyperframes init my-video --template warm-grain` to silently fall through and scaffold a blank project. So `--template` stays declared in the args schema, but its run handler immediately errors with a rename hint and exits 1 - **Internal names unchanged** — `templateId` local variables, `getStaticTemplateDir` function, `BUNDLED_TEMPLATES` constant. They're implementation details; their rename is scheduled for PR 5 when the compat shims in `packages/cli/src/templates/` are fully removed alongside the `init` refactor ## Test plan - [x] `bun run test` in `packages/cli`: **72 passed** (was 70 on #254, +2 new `init.test.ts` cases). Same 4 pre-existing failures unchanged - [x] **New unit tests** in `init.test.ts`: - `--example blank` non-interactive: exits 0, writes `index.html` to the target dir - `--template blank` non-interactive: exits non-zero, stderr contains the rename hint + corrected command line, target dir is **not** created - [x] **Manual smoke:** - `npx hyperframes init /tmp/x --example blank` → "Created /tmp/x/" - `npx hyperframes init /tmp/y --template blank` → "The --template flag was renamed to --example..." exit=1 - [x] `bunx oxfmt --check` + `bunx oxlint` on changed files: clean - [x] Pre-commit typecheck (core + studio): clean ## Incidental fix Resolver test regression from PR 3's simplify follow-up: `loadAllItems`' warning-path test was still spying on `console.warn` after the `onWarn` callback refactor. Now uses the callback directly. ## Stacks on #254 — base branch. When #254 merges, this rebases onto `main`. ## Next in stack PR 5 — `feat(cli): add command + hyperframes.json`. The big UX PR where: - `init.ts` gets fully ported to the new registry resolver - Compat shims in `packages/cli/src/templates/` are removed - Users gain the `add` verb for installing blocks and components into existing projects - `hyperframes.json` project-config file lands 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/packages/cli.mdx | 16 +++---- docs/quickstart.mdx | 4 +- docs/templates.mdx | 6 +-- packages/cli/src/commands/init.test.ts | 52 ++++++++++++++++++++++ packages/cli/src/commands/init.ts | 46 +++++++++++++------ packages/cli/src/docs/templates.md | 2 +- packages/cli/src/registry/resolver.test.ts | 8 ++-- 7 files changed, 103 insertions(+), 31 deletions(-) create mode 100644 packages/cli/src/commands/init.test.ts diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 2c5c343d3..bafb5bee4 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -34,7 +34,7 @@ npx hyperframes The CLI is **non-interactive by default** — designed so AI agents (Claude Code, Gemini CLI, Codex, Cursor) can drive every command without prompts or interactive UI. -- All inputs are passed via flags (e.g., `--template`, `--video`, `--output`) +- All inputs are passed via flags (e.g., `--example`, `--video`, `--output`) - Missing required flags fail fast with a clear error and usage example - Output is plain text suitable for parsing - No interactive prompts, spinners, or selection menus @@ -45,7 +45,7 @@ Add `--human-friendly` to any command to enable the interactive terminal UI with ```bash # Fully non-interactive — all inputs from flags - npx hyperframes init my-video --template blank --video video.mp4 + npx hyperframes init my-video --example blank --video video.mp4 npx hyperframes render --output output.mp4 --fps 30 --quality standard npx hyperframes upgrade --check --json ``` @@ -94,11 +94,11 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ Scaffold a new composition from a template: ```bash - npx hyperframes init --template warm-grain + npx hyperframes init --example warm-grain ``` You will be prompted for a project name, or pass it as an argument: ```bash - npx hyperframes init my-video --template warm-grain + npx hyperframes init my-video --example warm-grain ``` See [Templates](/templates) for all available templates. @@ -142,8 +142,8 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ Create a new composition project from a template: ```bash - # Agent mode (default) — --template is required - npx hyperframes init my-video --template blank --video video.mp4 + # Agent mode (default) — --example is required + npx hyperframes init my-video --example blank --video video.mp4 # Human mode — interactive prompts npx hyperframes init --human-friendly @@ -151,7 +151,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ | Flag | Description | |------|-------------| - | `--template, -t` | Template to use (required in default mode, interactive in `--human-friendly`) | + | `--example, -e` | Example to scaffold (required in default mode, interactive in `--human-friendly`) | | `--video, -V` | Path to a video file (MP4, WebM, MOV) | | `--audio, -a` | Path to an audio file (MP3, WAV, M4A) | | `--skip-skills` | Skip AI coding skills installation | @@ -168,7 +168,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ | `swiss-grid` | Structured grid layout | | `vignelli` | Bold typography with red accents | - In default (agent) mode, `--template` is required — the CLI errors with a usage example if missing. In `--human-friendly` mode, you choose interactively. When `--video` or `--audio` is provided, the CLI automatically transcribes the audio with Whisper and patches captions into the composition (use `--skip-transcribe` to disable). + In default (agent) mode, `--example` is required — the CLI errors with a usage example if missing. In `--human-friendly` mode, you choose interactively. When `--video` or `--audio` is provided, the CLI automatically transcribes the audio with Whisper and patches captions into the composition (use `--skip-transcribe` to disable). After scaffolding, the CLI installs AI coding skills for Claude Code, Gemini CLI, and Codex CLI (use `--skip-skills` to disable). See [`skills`](#skills) command. diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 13b2f6143..e48d8162d 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -70,7 +70,7 @@ A 1920x1080 video with an animated title that fades in from above — rendered t This starts an interactive wizard that walks you through template selection and media import. To skip prompts (e.g. in CI or from an agent), use `--non-interactive`: ```bash - npx hyperframes init my-video --non-interactive --template blank + npx hyperframes init my-video --non-interactive --example blank ``` See [Templates](/templates) for all available templates. @@ -101,7 +101,7 @@ A 1920x1080 video with an animated title that fades in from above — rendered t If you have a source video, pass it with `--video` for automatic transcription and captions: ```bash - npx hyperframes init my-video --template warm-grain --video ./intro.mp4 + npx hyperframes init my-video --example warm-grain --video ./intro.mp4 ``` diff --git a/docs/templates.mdx b/docs/templates.mdx index 2cdba614b..6420e8a1f 100644 --- a/docs/templates.mdx +++ b/docs/templates.mdx @@ -6,7 +6,7 @@ description: "Built-in templates for common video patterns. Hover to preview ani Hyperframes includes starter templates to help you scaffold compositions quickly. Each template gives you a working project with the correct [composition structure](/concepts/compositions), [data attributes](/concepts/data-attributes), and a [GSAP timeline](/guides/gsap-animation) already wired up. ```bash Terminal -npx hyperframes init my-video --template +npx hyperframes init my-video --example ``` ## Landscape Templates @@ -55,7 +55,7 @@ npx hyperframes init my-video --template Looking for a minimal starting point? Use **blank** — it gives you an empty composition with just the scaffolding, no visual design. ```bash Terminal - npx hyperframes init my-video --template blank + npx hyperframes init my-video --example blank ``` @@ -217,7 +217,7 @@ npx hyperframes init my-video --template ## Passing a Source Video ```bash Terminal -npx hyperframes init my-video --template warm-grain --video ./my-clip.mp4 +npx hyperframes init my-video --example warm-grain --video ./my-clip.mp4 ``` The CLI will probe the video for duration, resolution, and codec. If the video uses an incompatible codec, it will be automatically transcoded to H.264 MP4 if FFmpeg is available. diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts new file mode 100644 index 000000000..198e3de04 --- /dev/null +++ b/packages/cli/src/commands/init.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const cliEntry = resolve(fileURLToPath(import.meta.url), "..", "..", "cli.ts"); + +// Spawns `bun` directly because the CLI entry is a .ts file that needs a +// TypeScript-aware runtime. vitest runs under node, so `process.execPath` +// would be node and couldn't load the entry. This repo hard-depends on bun +// (package.json scripts), so assuming it's on PATH is safe. +function runInit(args: string[]): { status: number; stdout: string; stderr: string } { + const res = spawnSync("bun", ["run", cliEntry, "init", ...args], { + encoding: "utf-8", + timeout: 30_000, + }); + return { + status: res.status ?? -1, + stdout: res.stdout, + stderr: res.stderr, + }; +} + +describe("hyperframes init flag rename", () => { + it("--example blank scaffolds a bundled project", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-init-test-")); + const target = join(dir, "proj"); + try { + const res = runInit([target, "--example", "blank", "--non-interactive", "--skip-skills"]); + expect(res.status).toBe(0); + expect(existsSync(join(target, "index.html"))).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("--template prints a rename hint and exits non-zero", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-init-test-")); + const target = join(dir, "proj"); + try { + const res = runInit([target, "--template", "blank", "--non-interactive", "--skip-skills"]); + expect(res.status).not.toBe(0); + expect(res.stderr).toContain("--template flag was renamed to --example"); + expect(res.stderr).toContain(`--example "blank"`); + expect(existsSync(target)).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 1b02f4086..595c631bc 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -3,7 +3,7 @@ import type { Example } from "./_examples.js"; export const examples: Example[] = [ ["Create a project with the interactive wizard", "hyperframes init my-video"], - ["Pick a starter template", "hyperframes init my-video --template warm-grain"], + ["Pick a starter example", "hyperframes init my-video --example warm-grain"], ["Start from an existing video file", "hyperframes init my-video --video clip.mp4"], ["Start from an audio file", "hyperframes init my-video --audio track.mp3"], ["Non-interactive mode (for CI or AI agents)", "hyperframes init my-video --non-interactive"], @@ -362,10 +362,20 @@ export default defineCommand({ }, args: { name: { type: "positional", description: "Project name", required: false }, + example: { + type: "string", + description: "Example name (e.g. warm-grain, swiss-grid, blank)", + alias: "e", + }, + // Accepted-but-errored so users who still type the old flag get a clear + // message rather than citty silently ignoring it and producing a blank + // project. The actual behavior is gone — this exists purely for the + // diagnostic. `hidden` keeps it out of --help output so new users aren't + // taught about a flag that's already gone. template: { type: "string", - description: "Template name (e.g. warm-grain, swiss-grid, blank)", - alias: "t", + description: "[renamed] Use --example instead.", + hidden: true, }, video: { type: "string", @@ -401,7 +411,17 @@ export default defineCommand({ }, }, async run({ args }) { - const templateFlag = args.template; + if (args.template !== undefined) { + // Quote the value in case it looks flag-like — keeps the suggested + // command copy-pasteable. + console.error( + c.error( + `The --template flag was renamed to --example. Example:\n npx hyperframes init ${args.name ?? "my-video"} --example "${args.template}"`, + ), + ); + process.exit(1); + } + const exampleFlag = args.example; const videoFlag = args.video; const audioFlag = args.audio; const skipTranscribe = args["skip-transcribe"] === true; @@ -415,7 +435,7 @@ export default defineCommand({ // Non-interactive mode — all inputs from flags, defaults where missing // ----------------------------------------------------------------------- if (!interactive) { - const templateId = templateFlag ?? "blank"; + const templateId = exampleFlag ?? "blank"; const name = args.name ?? "my-video"; const destDir = resolve(name); @@ -496,10 +516,10 @@ export default defineCommand({ } catch (err) { console.error( c.error( - `Failed to scaffold template "${templateId}": ${err instanceof Error ? err.message : err}`, + `Failed to scaffold example "${templateId}": ${err instanceof Error ? err.message : err}`, ), ); - console.error(c.dim("Use --template blank for offline use.")); + console.error(c.dim("Use --example blank for offline use.")); process.exit(1); } trackInitTemplate(templateId); @@ -646,17 +666,17 @@ export default defineCommand({ } } - // 3. Pick template — skip prompt if --template was provided + // 3. Pick example — skip prompt if --example was provided let templateId: string; - if (templateFlag) { - templateId = templateFlag; + if (exampleFlag) { + templateId = exampleFlag; } else { // Resolve full template list (bundled + remote) const allTemplates = await resolveTemplateList(); const defaultTemplate = "blank"; const templateResult = await clack.select({ - message: "Pick a template", + message: "Pick an example", options: allTemplates.map((t: TemplateOption) => ({ value: t.id, label: t.label, @@ -675,7 +695,7 @@ export default defineCommand({ const spin = clack.spinner(); const isBundled = BUNDLED_TEMPLATES.some((t) => t.id === templateId); if (!isBundled) { - spin.start(`Downloading template ${c.accent(templateId)}...`); + spin.start(`Downloading example ${c.accent(templateId)}...`); } try { await scaffoldProject(destDir, name, templateId, localVideoName, videoDuration); @@ -687,7 +707,7 @@ export default defineCommand({ spin.stop(c.error("Download failed")); } clack.log.error( - `${err instanceof Error ? err.message : err}\n${c.dim("Use --template blank for offline use.")}`, + `${err instanceof Error ? err.message : err}\n${c.dim("Use --example blank for offline use.")}`, ); process.exit(1); } diff --git a/packages/cli/src/docs/templates.md b/packages/cli/src/docs/templates.md index cb08513e4..7e8ec2c32 100644 --- a/packages/cli/src/docs/templates.md +++ b/packages/cli/src/docs/templates.md @@ -1,6 +1,6 @@ # Templates -Built-in templates available via `npx hyperframes init --template `. +Built-in templates available via `npx hyperframes init --example `. ## blank diff --git a/packages/cli/src/registry/resolver.test.ts b/packages/cli/src/registry/resolver.test.ts index 8792668fc..fec6f316b 100644 --- a/packages/cli/src/registry/resolver.test.ts +++ b/packages/cli/src/registry/resolver.test.ts @@ -106,12 +106,12 @@ describe("registry resolver", () => { it("skips items whose manifest fails to load (warning, not failure)", async () => { mockFetch({ missing: ["beta"] }); const baseUrl = uniqueBaseUrl(); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const warnings: string[] = []; const entries = await listRegistryItems(undefined, { baseUrl }); - const items = await loadAllItems(entries, { baseUrl }); + const items = await loadAllItems(entries, { baseUrl, onWarn: (m) => warnings.push(m) }); expect(items.map((i) => i.name).sort()).toEqual(["alpha", "gamma"]); - expect(warn).toHaveBeenCalled(); - warn.mockRestore(); + expect(warnings.length).toBeGreaterThan(0); + expect(warnings.some((w) => w.includes("beta"))).toBe(true); }); });