feat(cli)!: rename --template to --example (#255)

## 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)
This commit is contained in:
James Russo
2026-04-13 20:44:23 -07:00
committed by GitHub
parent 969474e843
commit c8acd8abd8
7 changed files with 103 additions and 31 deletions
+8 -8
View File
@@ -34,7 +34,7 @@ npx hyperframes <command>
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
<Tab title="Agent mode (default)">
```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_
<Step title="Create a project">
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.
</Step>
@@ -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.
+2 -2
View File
@@ -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
```
</Step>
+3 -3
View File
@@ -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 <name>
npx hyperframes init my-video --example <name>
```
## Landscape Templates
@@ -55,7 +55,7 @@ npx hyperframes init my-video --template <name>
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
```
</Tip>
@@ -217,7 +217,7 @@ npx hyperframes init my-video --template <name>
## 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.
+52
View File
@@ -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 });
}
});
});
+33 -13
View File
@@ -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);
}
+1 -1
View File
@@ -1,6 +1,6 @@
# Templates
Built-in templates available via `npx hyperframes init --template <name>`.
Built-in templates available via `npx hyperframes init --example <name>`.
## blank
+4 -4
View File
@@ -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);
});
});