From 13ab1932adc4d3ccee87fae34cee3cc15dfbe257 Mon Sep 17 00:00:00 2001 From: James Russo Date: Tue, 14 Apr 2026 16:46:42 -0700 Subject: [PATCH] feat(cli): catalog browser command (#271) Adds `hyperframes catalog` for browsing the registry: - Default: non-interactive table output (agent-friendly) - --type block/component and --tag filters - --json for machine-readable output - --human-friendly for interactive picker that installs on select Registered in cli.ts, help.ts, documented in docs/packages/cli.mdx. --- docs/packages/cli.mdx | 28 +++++ packages/cli/src/cli.ts | 1 + packages/cli/src/commands/catalog.ts | 150 +++++++++++++++++++++++++++ packages/cli/src/help.ts | 1 + 4 files changed, 180 insertions(+) create mode 100644 packages/cli/src/commands/catalog.ts diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 6c9120b5d..9ec9f0013 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -205,6 +205,34 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ Trying `add` with an example's name (e.g. `hyperframes add warm-grain`) emits a clear error pointing you at `init --example`. + ### `catalog` + + Browse the registry — list available blocks and components with optional filters: + + ```bash + # List everything (default: table output) + npx hyperframes catalog + + # Filter by type or tag + npx hyperframes catalog --type block + npx hyperframes catalog --type block --tag social + + # Machine-readable JSON + npx hyperframes catalog --json + + # Interactive picker — select to install + npx hyperframes catalog --human-friendly + ``` + + | Flag | Description | + |------|-------------| + | `--type` | Filter by `block` or `component` | + | `--tag` | Filter by tag (e.g. `social`, `transition`, `text`) | + | `--json` | Print matching items as JSON (non-interactive) | + | `--human-friendly` | Interactive picker — select an item to install it | + + Default output is a table listing name, type, description, and tags — designed for agents to parse. `--json` produces structured output. `--human-friendly` opens an interactive picker that runs `add` on selection. + ### `compositions` List all compositions in the current project: diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 5a19972f2..43c2e836e 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -26,6 +26,7 @@ const isHelp = process.argv.includes("--help") || process.argv.includes("-h"); const subCommands = { init: () => import("./commands/init.js").then((m) => m.default), add: () => import("./commands/add.js").then((m) => m.default), + catalog: () => import("./commands/catalog.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/catalog.ts b/packages/cli/src/commands/catalog.ts new file mode 100644 index 000000000..43d3c949c --- /dev/null +++ b/packages/cli/src/commands/catalog.ts @@ -0,0 +1,150 @@ +import { defineCommand } from "citty"; +import type { Example } from "./_examples.js"; + +export const examples: Example[] = [ + ["List all blocks and components", "hyperframes catalog"], + ["List blocks only", "hyperframes catalog --type block"], + ["Filter by tag", "hyperframes catalog --type block --tag social"], + ["Machine-readable JSON", "hyperframes catalog --json"], + ["Interactive picker (install on select)", "hyperframes catalog --human-friendly"], +]; + +import * as clack from "@clack/prompts"; +import { type ItemType } from "@hyperframes/core"; +import { c } from "../ui/colors.js"; +import { listRegistryItems, loadAllItems } from "../registry/resolver.js"; +import { loadProjectConfig, DEFAULT_PROJECT_CONFIG } from "../utils/projectConfig.js"; +import { resolve } from "node:path"; +import { runAdd } from "./add.js"; + +export default defineCommand({ + meta: { + name: "catalog", + description: "Browse and install blocks and components from the registry", + }, + args: { + type: { + type: "string", + description: 'Filter by type: "block" or "component"', + }, + tag: { + type: "string", + description: "Filter by tag (e.g. social, transition, text)", + }, + json: { + type: "boolean", + description: "Print matching items as JSON to stdout", + }, + "human-friendly": { + type: "boolean", + description: "Interactive picker — select an item to install", + }, + }, + async run({ args }) { + const json = args.json === true; + const interactive = args["human-friendly"] === true; + const dir = resolve(process.cwd()); + const config = loadProjectConfig(dir) ?? DEFAULT_PROJECT_CONFIG; + + let typeFilter: ItemType | undefined; + if (args.type === "block") typeFilter = "hyperframes:block"; + else if (args.type === "component") typeFilter = "hyperframes:component"; + else if (args.type) { + console.error(`Invalid --type: "${args.type}". Use "block" or "component".`); + process.exit(1); + } + + const entries = await listRegistryItems(typeFilter ? { type: typeFilter } : undefined, { + baseUrl: config.registry, + }); + const filtered = entries.filter((e) => e.type !== "hyperframes:example"); + + if (filtered.length === 0) { + if (json) console.log("[]"); + else console.log("No items found in registry."); + return; + } + + const items = await loadAllItems(filtered, { baseUrl: config.registry }); + + const tagFilter = args.tag?.toLowerCase(); + const matching = tagFilter + ? items.filter((item) => item.tags?.some((t) => t.toLowerCase() === tagFilter)) + : items; + + if (matching.length === 0) { + if (json) console.log("[]"); + else console.log(`No items match tag "${args.tag}".`); + return; + } + + if (json) { + const output = matching.map((item) => ({ + name: item.name, + type: item.type.replace("hyperframes:", ""), + title: item.title, + description: item.description, + tags: item.tags ?? [], + ...("dimensions" in item && item.dimensions ? { dimensions: item.dimensions } : {}), + ...("duration" in item && item.duration ? { duration: item.duration } : {}), + })); + console.log(JSON.stringify(output, null, 2)); + return; + } + + if (interactive) { + const options = matching.map((item) => ({ + value: item.name, + label: item.name, + hint: item.description, + })); + + const selected = await clack.select({ + message: `${matching.length} items available — pick one to install`, + options, + }); + + if (clack.isCancel(selected)) { + clack.cancel("Cancelled."); + process.exit(0); + } + + const result = await runAdd({ + name: selected as string, + projectDir: dir, + skipClipboard: false, + }); + + console.log(""); + console.log(`${c.success("✓")} Installed ${c.accent(result.name)} (${result.type})`); + for (const file of result.written) { + const rel = file.replace(dir + "/", ""); + console.log(` ${c.dim(rel)}`); + } + if (result.snippet) { + console.log(""); + console.log(c.dim("Include snippet:")); + console.log(` ${result.snippet}`); + } + return; + } + + const NAME_COL = 28; + const TYPE_COL = 12; + console.log( + `${c.bold("Name".padEnd(NAME_COL))}${c.bold("Type".padEnd(TYPE_COL))}${c.bold("Description")}`, + ); + console.log("-".repeat(80)); + + for (const item of matching) { + const type = item.type.replace("hyperframes:", ""); + const tags = item.tags?.length ? c.dim(` [${item.tags.join(", ")}]`) : ""; + console.log( + `${c.cyan(item.name.padEnd(NAME_COL))}${type.padEnd(TYPE_COL)}${item.description}${tags}`, + ); + } + + console.log(""); + console.log(c.dim(`${matching.length} items. Run "hyperframes add " to install.`)); + }, +}); diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index e10ee47a3..fbd96baae 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -21,6 +21,7 @@ const GROUPS: Group[] = [ commands: [ ["init", "Scaffold a new composition project"], ["add", "Install a block or component from the registry"], + ["catalog", "Browse and install blocks and components"], ["preview", "Start the studio for previewing compositions"], ["render", "Render a composition to MP4 or WebM"], ],