feat(cli): add --tag flag to hyperframes add for bulk install

Install all registry blocks matching a tag in one command:

  hyperframes add --tag vfx       # installs all 7 VFX blocks
  hyperframes add --tag captions  # installs all 5 caption blocks

The resolver loads each item's full manifest to check tags, then
installs matching blocks sequentially. Failed items are skipped
with a warning so one broken block doesn't abort the batch.

Also supports JSON output for CI: hyperframes add --tag vfx --json
This commit is contained in:
Miguel Ángel
2026-05-06 00:52:23 -07:00
parent da95f6c7ad
commit d9d4df4265
3 changed files with 93 additions and 3 deletions
+70 -2
View File
@@ -4,6 +4,7 @@ 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"],
["Add all blocks with a tag", "hyperframes add --tag vfx"],
["Target a specific project directory", "hyperframes add shader-wipe --dir ./my-video"],
["Skip the clipboard copy (CI/headless)", "hyperframes add shader-wipe --no-clipboard"],
];
@@ -12,7 +13,7 @@ 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 { installItem, resolveItem, resolveItemsByTag } from "../registry/index.js";
import {
DEFAULT_PROJECT_CONFIG,
loadProjectConfig,
@@ -174,7 +175,12 @@ export default defineCommand({
name: {
type: "positional",
description: "Registry item name (e.g. claude-code-window, shader-wipe)",
required: true,
required: false,
},
tag: {
type: "string",
alias: "t",
description: "Install all blocks matching a tag (e.g. --tag vfx, --tag captions)",
},
dir: {
type: "string",
@@ -193,8 +199,70 @@ export default defineCommand({
const projectDir = resolve(args.dir ?? process.cwd());
const json = args.json === true;
const skipClipboard = args["no-clipboard"] === true;
const tag = args.tag?.trim();
const hasConfigBefore = existsSync(projectConfigPath(projectDir));
// ── Tag-based bulk install ──────────────────────────────────────────
if (tag) {
let config = loadProjectConfig(projectDir);
if (
!existsSync(projectConfigPath(projectDir)) &&
existsSync(resolve(projectDir, "index.html"))
) {
writeProjectConfig(projectDir, DEFAULT_PROJECT_CONFIG);
config = DEFAULT_PROJECT_CONFIG;
}
let items: Awaited<ReturnType<typeof resolveItemsByTag>>;
try {
items = await resolveItemsByTag(tag, { baseUrl: config.registry });
} 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);
}
if (items.length === 0) {
const msg = `No blocks found with tag "${tag}".`;
if (json) console.log(JSON.stringify({ ok: false, error: msg }));
else console.error(c.error(msg));
process.exit(1);
}
if (!json) {
console.log("");
console.log(
`${c.accent("◆")} Installing ${c.accent(String(items.length))} blocks tagged ${c.accent(tag)}`,
);
}
const results: RunAddResult[] = [];
for (const item of items) {
try {
const result = await runAdd({ name: item.name, projectDir, skipClipboard: true });
results.push(result);
if (!json) console.log(` ${c.success("✓")} ${result.name}`);
} catch {
if (!json) console.log(` ${c.error("✗")} ${item.name} (skipped)`);
}
}
if (json) {
console.log(JSON.stringify({ ok: true, tag, installed: results.map((r) => r.name) }));
} else {
console.log("");
console.log(`${c.success("✓")} Installed ${results.length}/${items.length} blocks`);
}
return;
}
// ── Single item install ────────────────────────────────────────────
if (!args.name) {
console.error(c.error("Provide a block name or use --tag <tag> to install by tag."));
process.exit(1);
}
try {
const result = await runAdd({ name: args.name, projectDir, skipClipboard });
const wroteConfig = !hasConfigBefore && existsSync(projectConfigPath(projectDir));
+7 -1
View File
@@ -5,7 +5,13 @@ export {
fetchItemFile,
} from "./remote.js";
export { listRegistryItems, loadAllItems, resolveItem, type ResolveOptions } from "./resolver.js";
export {
listRegistryItems,
loadAllItems,
resolveItem,
resolveItemsByTag,
type ResolveOptions,
} from "./resolver.js";
export {
installItem,
+16
View File
@@ -87,3 +87,19 @@ export async function resolveItem(
}
return fetchItemManifest(entry.name, entry.type, options.baseUrl);
}
/**
* Resolve all items matching a tag. Loads each item's full manifest to check
* tags (the top-level registry.json only has name+type, not tags). Items that
* fail to load are silently skipped.
*/
export async function resolveItemsByTag(
tag: string,
options: ResolveOptions = {},
): Promise<RegistryItem[]> {
const entries = await listRegistryItems(undefined, options);
const allItems = await loadAllItems(entries, { ...options, onWarn: () => {} });
return allItems.filter(
(item) => "tags" in item && Array.isArray(item.tags) && item.tags.includes(tag),
);
}