diff --git a/docs/concepts/compositions.mdx b/docs/concepts/compositions.mdx
index 08d8fc045..b74c75a20 100644
--- a/docs/concepts/compositions.mdx
+++ b/docs/concepts/compositions.mdx
@@ -133,7 +133,7 @@ Compositions can expose variables for dynamic content:
```
-Variables make compositions reusable as [templates](/templates) -- the same composition can render different content by injecting variable values at render time.
+Variables make compositions reusable as [examples](/examples) -- the same composition can render different content by injecting variable values at render time.
## Listing Compositions
@@ -152,8 +152,8 @@ npx hyperframes compositions
Add animations to your compositions with GSAP timelines
-
- Start from built-in templates for common video patterns
+
+ Start from built-in examples for common video patterns
Complete schema for authoring compositions
diff --git a/docs/contributing.mdx b/docs/contributing.mdx
index 43d40095f..17d1e19fc 100644
--- a/docs/contributing.mdx
+++ b/docs/contributing.mdx
@@ -93,7 +93,7 @@ Not sure where to start? Here are some ideas:
- **Good first issues** — look for issues labeled `good first issue` on GitHub
- **Documentation** — improve docs, add examples, fix typos
- **Linter rules** — add new rules to catch more composition mistakes
-- **Templates** — create new starter templates
+- **Examples** — create new starter examples
- **Bug fixes** — check the issue tracker for reported bugs
## Pull Requests
diff --git a/docs/contributing/testing-local-changes.mdx b/docs/contributing/testing-local-changes.mdx
index 8c8733cff..ae54aacb8 100644
--- a/docs/contributing/testing-local-changes.mdx
+++ b/docs/contributing/testing-local-changes.mdx
@@ -65,7 +65,7 @@ Replace `/path/to/hyperframes-oss` with your actual monorepo path.
## Option 3: npm pack (test the exact published artifact)
-Use this when you want to verify what would actually ship in a release, including the bundled studio and templates.
+Use this when you want to verify what would actually ship in a release, including the bundled studio and examples.
```bash
cd packages/cli
diff --git a/docs/docs.json b/docs/docs.json
index 2df12e686..7be169e6d 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -51,7 +51,7 @@
"groups": [
{
"group": "Getting Started",
- "pages": ["introduction", "quickstart", "templates"]
+ "pages": ["introduction", "quickstart", "examples"]
},
{
"group": "Concepts",
diff --git a/docs/templates.mdx b/docs/examples.mdx
similarity index 92%
rename from docs/templates.mdx
rename to docs/examples.mdx
index 6420e8a1f..7272ca6de 100644
--- a/docs/templates.mdx
+++ b/docs/examples.mdx
@@ -1,9 +1,9 @@
---
-title: Templates
-description: "Built-in templates for common video patterns. Hover to preview animations."
+title: Examples
+description: "Built-in examples for common video patterns. Hover to preview animations."
---
-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.
+Hyperframes includes starter examples to help you scaffold compositions quickly. Each example 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 --example
@@ -59,9 +59,9 @@ npx hyperframes init my-video --example
```
-## Choosing a Template
+## Choosing an Example
-| Template | Style | Format | Best for |
+| Example | Style | Format | Best for |
|----------|-------|--------|----------|
| `warm-grain` | Organic, textured | Landscape | Lifestyle, branding, editorial |
| `play-mode` | Energetic, elastic | Landscape | Social media, product launches |
@@ -73,7 +73,7 @@ npx hyperframes init my-video --example
| `vignelli` | Bold, typographic | Portrait | Headlines, announcements |
| `blank` | Minimal scaffolding | — | Full control, agent-generated |
-## Template Details
+## Example Details
@@ -222,16 +222,16 @@ 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.
-## Custom Templates
+## Custom Examples
-Any directory with an `index.html` can serve as a template. Your custom template needs:
+Any directory with an `index.html` can serve as an example. Your custom example needs:
1. An `index.html` with a [`data-composition-id`](/concepts/data-attributes#composition-attributes) root element
2. A [GSAP timeline](/guides/gsap-animation) registered in `window.__timelines`
3. Any assets in the same directory or a subdirectory
```html index.html
-
@@ -241,12 +241,12 @@ Any directory with an `index.html` can serve as a template. Your custom template
const tl = gsap.timeline({ paused: true });
// Add your animations...
window.__timelines = window.__timelines || {};
- window.__timelines["my-template"] = tl;
+ window.__timelines["my-example"] = tl;
```
-After creating a custom template, validate it with the [linter](/packages/cli#lint):
+After creating a custom example, validate it with the [linter](/packages/cli#lint):
```bash Terminal
npx hyperframes lint
@@ -259,7 +259,7 @@ npx hyperframes lint
Create, preview, and render your first video
- Add animations to your template
+ Add animations to your example
Understand the composition data model
diff --git a/docs/guides/troubleshooting.mdx b/docs/guides/troubleshooting.mdx
index a9757db3e..e9ef2203c 100644
--- a/docs/guides/troubleshooting.mdx
+++ b/docs/guides/troubleshooting.mdx
@@ -9,7 +9,7 @@ If your issue is about a specific coding mistake (animations not working, video
Your directory needs an `index.html` with a valid [composition](/concepts/compositions). The root element must have a [`data-composition-id`](/concepts/data-attributes#composition-attributes) attribute.
- **Fix:** Run `npx hyperframes init` to create a composition from a [template](/templates), or verify your `index.html` has the correct structure:
+ **Fix:** Run `npx hyperframes init` to create a composition from an [example](/examples), or verify your `index.html` has the correct structure:
```html index.html
## When to Use
**Use the CLI when you want to:**
-- Create a new composition project from a template
+- Create a new composition project from an example
- Preview compositions with live hot reload during development
- Render compositions to MP4 (locally or in Docker)
- Lint compositions for structural issues
@@ -92,7 +92,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
- Scaffold a new composition from a template:
+ Scaffold a new composition from an example:
```bash
npx hyperframes init --example warm-grain
```
@@ -100,7 +100,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
```bash
npx hyperframes init my-video --example warm-grain
```
- See [Templates](/templates) for all available templates.
+ See [Examples](/examples) for all available examples.
Start the development server with live hot reload:
@@ -139,7 +139,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
### `init`
- Create a new composition project from a template:
+ Create a new composition project from an example:
```bash
# Agent mode (default) — --example is required
@@ -160,7 +160,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| `--language` | Language code for transcription (e.g. `en`, `es`, `ja`). Filters non-target speech. |
| `--human-friendly` | Enable interactive terminal UI with prompts |
- | Template | Description |
+ | Example | Description |
|----------|-------------|
| `blank` | Empty composition — just the scaffolding |
| `warm-grain` | Cream aesthetic with grain texture |
@@ -172,7 +172,38 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
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.
- See [Templates](/templates) for full details.
+ See [Examples](/examples) for full details.
+
+ ### `add`
+
+ Install a **block** or **component** from the registry into an existing project. Examples (full projects) are scaffolded with [`init`](#init); blocks and components are smaller units you add to a composition you already have.
+
+ ```bash
+ # Add a block (sub-composition scene)
+ npx hyperframes add claude-code-window
+
+ # Add a component (effect / snippet)
+ npx hyperframes add shader-wipe
+
+ # Target a different project dir
+ npx hyperframes add shader-wipe --dir ./my-video
+
+ # Headless / CI (skip clipboard; also: --json for a machine-readable result)
+ npx hyperframes add shader-wipe --no-clipboard --json
+ ```
+
+ | Flag | Description |
+ |------|-------------|
+ | `` (positional) | Registry item name (e.g. `claude-code-window`, `shader-wipe`) |
+ | `--dir` | Project directory (defaults to the current working directory) |
+ | `--no-clipboard` | Skip copying the include snippet to the clipboard |
+ | `--json` | Print a machine-readable summary (written files + snippet) to stdout |
+
+ `add` reads [`hyperframes.json`](#hyperframes-json) at the project root to know which registry to pull from and where to drop files. If the file is missing but the directory looks like a Hyperframes project (has `index.html`), a default `hyperframes.json` is written the first time you run `add`.
+
+ Output for a block or component is a set of files plus a **paste snippet** — the `
+## hyperframes.json
+
+`hyperframes init` writes a `hyperframes.json` file at the root of every new project. `hyperframes add` reads it to know which registry to pull items from and where to drop them. Edit the file (or delete it to fall back to defaults) to reshape your project layout or point at a custom registry.
+
+```json
+{
+ "$schema": "https://hyperframes.heygen.com/schema/hyperframes.json",
+ "registry": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry",
+ "paths": {
+ "blocks": "compositions",
+ "components": "compositions/components",
+ "assets": "assets"
+ }
+}
+```
+
+| Field | Description |
+|-------|-------------|
+| `registry` | Base URL of the registry `add` pulls from. Defaults to the public Hyperframes registry. |
+| `paths.blocks` | Where block `.html` files land (relative to project root). |
+| `paths.components` | Where component files land (relative to project root). |
+| `paths.assets` | Where referenced asset files (images, fonts) land. |
+
+Missing fields are filled with defaults — you only need to specify what you want to override.
+
## Related Packages
diff --git a/docs/packages/core.mdx b/docs/packages/core.mdx
index 2b45a97f3..896d89987 100644
--- a/docs/packages/core.mdx
+++ b/docs/packages/core.mdx
@@ -33,7 +33,7 @@ The core package has four entry points:
| Import | Description |
|--------|-------------|
-| `@hyperframes/core` | Types, parsers, generators, templates, adapters, runtime utilities |
+| `@hyperframes/core` | Types, parsers, generators, adapters, runtime utilities |
| `@hyperframes/core/lint` | Composition linter |
| `@hyperframes/core/compiler` | Timing compiler, HTML compiler, bundler, static guard |
| `@hyperframes/core/runtime` | Pre-built IIFE runtime for browser injection |
diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx
index e48d8162d..9b617092e 100644
--- a/docs/quickstart.mdx
+++ b/docs/quickstart.mdx
@@ -67,13 +67,13 @@ A 1920x1080 video with an animated title that fades in from above — rendered t
cd my-video
```
- 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`:
+ This starts an interactive wizard that walks you through example 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 --example blank
```
- See [Templates](/templates) for all available templates.
+ See [Examples](/examples) for all available examples.
This generates a project structure like:
@@ -188,8 +188,8 @@ A 1920x1080 video with an animated title that fades in from above — rendered t
Add fade, slide, scale, and custom animations to your videos
-
- Start from built-in templates like Warm Grain and Swiss Grid
+
+ Start from built-in examples like Warm Grain and Swiss Grid
Explore render options: quality presets, Docker mode, and GPU encoding
diff --git a/docs/snippets/TemplateCard.jsx b/docs/snippets/TemplateCard.jsx
index b14b6bc08..bffee6985 100644
--- a/docs/snippets/TemplateCard.jsx
+++ b/docs/snippets/TemplateCard.jsx
@@ -17,7 +17,7 @@ export function TemplateCard({ id, title, description, href, portrait }) {
>
import("./commands/init.js").then((m) => m.default),
+ add: () => import("./commands/add.js").then((m) => m.default),
play: () => import("./commands/play.js").then((m) => m.default),
preview: () => import("./commands/preview.js").then((m) => m.default),
render: () => import("./commands/render.js").then((m) => m.default),
diff --git a/packages/cli/src/commands/add.test.ts b/packages/cli/src/commands/add.test.ts
new file mode 100644
index 000000000..157e99c8d
--- /dev/null
+++ b/packages/cli/src/commands/add.test.ts
@@ -0,0 +1,269 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import type { RegistryItem, RegistryManifest } from "@hyperframes/core";
+import { AddError, buildSnippet, remapTarget, runAdd } from "./add.js";
+
+// ── Fixtures ────────────────────────────────────────────────────────────────
+
+const MANIFEST: RegistryManifest = {
+ $schema: "https://hyperframes.heygen.com/schema/registry.json",
+ name: "test",
+ homepage: "https://example.com",
+ items: [
+ { name: "my-block", type: "hyperframes:block" },
+ { name: "my-component", type: "hyperframes:component" },
+ { name: "my-example", type: "hyperframes:example" },
+ ],
+};
+
+const BLOCK_ITEM: RegistryItem = {
+ $schema: "https://hyperframes.heygen.com/schema/registry-item.json",
+ name: "my-block",
+ type: "hyperframes:block",
+ title: "My Block",
+ description: "Block for tests",
+ dimensions: { width: 1080, height: 1350 },
+ duration: 6,
+ files: [
+ {
+ path: "my-block.html",
+ target: "compositions/my-block.html",
+ type: "hyperframes:composition",
+ },
+ ],
+};
+
+const COMPONENT_ITEM: RegistryItem = {
+ $schema: "https://hyperframes.heygen.com/schema/registry-item.json",
+ name: "my-component",
+ type: "hyperframes:component",
+ title: "My Component",
+ description: "Component for tests",
+ files: [
+ {
+ path: "my-component.html",
+ target: "compositions/components/my-component/my-component.html",
+ type: "hyperframes:snippet",
+ },
+ {
+ path: "my-component.css",
+ target: "compositions/components/my-component/my-component.css",
+ type: "hyperframes:style",
+ },
+ ],
+};
+
+const EXAMPLE_ITEM: RegistryItem = {
+ $schema: "https://hyperframes.heygen.com/schema/registry-item.json",
+ name: "my-example",
+ type: "hyperframes:example",
+ title: "My Example",
+ description: "Example for tests",
+ dimensions: { width: 1920, height: 1080 },
+ duration: 10,
+ files: [{ path: "index.html", target: "index.html", type: "hyperframes:composition" }],
+};
+
+const ITEM_BY_NAME: Record = {
+ "my-block": BLOCK_ITEM,
+ "my-component": COMPONENT_ITEM,
+ "my-example": EXAMPLE_ITEM,
+};
+
+function mockFetch(): void {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: string | URL) => {
+ const url = typeof input === "string" ? input : input.toString();
+ if (url.endsWith("/registry.json")) {
+ return new Response(JSON.stringify(MANIFEST), { status: 200 });
+ }
+ const m = /\/(examples|blocks|components)\/([^/]+)\/registry-item\.json$/.exec(url);
+ if (m) {
+ const item = ITEM_BY_NAME[m[2]!];
+ if (item) return new Response(JSON.stringify(item), { status: 200 });
+ }
+ // File fetch — match `///` and serve synthetic content.
+ const f = /\/(examples|blocks|components)\/([^/]+)\/(.+)$/.exec(url);
+ if (f) {
+ return new Response(`/* ${f[3]} */\n`, { status: 200 });
+ }
+ return new Response("not found", { status: 404 });
+ }),
+ );
+}
+
+function tmp(): string {
+ return mkdtempSync(join(tmpdir(), "hf-add-test-"));
+}
+
+function uniqueBase(): string {
+ return `https://test.invalid/${crypto.randomUUID()}`;
+}
+
+// ── Tests ───────────────────────────────────────────────────────────────────
+
+describe("add command pure helpers", () => {
+ describe("remapTarget", () => {
+ const PATHS = { blocks: "src/scenes", components: "src/fx" };
+
+ it("rewrites block default path to paths.blocks", () => {
+ expect(remapTarget(BLOCK_ITEM, "compositions/my-block.html", PATHS)).toBe(
+ "src/scenes/my-block.html",
+ );
+ });
+
+ it("rewrites component default path to paths.components", () => {
+ expect(
+ remapTarget(
+ COMPONENT_ITEM,
+ "compositions/components/my-component/my-component.html",
+ PATHS,
+ ),
+ ).toBe("src/fx/my-component/my-component.html");
+ });
+
+ it("leaves example targets alone", () => {
+ expect(remapTarget(EXAMPLE_ITEM, "index.html", PATHS)).toBe("index.html");
+ });
+
+ it("leaves non-default block paths alone (no blind string replace)", () => {
+ // A block's manifest could in future use a non-default target — make
+ // sure the prefix match is anchored.
+ expect(remapTarget(BLOCK_ITEM, "elsewhere/my-block.html", PATHS)).toBe(
+ "elsewhere/my-block.html",
+ );
+ });
+ });
+
+ describe("buildSnippet", () => {
+ it("wraps blocks in an iframe with start/duration", () => {
+ const snip = buildSnippet(BLOCK_ITEM, "src/scenes/my-block.html");
+ expect(snip).toContain('src="src/scenes/my-block.html"');
+ expect(snip).toContain('data-duration="6"');
+ });
+
+ it("emits a paste hint for components", () => {
+ const snip = buildSnippet(COMPONENT_ITEM, "src/fx/my-component/my-component.html");
+ expect(snip).toContain("paste from");
+ expect(snip).toContain("my-component.html");
+ });
+
+ it("returns empty string for examples", () => {
+ expect(buildSnippet(EXAMPLE_ITEM, "index.html")).toBe("");
+ });
+ });
+});
+
+describe("runAdd (integration, mocked registry)", () => {
+ beforeEach(() => mockFetch());
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("installs a block into the default compositions/ path and returns the snippet", async () => {
+ const dir = tmp();
+ try {
+ // Write hyperframes.json so runAdd uses our unique baseUrl.
+ const baseUrl = uniqueBase();
+ const cfg = {
+ $schema: "https://hyperframes.heygen.com/schema/hyperframes.json",
+ registry: baseUrl,
+ paths: { blocks: "compositions", components: "compositions/components", assets: "assets" },
+ };
+ writeFileSync(join(dir, "hyperframes.json"), JSON.stringify(cfg), "utf-8");
+
+ const result = await runAdd({ name: "my-block", projectDir: dir, skipClipboard: true });
+ expect(result.ok).toBe(true);
+ expect(result.name).toBe("my-block");
+ expect(result.type).toBe("hyperframes:block");
+ expect(result.written).toHaveLength(1);
+ expect(existsSync(join(dir, "compositions/my-block.html"))).toBe(true);
+ expect(readFileSync(join(dir, "compositions/my-block.html"), "utf-8")).toContain(
+ "my-block.html",
+ );
+ expect(result.snippet).toContain("compositions/my-block.html");
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("remaps component targets per hyperframes.json paths.components", async () => {
+ const dir = tmp();
+ try {
+ const baseUrl = uniqueBase();
+ const cfg = {
+ $schema: "https://hyperframes.heygen.com/schema/hyperframes.json",
+ registry: baseUrl,
+ paths: { blocks: "compositions", components: "src/fx", assets: "assets" },
+ };
+ writeFileSync(join(dir, "hyperframes.json"), JSON.stringify(cfg), "utf-8");
+
+ const result = await runAdd({
+ name: "my-component",
+ projectDir: dir,
+ skipClipboard: true,
+ });
+ expect(result.written.length).toBe(2);
+ expect(existsSync(join(dir, "src/fx/my-component/my-component.html"))).toBe(true);
+ expect(existsSync(join(dir, "src/fx/my-component/my-component.css"))).toBe(true);
+ expect(result.snippet).toContain("src/fx/my-component/my-component.html");
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("throws AddError with code 'example-type' when asked to add an example", async () => {
+ const dir = tmp();
+ try {
+ const baseUrl = uniqueBase();
+ writeFileSync(
+ join(dir, "hyperframes.json"),
+ JSON.stringify({
+ registry: baseUrl,
+ paths: {
+ blocks: "compositions",
+ components: "compositions/components",
+ assets: "assets",
+ },
+ }),
+ "utf-8",
+ );
+
+ await expect(
+ runAdd({ name: "my-example", projectDir: dir, skipClipboard: true }),
+ ).rejects.toMatchObject({
+ code: "example-type",
+ });
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("throws AddError with code 'unknown-item' for a missing name", async () => {
+ const dir = tmp();
+ try {
+ const baseUrl = uniqueBase();
+ writeFileSync(
+ join(dir, "hyperframes.json"),
+ JSON.stringify({
+ registry: baseUrl,
+ paths: {
+ blocks: "compositions",
+ components: "compositions/components",
+ assets: "assets",
+ },
+ }),
+ "utf-8",
+ );
+
+ await expect(
+ runAdd({ name: "nope", projectDir: dir, skipClipboard: true }),
+ ).rejects.toBeInstanceOf(AddError);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts
new file mode 100644
index 000000000..d04478241
--- /dev/null
+++ b/packages/cli/src/commands/add.ts
@@ -0,0 +1,232 @@
+import { defineCommand } from "citty";
+import type { Example } from "./_examples.js";
+
+export const examples: Example[] = [
+ ["Add a block to the current project", "hyperframes add claude-code-window"],
+ ["Add a component effect", "hyperframes add shader-wipe"],
+ ["Target a specific project directory", "hyperframes add shader-wipe --dir ./my-video"],
+ ["Skip the clipboard copy (CI/headless)", "hyperframes add shader-wipe --no-clipboard"],
+];
+
+import { existsSync } from "node:fs";
+import { resolve, relative } from "node:path";
+import { ITEM_TYPE_DIRS, type RegistryItem } from "@hyperframes/core";
+import { c } from "../ui/colors.js";
+import { installItem, resolveItem } from "../registry/index.js";
+import {
+ DEFAULT_PROJECT_CONFIG,
+ loadProjectConfig,
+ projectConfigPath,
+ writeProjectConfig,
+} from "../utils/projectConfig.js";
+import { copyToClipboard } from "../utils/clipboard.js";
+
+// ── Target-path resolution ──────────────────────────────────────────────────
+// `registry-item.json` files specify `target` paths relative to the project
+// root. For blocks and components we override the default path with the
+// user's `hyperframes.json#paths` so a project can reshape its layout
+// without editing every item's manifest.
+
+export function remapTarget(
+ item: RegistryItem,
+ originalTarget: string,
+ paths: { blocks: string; components: string },
+): string {
+ if (item.type === "hyperframes:block") {
+ // Anchored to the default target prefix from DEFAULT_PROJECT_CONFIG.paths.blocks.
+ // Targets that don't start with "compositions/" pass through unchanged.
+ // Strip trailing slashes to prevent double-slash in output.
+ const blocksDir = paths.blocks.replace(/\/+$/, "");
+ return originalTarget.replace(/^compositions\//, `${blocksDir}/`);
+ }
+ if (item.type === "hyperframes:component") {
+ // Anchored to the default target prefix from DEFAULT_PROJECT_CONFIG.paths.components.
+ const componentsDir = paths.components.replace(/\/+$/, "");
+ return originalTarget.replace(/^compositions\/components\//, `${componentsDir}/`);
+ }
+ // Examples are installed by `init`, not `add` — no remapping.
+ return originalTarget;
+}
+
+// ── Include-snippet builders ────────────────────────────────────────────────
+// Shown to the user after install so they know how to wire the item into
+// their host composition. Copied to clipboard by default.
+
+export function buildSnippet(item: RegistryItem, relativeTarget: string): string {
+ if (item.type === "hyperframes:block") {
+ // data-start omitted — adjust to your timeline position after pasting.
+ return ``;
+ }
+ if (item.type === "hyperframes:component") {
+ return ``;
+ }
+ return "";
+}
+
+// ── Core runner (tested) ────────────────────────────────────────────────────
+
+export interface RunAddArgs {
+ name: string;
+ projectDir: string;
+ skipClipboard?: boolean;
+}
+
+export interface RunAddResult {
+ ok: true;
+ name: string;
+ type: RegistryItem["type"];
+ typeDir: string;
+ written: string[];
+ snippet: string;
+ clipboardCopied: boolean;
+}
+
+export class AddError extends Error {
+ constructor(
+ message: string,
+ public readonly code: "unknown-item" | "wrong-type" | "install-failed" | "example-type",
+ ) {
+ super(message);
+ this.name = "AddError";
+ }
+}
+
+export async function runAdd(opts: RunAddArgs): Promise {
+ const projectDir = resolve(opts.projectDir);
+
+ // 1. Load (or write default) project config.
+ let config = loadProjectConfig(projectDir);
+ const hasConfig = existsSync(projectConfigPath(projectDir));
+ if (!hasConfig && existsSync(resolve(projectDir, "index.html"))) {
+ writeProjectConfig(projectDir, DEFAULT_PROJECT_CONFIG);
+ config = DEFAULT_PROJECT_CONFIG;
+ }
+
+ // 2. Resolve the item from the registry.
+ let item: RegistryItem;
+ try {
+ item = await resolveItem(opts.name, { baseUrl: config.registry });
+ } catch (err) {
+ throw new AddError(err instanceof Error ? err.message : String(err), "unknown-item");
+ }
+
+ if (item.type === "hyperframes:example") {
+ throw new AddError(
+ `"${item.name}" is an example — use \`hyperframes init --example ${item.name}\` instead.`,
+ "example-type",
+ );
+ }
+
+ // 3. Remap targets per project config.
+ const remappedFiles = item.files.map((f) => ({
+ ...f,
+ target: remapTarget(item, f.target, config.paths),
+ }));
+ const itemForInstall: RegistryItem = { ...item, files: remappedFiles };
+
+ // 4. Install — the installer validates every target before any write.
+ let written: string[];
+ try {
+ const result = await installItem(itemForInstall, {
+ destDir: projectDir,
+ baseUrl: config.registry,
+ });
+ written = result.written;
+ } catch (err) {
+ throw new AddError(
+ `Install failed: ${err instanceof Error ? err.message : String(err)}`,
+ "install-failed",
+ );
+ }
+
+ // 5. Build include snippet + clipboard copy.
+ const primaryFile =
+ itemForInstall.files.find((f) => f.type === "hyperframes:snippet") ??
+ itemForInstall.files.find((f) => f.type === "hyperframes:composition") ??
+ itemForInstall.files[0];
+ const snippetTargetRel = primaryFile?.target ?? "";
+ const snippet = buildSnippet(item, snippetTargetRel);
+ const clipboardCopied = !opts.skipClipboard && snippet ? copyToClipboard(snippet) : false;
+
+ return {
+ ok: true,
+ name: item.name,
+ type: item.type,
+ typeDir: ITEM_TYPE_DIRS[item.type],
+ written,
+ snippet,
+ clipboardCopied,
+ };
+}
+
+// ── Command ─────────────────────────────────────────────────────────────────
+
+export default defineCommand({
+ meta: {
+ name: "add",
+ description: "Install a block or component from the registry into this project",
+ },
+ args: {
+ name: {
+ type: "positional",
+ description: "Registry item name (e.g. claude-code-window, shader-wipe)",
+ required: true,
+ },
+ dir: {
+ type: "string",
+ description: "Project directory (defaults to the current working directory)",
+ },
+ "no-clipboard": {
+ type: "boolean",
+ description: "Skip copying the include snippet to the clipboard",
+ },
+ json: {
+ type: "boolean",
+ description: "Print a machine-readable summary (written files + snippet) to stdout",
+ },
+ },
+ async run({ args }) {
+ const projectDir = resolve(args.dir ?? process.cwd());
+ const json = args.json === true;
+ const skipClipboard = args["no-clipboard"] === true;
+ const hasConfigBefore = existsSync(projectConfigPath(projectDir));
+
+ try {
+ const result = await runAdd({ name: args.name, projectDir, skipClipboard });
+ const wroteConfig = !hasConfigBefore && existsSync(projectConfigPath(projectDir));
+
+ if (json) {
+ console.log(JSON.stringify(result));
+ return;
+ }
+
+ if (wroteConfig) {
+ console.log(c.dim(`Wrote default ${projectConfigPath(projectDir)}`));
+ }
+ console.log("");
+ console.log(`${c.success("✓")} Added ${c.accent(result.name)} (${result.type})`);
+ for (const file of result.written) {
+ console.log(` ${c.dim(relative(projectDir, file))}`);
+ }
+ if (result.snippet) {
+ console.log("");
+ console.log(c.dim("Include snippet:"));
+ console.log(` ${result.snippet}`);
+ console.log("");
+ console.log(
+ result.clipboardCopied
+ ? c.dim("Copied to clipboard — paste into your host composition.")
+ : c.dim("Paste the snippet above into your host composition."),
+ );
+ }
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ if (json) {
+ console.log(JSON.stringify({ ok: false, error: msg }));
+ } else {
+ console.error(c.error(msg));
+ }
+ process.exit(1);
+ }
+ },
+});
diff --git a/packages/cli/src/commands/docs.ts b/packages/cli/src/commands/docs.ts
index 29e462bea..cd699a2bd 100644
--- a/packages/cli/src/commands/docs.ts
+++ b/packages/cli/src/commands/docs.ts
@@ -22,9 +22,9 @@ const TOPICS: Record = {
file: "data-attributes.md",
description: "Timing, media, and composition attributes",
},
- templates: {
- file: "templates.md",
- description: "Built-in project templates for init",
+ examples: {
+ file: "examples.md",
+ description: "Built-in project examples for init",
},
rendering: {
file: "rendering.md",
diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts
index 198e3de04..132958fa1 100644
--- a/packages/cli/src/commands/init.test.ts
+++ b/packages/cli/src/commands/init.test.ts
@@ -41,7 +41,7 @@ describe("hyperframes init flag rename", () => {
const target = join(dir, "proj");
try {
const res = runInit([target, "--template", "blank", "--non-interactive", "--skip-skills"]);
- expect(res.status).not.toBe(0);
+ expect(res.status).toBe(1);
expect(res.stderr).toContain("--template flag was renamed to --example");
expect(res.stderr).toContain(`--example "blank"`);
expect(existsSync(target)).toBe(false);
diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts
index 595c631bc..17fa7c4be 100644
--- a/packages/cli/src/commands/init.ts
+++ b/packages/cli/src/commands/init.ts
@@ -338,6 +338,14 @@ async function scaffoldProject(
"utf-8",
);
+ // Write hyperframes.json so `hyperframes add` knows which registry to use
+ // and where to drop block/component files. Overwritten only if absent.
+ if (!existsSync(resolve(destDir, "hyperframes.json"))) {
+ const { writeProjectConfig, DEFAULT_PROJECT_CONFIG } =
+ await import("../utils/projectConfig.js");
+ writeProjectConfig(destDir, DEFAULT_PROJECT_CONFIG);
+ }
+
// Copy shared files (CLAUDE.md, AGENTS.md) for AI agent context
const sharedDir = getSharedTemplateDir();
if (existsSync(sharedDir)) {
@@ -375,6 +383,7 @@ export default defineCommand({
template: {
type: "string",
description: "[renamed] Use --example instead.",
+ alias: "t",
hidden: true,
},
video: {
diff --git a/packages/cli/src/docs/templates.md b/packages/cli/src/docs/examples.md
similarity index 87%
rename from packages/cli/src/docs/templates.md
rename to packages/cli/src/docs/examples.md
index 7e8ec2c32..e79e96073 100644
--- a/packages/cli/src/docs/templates.md
+++ b/packages/cli/src/docs/examples.md
@@ -16,4 +16,4 @@ Video element with trimming, audio, and track controls. Starting point for video
## Custom Templates
-Any directory with an `index.html` can serve as a template. Copy it manually or build your own init workflow.
+Any directory with an `index.html` can serve as an example. Copy it manually or build your own init workflow.
diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts
index 760abdb79..e10ee47a3 100644
--- a/packages/cli/src/help.ts
+++ b/packages/cli/src/help.ts
@@ -20,6 +20,7 @@ const GROUPS: Group[] = [
title: "Getting Started",
commands: [
["init", "Scaffold a new composition project"],
+ ["add", "Install a block or component from the registry"],
["preview", "Start the studio for previewing compositions"],
["render", "Render a composition to MP4 or WebM"],
],
diff --git a/packages/cli/src/registry/remote.ts b/packages/cli/src/registry/remote.ts
index 99a5e6afd..49b254a4c 100644
--- a/packages/cli/src/registry/remote.ts
+++ b/packages/cli/src/registry/remote.ts
@@ -48,6 +48,7 @@ function cachePath(baseUrl: string, key: string): string {
function readCache(path: string): T | undefined {
try {
const entry = JSON.parse(readFileSync(path, "utf-8")) as CacheEntry;
+ if (typeof entry.fetchedAt !== "number") return undefined;
if (Date.now() - entry.fetchedAt > CACHE_TTL_MS) return undefined;
return entry.data;
} catch {
@@ -122,6 +123,10 @@ export async function fetchItemFile(
destPath: string,
baseUrl: string = DEFAULT_REGISTRY_URL,
): Promise {
+ // Reject path-traversal in file.path (mirrors assertSafeTarget for file.target).
+ if (/(^|[/\\])\.\.([/\\]|$)/.test(file.path)) {
+ throw new Error(`Unsafe file.path "${file.path}": path segments may not contain "..".`);
+ }
const url = `${baseUrl}/${ITEM_TYPE_DIRS[item.type]}/${item.name}/${file.path}`;
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
if (!res.ok) {
diff --git a/packages/cli/src/registry/resolver.ts b/packages/cli/src/registry/resolver.ts
index e45b2ba2a..9db4e04d9 100644
--- a/packages/cli/src/registry/resolver.ts
+++ b/packages/cli/src/registry/resolver.ts
@@ -63,7 +63,14 @@ export async function loadAllItems(
return items;
}
-/** Resolve a single item by name. Throws if unknown or unreachable. */
+/**
+ * Resolve a single item by name. Throws if unknown or unreachable.
+ *
+ * TODO: walk registryDependencies transitively and return a topo-sorted
+ * list of items. Today examples have no deps so this returns a single item.
+ * Blocks and components will need transitive resolution once they ship with
+ * deps (seed items in Phase B).
+ */
export async function resolveItem(
name: string,
options: ResolveOptions = {},
diff --git a/packages/cli/src/templates/_shared/CLAUDE.md b/packages/cli/src/templates/_shared/CLAUDE.md
index 7a65b2955..f6b5622d9 100644
--- a/packages/cli/src/templates/_shared/CLAUDE.md
+++ b/packages/cli/src/templates/_shared/CLAUDE.md
@@ -33,7 +33,7 @@ npx hyperframes docs # reference docs in terminal
npx hyperframes docs
```
-Topics: `data-attributes`, `gsap`, `compositions`, `rendering`, `templates`, `troubleshooting`
+Topics: `data-attributes`, `gsap`, `compositions`, `rendering`, `examples`, `troubleshooting`
**For full documentation**, discover pages via the machine-readable index — do NOT guess URLs:
diff --git a/packages/cli/src/templates/remote.ts b/packages/cli/src/templates/remote.ts
index 7d5c17291..ec1655a18 100644
--- a/packages/cli/src/templates/remote.ts
+++ b/packages/cli/src/templates/remote.ts
@@ -46,7 +46,7 @@ export async function fetchRemoteTemplate(templateId: string, destDir: string):
// Safety check — an item with no index.html isn't a valid example.
if (!existsSync(join(destDir, "index.html"))) {
throw new Error(
- `Template "${templateId}" installed but missing index.html. The registry item may be malformed.`,
+ `Example "${templateId}" installed but missing index.html. The registry item may be malformed.`,
);
}
}
diff --git a/packages/cli/src/utils/clipboard.ts b/packages/cli/src/utils/clipboard.ts
new file mode 100644
index 000000000..fd4b962e6
--- /dev/null
+++ b/packages/cli/src/utils/clipboard.ts
@@ -0,0 +1,56 @@
+/**
+ * Minimal cross-platform clipboard copy. Shells out to the OS tool; gracefully
+ * no-ops when no tool is available (CI, headless SSH, etc.) so callers can
+ * always invoke it without guarding.
+ *
+ * Returns true if the copy succeeded, false otherwise.
+ */
+
+import { spawnSync } from "node:child_process";
+import { platform } from "node:os";
+
+interface ClipboardProvider {
+ cmd: string;
+ args: string[];
+}
+
+function detectProvider(): ClipboardProvider | undefined {
+ const os = platform();
+ if (os === "darwin") {
+ return { cmd: "pbcopy", args: [] };
+ }
+ if (os === "win32") {
+ return { cmd: "clip.exe", args: [] };
+ }
+ // Linux / BSD — pick the first tool that's on PATH.
+ // WSL exposes clip.exe too; prefer it so copies land in the Windows
+ // clipboard where the user actually sees them.
+ const candidates: ClipboardProvider[] = [
+ { cmd: "clip.exe", args: [] },
+ { cmd: "wl-copy", args: [] },
+ { cmd: "xclip", args: ["-selection", "clipboard"] },
+ { cmd: "xsel", args: ["--clipboard", "--input"] },
+ ];
+ for (const p of candidates) {
+ const which = spawnSync("which", [p.cmd], { stdio: "ignore" });
+ if (which.status === 0) return p;
+ }
+ return undefined;
+}
+
+let cachedProvider: ClipboardProvider | undefined | null = null;
+
+export function copyToClipboard(text: string): boolean {
+ if (cachedProvider === null) cachedProvider = detectProvider();
+ const provider = cachedProvider;
+ if (!provider) return false;
+ try {
+ const res = spawnSync(provider.cmd, provider.args, {
+ input: text,
+ encoding: "utf-8",
+ });
+ return res.status === 0;
+ } catch {
+ return false;
+ }
+}
diff --git a/packages/cli/src/utils/projectConfig.test.ts b/packages/cli/src/utils/projectConfig.test.ts
new file mode 100644
index 000000000..c3a874ef9
--- /dev/null
+++ b/packages/cli/src/utils/projectConfig.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, it } from "vitest";
+import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+ DEFAULT_PROJECT_CONFIG,
+ loadProjectConfig,
+ normalizeConfig,
+ projectConfigPath,
+ readProjectConfig,
+ writeProjectConfig,
+ PROJECT_CONFIG_FILENAME,
+} from "./projectConfig.js";
+
+function tmp(): string {
+ return mkdtempSync(join(tmpdir(), "hf-cfg-test-"));
+}
+
+describe("projectConfig", () => {
+ describe("write + read round-trip", () => {
+ it("writes the default config and reads it back", () => {
+ const dir = tmp();
+ try {
+ writeProjectConfig(dir);
+ const read = readProjectConfig(dir);
+ expect(read).toEqual(DEFAULT_PROJECT_CONFIG);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("writes a custom config and reads it back verbatim", () => {
+ const dir = tmp();
+ try {
+ const custom = {
+ $schema: DEFAULT_PROJECT_CONFIG.$schema,
+ registry: "https://example.com/my-registry",
+ paths: { blocks: "src/blocks", components: "src/fx", assets: "media" },
+ };
+ writeProjectConfig(dir, custom);
+ const read = readProjectConfig(dir);
+ expect(read).toEqual(custom);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+ });
+
+ describe("normalizeConfig", () => {
+ it("fills in defaults for missing fields", () => {
+ const result = normalizeConfig({ registry: "https://alt.example.com" });
+ expect(result.registry).toBe("https://alt.example.com");
+ expect(result.paths).toEqual(DEFAULT_PROJECT_CONFIG.paths);
+ expect(result.$schema).toBe(DEFAULT_PROJECT_CONFIG.$schema);
+ });
+
+ it("preserves partial paths objects", () => {
+ const result = normalizeConfig({ paths: { blocks: "x" } as unknown as never });
+ expect(result.paths.blocks).toBe("x");
+ expect(result.paths.components).toBe(DEFAULT_PROJECT_CONFIG.paths.components);
+ expect(result.paths.assets).toBe(DEFAULT_PROJECT_CONFIG.paths.assets);
+ });
+ });
+
+ describe("readProjectConfig", () => {
+ it("returns undefined when the file is absent", () => {
+ const dir = tmp();
+ try {
+ expect(readProjectConfig(dir)).toBeUndefined();
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("returns undefined when the file is corrupt", () => {
+ const dir = tmp();
+ try {
+ writeFileSync(projectConfigPath(dir), "{ not valid json", "utf-8");
+ expect(readProjectConfig(dir)).toBeUndefined();
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("normalizes a partial on-disk config", () => {
+ const dir = tmp();
+ try {
+ writeFileSync(
+ projectConfigPath(dir),
+ JSON.stringify({ registry: "https://only-this.example.com" }),
+ "utf-8",
+ );
+ const read = readProjectConfig(dir);
+ expect(read?.registry).toBe("https://only-this.example.com");
+ expect(read?.paths).toEqual(DEFAULT_PROJECT_CONFIG.paths);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+ });
+
+ describe("loadProjectConfig", () => {
+ it("returns defaults when no config file exists", () => {
+ const dir = tmp();
+ try {
+ expect(loadProjectConfig(dir)).toEqual(DEFAULT_PROJECT_CONFIG);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+ });
+
+ describe("writeProjectConfig", () => {
+ it("writes to hyperframes.json at the project root", () => {
+ const dir = tmp();
+ try {
+ writeProjectConfig(dir);
+ const path = join(dir, PROJECT_CONFIG_FILENAME);
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
+ expect(parsed.registry).toBe(DEFAULT_PROJECT_CONFIG.registry);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+ });
+});
diff --git a/packages/cli/src/utils/projectConfig.ts b/packages/cli/src/utils/projectConfig.ts
new file mode 100644
index 000000000..b4c0451a4
--- /dev/null
+++ b/packages/cli/src/utils/projectConfig.ts
@@ -0,0 +1,94 @@
+/**
+ * Read and write `hyperframes.json` — the per-project config that tells
+ * `hyperframes add` which registry to pull items from and where to drop them
+ * in the user's project tree.
+ *
+ * The file is created by `hyperframes init` and optionally edited by users to
+ * point at custom registries or reshape their project layout.
+ */
+
+import { readFileSync, writeFileSync } from "node:fs";
+import { join, resolve } from "node:path";
+import { DEFAULT_REGISTRY_URL } from "../registry/index.js";
+
+export const PROJECT_CONFIG_FILENAME = "hyperframes.json";
+export const PROJECT_CONFIG_SCHEMA_URL = "https://hyperframes.heygen.com/schema/hyperframes.json";
+
+export interface ProjectConfigPaths {
+ /** Where `hyperframes:block` items land, relative to project root. */
+ blocks: string;
+ /** Where `hyperframes:component` items land, relative to project root. */
+ components: string;
+ /** Where asset files (images, fonts, videos) land, relative to project root. */
+ assets: string;
+}
+
+export interface ProjectConfig {
+ $schema?: string;
+ /** Base URL of the registry to pull items from. */
+ registry: string;
+ /** Target paths for each item type. */
+ paths: ProjectConfigPaths;
+}
+
+export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
+ $schema: PROJECT_CONFIG_SCHEMA_URL,
+ registry: DEFAULT_REGISTRY_URL,
+ paths: {
+ blocks: "compositions",
+ components: "compositions/components",
+ assets: "assets",
+ },
+};
+
+/** Path to the config file for a project rooted at `projectDir`. */
+export function projectConfigPath(projectDir: string): string {
+ return join(resolve(projectDir), PROJECT_CONFIG_FILENAME);
+}
+
+/** Read `hyperframes.json` from a project directory. */
+export function readProjectConfig(projectDir: string): ProjectConfig | undefined {
+ const path = projectConfigPath(projectDir);
+ try {
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as Partial;
+ return normalizeConfig(parsed);
+ } catch {
+ // Missing file or corrupt JSON → no config.
+ return undefined;
+ }
+}
+
+/**
+ * Return a valid config — fills in any missing fields with defaults. Used
+ * when a user's config file is present but partial (e.g. they only set
+ * `registry` and rely on default paths).
+ */
+export function normalizeConfig(partial: Partial): ProjectConfig {
+ return {
+ $schema: partial.$schema ?? DEFAULT_PROJECT_CONFIG.$schema,
+ registry: partial.registry ?? DEFAULT_PROJECT_CONFIG.registry,
+ paths: {
+ blocks: partial.paths?.blocks ?? DEFAULT_PROJECT_CONFIG.paths.blocks,
+ components: partial.paths?.components ?? DEFAULT_PROJECT_CONFIG.paths.components,
+ assets: partial.paths?.assets ?? DEFAULT_PROJECT_CONFIG.paths.assets,
+ },
+ };
+}
+
+/** Write `hyperframes.json` to a project directory. Overwrites if present. */
+export function writeProjectConfig(
+ projectDir: string,
+ config: ProjectConfig = DEFAULT_PROJECT_CONFIG,
+): void {
+ const path = projectConfigPath(projectDir);
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n", "utf-8");
+}
+
+/**
+ * Load the project config for the given directory, falling back to defaults
+ * if missing. Mutates nothing on disk. Used by commands that want to operate
+ * with or without an explicit config.
+ */
+export function loadProjectConfig(projectDir: string): ProjectConfig {
+ return readProjectConfig(projectDir) ?? DEFAULT_PROJECT_CONFIG;
+}
diff --git a/packages/core/schemas/registry-item.json b/packages/core/schemas/registry-item.json
index 1d0676915..ba72da93c 100644
--- a/packages/core/schemas/registry-item.json
+++ b/packages/core/schemas/registry-item.json
@@ -29,7 +29,7 @@
},
"tags": {
"type": "array",
- "items": { "type": "string" }
+ "items": { "type": "string", "minLength": 1 }
},
"author": {
"type": "string",
diff --git a/scripts/generate-registry-items.ts b/scripts/generate-registry-items.ts
index ab33ff17e..d06a4b1c0 100644
--- a/scripts/generate-registry-items.ts
+++ b/scripts/generate-registry-items.ts
@@ -47,9 +47,30 @@ interface LegacyManifest {
}
function readLegacyManifest(): LegacyTemplateEntry[] {
- const raw = readFileSync(legacyManifestPath, "utf-8");
- const parsed = JSON.parse(raw) as LegacyManifest;
- return parsed.templates;
+ try {
+ const raw = readFileSync(legacyManifestPath, "utf-8");
+ const parsed = JSON.parse(raw) as LegacyManifest;
+ return parsed.templates;
+ } catch {
+ // templates.json was the bootstrap source and has been deleted. Fall back
+ // to scanning existing registry-item.json files and reconstructing entries.
+ return scanExistingItems();
+ }
+}
+
+function scanExistingItems(): LegacyTemplateEntry[] {
+ const entries: LegacyTemplateEntry[] = [];
+ for (const dir of readdirSync(examplesDir, { withFileTypes: true })) {
+ if (!dir.isDirectory()) continue;
+ const itemPath = join(examplesDir, dir.name, "registry-item.json");
+ try {
+ const item = JSON.parse(readFileSync(itemPath, "utf-8")) as RegistryItem;
+ entries.push({ id: item.name, label: item.title, hint: item.description, bundled: false });
+ } catch {
+ // No manifest — skip.
+ }
+ }
+ return entries;
}
function extractAttr(html: string, attr: string): string | undefined {
diff --git a/skills/hyperframes-cli/SKILL.md b/skills/hyperframes-cli/SKILL.md
index 414fd3ba9..ba14cfe64 100644
--- a/skills/hyperframes-cli/SKILL.md
+++ b/skills/hyperframes-cli/SKILL.md
@@ -21,7 +21,7 @@ Lint before preview — catches missing `data-composition-id`, overlapping track
```bash
npx hyperframes init my-video # interactive wizard
-npx hyperframes init my-video --template warm-grain # pick a template
+npx hyperframes init my-video --example warm-grain # pick an example
npx hyperframes init my-video --video clip.mp4 # with video file
npx hyperframes init my-video --audio track.mp3 # with audio file
npx hyperframes init my-video --non-interactive # skip prompts (CI/agents)