From 08fb1de61ff14e73e00fcc9c046e3865c9c40b04 Mon Sep 17 00:00:00 2001 From: James Russo Date: Mon, 13 Apr 2026 21:04:59 -0700 Subject: [PATCH] feat(cli): add command + hyperframes.json (#256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What PR 5/17 of the catalog system rollout. Adds the `hyperframes add` verb for installing blocks and components from the registry into an existing project, plus the `hyperframes.json` project config that tells `add` which registry to use and where to drop files. Stacks on #255. - **`packages/cli/src/commands/add.ts`** — new `hyperframes add ` command. Resolves an item, validates target paths, installs files in parallel, builds an include snippet, copies it to the clipboard. Exposes a testable `runAdd(opts)` function; the citty default wraps it with console output + exit handling - **`packages/cli/src/utils/projectConfig.ts`** — read/write/normalize `hyperframes.json`. Tolerant to missing and partial configs - **`packages/cli/src/utils/clipboard.ts`** — minimal cross-platform clipboard (pbcopy / clip.exe / wl-copy / xclip / xsel). Zero deps. Gracefully no-ops in headless environments - **`packages/cli/src/commands/init.ts`** — write `hyperframes.json` during scaffold if not already present - **`packages/cli/src/cli.ts`** + **`help.ts`** — register `add` under Getting Started (directly below `init`) Design doc: [Hyperframes Catalog System](https://www.notion.so/heygen/Hyperframes-Catalog-System-Design-Plan-341449792c69813f899dcd53b4c0383a). ## UX ```bash # Scaffold a project (now writes hyperframes.json too) npx hyperframes init my-video --example blank cd my-video # Add a block — files land, snippet copied to clipboard npx hyperframes add claude-code-window # ✓ Added claude-code-window (hyperframes:block) # compositions/claude-code-window.html # # Include snippet: # # # Copied to clipboard — paste into your host composition. # Add a component effect npx hyperframes add shader-wipe # Headless / CI — no clipboard, JSON output for tooling npx hyperframes add shader-wipe --no-clipboard --json ``` Running `hyperframes add warm-grain` (an example) errors clearly pointing to `init --example`. ## Docs (bundled in this PR per the tracker principle) - `docs/packages/cli.mdx` — new `add` subsection under Commands (flags, examples, trigger rules) + new `hyperframes.json` section describing the config file shape ## Tests - **`packages/cli/src/commands/add.test.ts`** — 11 tests: - `remapTarget` / `buildSnippet` pure helpers (5 tests) - `runAdd` integration against a mocked `fetch` registry: block install lands files + returns snippet, component install respects `paths.components` remap, example-typed names throw `AddError` with code `example-type`, unknown names throw `AddError` with code `unknown-item` (4 tests plus 2 covering block default path and non-default path preservation) - **`packages/cli/src/utils/projectConfig.test.ts`** — 9 tests: - Write/read round-trip, partial-config normalization, corrupt-file handling, absent-file fallback to defaults, custom paths preserved - **CLI suite:** 92 passed (was 72 on #255, **+20**). Same 4 pre-existing failures unchanged ## Scope decisions - **`init.ts` full port to new resolver deferred.** The original plan bundled a removal of the `packages/cli/src/templates/` compat shim. That's ~300 more lines and isn't required for `add` to work. The compat shim from #254 still functions; a separate cleanup PR handles it - **No ajv runtime schema validation.** Manifests are trusted as schema-valid. Full validation lands when third-party registries arrive (PR 14/15). Path safety is still enforced by the installer's `assertSafeTarget` guard - **Default project paths stay under `compositions/`.** Blocks → `compositions/.html`; components → `compositions/components//`. Users override via `hyperframes.json#paths` ## Breaking / migration **None.** Pure additive — new command, new file types, no existing commands or flags change. `init.ts` now writes `hyperframes.json` but that's a new additional file, not a modification of existing output. ## Stacks on #255 — base branch. When #255 merges, this rebases onto `main`. ## Next in stack PR 6 — `feat(registry): seed block — claude-code-window`. First real registry item. Exercises the full `hyperframes add ` flow end-to-end against a committed item on `main`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/concepts/compositions.mdx | 6 +- docs/contributing.mdx | 2 +- docs/contributing/testing-local-changes.mdx | 2 +- docs/docs.json | 2 +- docs/{templates.mdx => examples.mdx} | 24 +- docs/guides/troubleshooting.mdx | 2 +- docs/packages/cli.mdx | 72 ++++- docs/packages/core.mdx | 2 +- docs/quickstart.mdx | 8 +- docs/snippets/TemplateCard.jsx | 2 +- packages/cli/src/cli.ts | 1 + packages/cli/src/commands/add.test.ts | 269 ++++++++++++++++++ packages/cli/src/commands/add.ts | 232 +++++++++++++++ packages/cli/src/commands/docs.ts | 6 +- packages/cli/src/commands/init.test.ts | 2 +- packages/cli/src/commands/init.ts | 9 + .../src/docs/{templates.md => examples.md} | 2 +- packages/cli/src/help.ts | 1 + packages/cli/src/registry/remote.ts | 5 + packages/cli/src/registry/resolver.ts | 9 +- packages/cli/src/templates/_shared/CLAUDE.md | 2 +- packages/cli/src/templates/remote.ts | 2 +- packages/cli/src/utils/clipboard.ts | 56 ++++ packages/cli/src/utils/projectConfig.test.ts | 126 ++++++++ packages/cli/src/utils/projectConfig.ts | 94 ++++++ packages/core/schemas/registry-item.json | 2 +- scripts/generate-registry-items.ts | 27 +- skills/hyperframes-cli/SKILL.md | 2 +- 28 files changed, 923 insertions(+), 46 deletions(-) rename docs/{templates.mdx => examples.mdx} (92%) create mode 100644 packages/cli/src/commands/add.test.ts create mode 100644 packages/cli/src/commands/add.ts rename packages/cli/src/docs/{templates.md => examples.md} (87%) create mode 100644 packages/cli/src/utils/clipboard.ts create mode 100644 packages/cli/src/utils/projectConfig.test.ts create mode 100644 packages/cli/src/utils/projectConfig.ts 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 ``; + } + 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)