Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
0931ae1
feat(cli): move media-use engine into the CLI
miguel-heygen Sep 20, 2026
b275b0c
fix(cli): replace media-use compatibility asset
miguel-heygen Sep 20, 2026
56dd3b0
fix(cli): replace moved media-use tree
miguel-heygen Sep 20, 2026
4f160c2
fix(cli): copy moved media-use files cleanly
miguel-heygen Sep 20, 2026
626fe94
fix(cli): copy media-use engine files individually
miguel-heygen Sep 20, 2026
be436bc
fix(cli): keep audio helper compatibility import
miguel-heygen Sep 20, 2026
1f64d7b
fix(cli): clear copied skill destinations
miguel-heygen Sep 20, 2026
cf34631
test(cli): preserve moved media-use test paths
miguel-heygen Sep 20, 2026
0f8b1ab
fix(cli): preserve media-use script compatibility imports
miguel-heygen Sep 20, 2026
a23f918
fix(cli): preserve media-use telemetry shim
miguel-heygen Sep 20, 2026
88633b1
fix(cli): resolve packaged media-use LUT assets
miguel-heygen Sep 20, 2026
f7fb7c8
fix(cli): resolve packaged bundled SFX assets
miguel-heygen Sep 20, 2026
99fbfa4
chore(skills): regenerate media-use manifest
miguel-heygen Sep 20, 2026
191e16a
fix(cli): preserve media-use parity coverage
miguel-heygen Sep 20, 2026
41d11d8
fix(cli): cover relocated media-use tests
miguel-heygen Sep 20, 2026
cfd2a62
chore(skills): regenerate media-use manifest
miguel-heygen Sep 20, 2026
13d211b
fix(cli): format bundled media provider
miguel-heygen Sep 20, 2026
7b2112b
fix(ci): run relocated media-use tests with node
miguel-heygen Sep 20, 2026
e0503ec
fix(ci): preserve fallow script exemptions
miguel-heygen Sep 20, 2026
112ea64
fix(ci): scope media-use fallow exemption
miguel-heygen Sep 20, 2026
a5f1f88
refactor(cli): trim media-use essay comments
miguel-heygen Sep 21, 2026
fde4155
feat(cli): index media-use assets for catalog search
miguel-heygen Sep 20, 2026
b0dabe9
feat(catalog): commit media-use search vectors
miguel-heygen Sep 20, 2026
5933fe1
chore(catalog): track media vector metadata
miguel-heygen Sep 20, 2026
51c3178
fix(catalog): validate media vector row order
miguel-heygen Sep 20, 2026
a38ad50
fix(catalog): validate media artifact freshness
miguel-heygen Sep 20, 2026
d36e7ca
fix(catalog): cover media metadata and credits
miguel-heygen Sep 20, 2026
885df58
fix(cli): reject malformed media rows
miguel-heygen Sep 20, 2026
1c3b7d2
fix(catalog): compare complete media rows
miguel-heygen Sep 20, 2026
7c79aba
feat(cli): route media search through media-use
miguel-heygen Sep 20, 2026
58451fc
refactor(cli): trim relocated media-use comments
miguel-heygen Sep 20, 2026
4c16607
fix(cli): make media search runtime-safe
miguel-heygen Sep 21, 2026
79dffe0
fix(cli): preserve sfx fallback diagnostics
miguel-heygen Sep 21, 2026
8d57c52
fix(cli): probe sfx fallback provider explicitly
miguel-heygen Sep 21, 2026
b14b193
chore(skills): regenerate manifest
miguel-heygen Sep 21, 2026
1c5412a
refactor(cli): keep typed media ranker source
miguel-heygen Sep 21, 2026
729dcd9
fix(cli): align media search checks with local-first flow
miguel-heygen Sep 21, 2026
369d1dd
fix(cli): scope vector audit exceptions
miguel-heygen Sep 21, 2026
d784b5e
fix(cli): ship resolver registry beside skill
miguel-heygen Sep 21, 2026
059c24c
fix(cli): keep media-use skill scripts standalone
miguel-heygen Sep 21, 2026
28863b5
test(cli): cover local vector validation
miguel-heygen Sep 21, 2026
6a7e36b
test(cli): correct media vector fixtures
miguel-heygen Sep 21, 2026
72cddf2
fix(cli): prefer exact media keys
miguel-heygen Sep 21, 2026
ee570ae
fix(cli): stem exact media keys
miguel-heygen Sep 21, 2026
615a7ff
test(cli): skip media lint for snippets without media
miguel-heygen Sep 21, 2026
c0e053a
fix(cli): align skills test discovery
miguel-heygen Sep 21, 2026
b889070
fix(cli): close media-use review gaps
miguel-heygen Sep 21, 2026
3654b5f
fix(cli): lower parity check complexity
miguel-heygen Sep 21, 2026
632d7d6
fix(cli): close media-use review seams
miguel-heygen Sep 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@
"packages/engine/tests/**",
"skills/**/test-corpus/**",
"skills/**/scripts/**",
// The relocated media-use .mjs files are agent-invoked scripts outside the
// import graph; keep the exemption scoped to that shipped script type.
"packages/cli/src/media-use/**/*.mjs",
// Agent-invoked motion-graphics tools co-located with their docs (run via
// `node <path>` per grounding/PROTOCOL.md / categories/maps/module.md
// prose), not import-graph reachable.
Expand Down
8 changes: 5 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ jobs:
catalog_index:
- "registry/registry.json"
- "registry/catalog-artifact/**"
- "skills/media-use/audio/assets/sfx/**"
- "scripts/catalog/check-artifact-coverage.ts"
docs_catalog:
- "docs/**"
Expand Down Expand Up @@ -375,8 +376,8 @@ jobs:
# `node:` built-in imports. They aren't part of any workspace package, and
# the main `Test` job's `code` path filter excludes `skills/**`, so without
# this dedicated job they'd never run in CI. Examples:
# * skills/media-use/scripts/resolve.test.mjs
# * skills/media-use/scripts/lib/manifest.test.mjs
# * packages/cli/src/media-use/resolve.test.mjs
# * packages/cli/src/media-use/lib/manifest.test.mjs
# Several of these are regression guards (e.g. shell-injection cases), so
# the whole point is that they fire on PRs that touch skills/.
test-skills:
Expand All @@ -396,11 +397,12 @@ jobs:
# would defeat the whole point of this job).
run: |
set -euo pipefail
mapfile -t SKILLS_TESTS < <(find skills -type f -name "*.test.mjs" | sort)
mapfile -t SKILLS_TESTS < <(find skills packages/cli/src/media-use -type f -name "*.test.mjs" | sort)
if [ "${#SKILLS_TESTS[@]}" -eq 0 ]; then
echo "::error::No skills/**/*.test.mjs files found. Did the layout change?"
exit 1
fi
SKILLS_TESTS+=(scripts/check-media-use-copy-parity.test.mjs)
printf 'Running %d skills test file(s):\n' "${#SKILLS_TESTS[@]}"
printf ' * %s\n' "${SKILLS_TESTS[@]}"
node --test "${SKILLS_TESTS[@]}"
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@
"player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .",
"knip": "knip",
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts && vitest run scripts/catalog/",
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts && vitest run scripts/catalog/",
"typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json",
"test:skills": "node --test 'skills/**/*.test.mjs'",
"test:skills": "node --test 'skills/**/*.test.mjs' 'packages/cli/src/media-use/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",
"package:codex-plugin": "node scripts/package-codex-plugin.mjs",
Expand Down
24 changes: 21 additions & 3 deletions packages/cli/scripts/build-copy.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Cross-platform replacement for the previous `mkdir -p … && cp -r …` shell
// chain, which failed on Windows because `cp` doesn't accept `-r` there.

import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { setTimeout as sleep } from "node:timers/promises";
Expand Down Expand Up @@ -80,15 +80,33 @@ async function main() {
// Skills bundled into the published CLI. Branches don't all carry the same
// skills/ tree (it gets restructured), so each entry is existsSync-guarded:
// a missing skill dir warns + skips instead of crashing the build.
for (const skill of ["hyperframes", "hyperframes-cli", "gsap"]) {
for (const skill of ["hyperframes", "hyperframes-cli", "gsap", "media-use"]) {
const src = join(REPO_ROOT, "skills", skill);
if (!existsSync(src)) {
console.warn(`[build-copy] skill not found, skipping: skills/${skill}`);
continue;
}
copyDir(src, join(DIST, "skills", skill));
const destination = join(DIST, "skills", skill);
rmSync(destination, { recursive: true, force: true });
copyDir(src, destination);
}

// The media-use engine lives with the CLI source, but keeps its published
// skill-relative layout so the moved .mjs tree can run without a rewrite.
const mediaEngine = join(CLI_ROOT, "src", "media-use");
const publishedMediaLib = join(DIST, "skills", "media-use", "scripts", "lib");
rmSync(publishedMediaLib, { recursive: true, force: true });
mkdirSync(publishedMediaLib, { recursive: true });
copyDirContents(join(mediaEngine, "lib"), publishedMediaLib);
cpSync(
join(mediaEngine, "resolve.mjs"),
join(DIST, "skills", "media-use", "scripts", "resolve.mjs"),
);
mkdirSync(join(DIST, "skills", "registry"), { recursive: true });
copyDirContents(join(DIST, "registry"), join(DIST, "skills", "registry"));
mkdirSync(join(DIST, "skills", "media-use", "registry"), { recursive: true });
copyDirContents(join(DIST, "registry"), join(DIST, "skills", "media-use", "registry"));

const dockerfile = join(CLI_ROOT, "src", "docker", "Dockerfile.render");
if (existsSync(dockerfile)) {
cpSync(dockerfile, join(DIST, "docker", "Dockerfile.render"));
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/audio/scripts
1 change: 1 addition & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ const commandLoaders = {
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),
"media-use": () => import("./commands/media-use.js").then((m) => m.default),
play: () => import("./commands/play.js").then((m) => m.default),
present: () => import("./commands/present.js").then((m) => m.default),
preview: () =>
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ vi.mock("../registry/localEmbedder.js", () => ({
}));

vi.mock("../registry/localSemantic.js", () => ({
mediaSemanticRanking: async () => null,
localSemanticRanking: async () => {
if (state.rankingError) throw state.rankingError;
return state.ranking;
Expand Down
86 changes: 86 additions & 0 deletions packages/cli/src/commands/media-use.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createMediaUseCommand,
MEDIA_USE_VERBS,
mediaUsePassthroughArgs,
mediaUseVerbFlags,
resolveMediaUseEnginePath,
} from "./media-use.js";

function tempCommandDir(): string {
return mkdtempSync(join(tmpdir(), "hyperframes-media-use-command-"));
}

describe("media-use command wiring", () => {
it("uses the bundled engine when the first candidate exists", () => {
const here = tempCommandDir();
const engine = join(here, "..", "media-use", "resolve.mjs");
try {
expect(resolveMediaUseEnginePath(here, (candidate) => candidate === engine)).toBe(engine);
} finally {
rmSync(here, { recursive: true, force: true });
}
});

it("falls back to the source-tree skill engine", () => {
const here = tempCommandDir();
const engine = join(here, "skills", "media-use", "scripts", "resolve.mjs");
try {
expect(resolveMediaUseEnginePath(here, (candidate) => candidate === engine)).toBe(engine);
} finally {
rmSync(here, { recursive: true, force: true });
}
});

it("explains how to recover when the engine is absent", () => {
const here = tempCommandDir();
try {
expect(() => resolveMediaUseEnginePath(here)).toThrow(
"media-use engine is missing from this CLI build; reinstall the CLI or run from a source checkout",
);
} finally {
rmSync(here, { recursive: true, force: true });
}
});

it("passes flags after the media-use verb through unchanged", () => {
expect(
mediaUsePassthroughArgs([
"/usr/bin/node",
"cli.js",
"media-use",
"resolve",
"--type",
"sfx",
"--intent",
"cat",
]),
).toEqual(["--type", "sfx", "--intent", "cat"]);
});

it("maps every verb to the engine flag, leaving resolve unflagged", () => {
for (const verb of MEDIA_USE_VERBS) {
expect(mediaUseVerbFlags(verb)).toEqual(verb === "resolve" ? [] : [`--${verb}`]);
}
});

it("wires every subcommand to invoke its matching verb", () => {
const invoked: string[] = [];
const command = createMediaUseCommand((verb) => {
invoked.push(verb);
throw new Error("stop after dispatch");
});

for (const verb of MEDIA_USE_VERBS) {
const factory = (command.subCommands as Record<string, unknown> | undefined)?.[verb];
expect(factory).toBeTypeOf("function");
const child = (factory as () => { run?: () => never })();
expect(() => child.run?.()).toThrow("stop after dispatch");
}

expect(invoked).toEqual(MEDIA_USE_VERBS);
});
});
107 changes: 107 additions & 0 deletions packages/cli/src/commands/media-use.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { defineCommand } from "citty";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { finishCommand } from "../utils/commandResult.js";

const MEDIA_USE_ARGS = {
type: { type: "string" },
intent: { type: "string" },
entity: { type: "string" },
project: { type: "string", alias: "p" },
adopt: { type: "boolean" },
candidates: { type: "boolean" },
doctor: { type: "boolean" },
stats: { type: "boolean" },
days: { type: "string" },
"dry-run": { type: "boolean" },
reuse: { type: "string" },
from: { type: "string" },
params: { type: "string" },
for: { type: "string" },
analyze: { type: "boolean" },
"local-only": { type: "boolean" },
provider: { type: "string" },
"avatar-id": { type: "string" },
"voice-id": { type: "string" },
json: { type: "boolean" },
help: { type: "boolean", alias: "h" },
} as const;

export const MEDIA_USE_VERBS = [
"resolve",
"doctor",
"stats",
"adopt",
"candidates",
"reuse",
"from",
"params",
"analyze",
] as const;
export type MediaUseVerb = (typeof MEDIA_USE_VERBS)[number];

export function resolveMediaUseEnginePath(
here: string,
fileExists: (path: string) => boolean = existsSync,
): string {
const candidates = [
join(here, "..", "media-use", "resolve.mjs"),
join(here, "skills", "media-use", "scripts", "resolve.mjs"),
];
const engine = candidates.find((candidate) => fileExists(candidate));
if (!engine) {
throw new Error(
"media-use engine is missing from this CLI build; reinstall the CLI or run from a source checkout",
);
}
return engine;
}

export function mediaUsePassthroughArgs(argv: readonly string[]): string[] {
const commandIndex = argv.indexOf("media-use");
return argv.slice(commandIndex + 2);
}

export function mediaUseVerbFlags(verb: MediaUseVerb): string[] {
return verb === "resolve" ? [] : [`--${verb}`];
}

type InvokeMediaUse = (verb: MediaUseVerb) => never;

function invokeEngine(verb: MediaUseVerb): never {
const here = dirname(fileURLToPath(import.meta.url));
const passed = mediaUsePassthroughArgs(process.argv);
const flag = mediaUseVerbFlags(verb);
const result = spawnSync(
process.execPath,
[resolveMediaUseEnginePath(here), ...flag, ...passed],
{
stdio: "inherit",
},
);
if (result.error) throw result.error;
finishCommand(result.status ?? 1);
}

function subcommand(name: MediaUseVerb, invoke: InvokeMediaUse) {
return defineCommand({
meta: { name, description: `media-use ${name}` },
args: MEDIA_USE_ARGS,
run: () => invoke(name),
});
}

export function createMediaUseCommand(invoke: InvokeMediaUse = invokeEngine) {
const subCommands = Object.fromEntries(
MEDIA_USE_VERBS.map((name) => [name, () => subcommand(name, invoke)]),
);
return defineCommand({
meta: { name: "media-use", description: "Resolve and operate on project media" },
subCommands,
run: () => console.log("Run `hyperframes media-use <resolve|doctor|stats|...> --help`"),
});
}

export default createMediaUseCommand();
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
import { existsSync, readFileSync } from "node:fs";
import { extname, join } from "node:path";
import { rankMediaRows } from "./media-search.mjs";

const LIB_DIR =
process.env.HYPERFRAMES_MEDIA_USE_SFX_DIR ||
[
join(import.meta.dirname, "..", "..", "audio", "assets", "sfx"),
join(
import.meta.dirname,
"..",
"..",
"..",
"..",
"..",
"skills",
"media-use",
"audio",
"assets",
"sfx",
),
].find((candidate) => existsSync(candidate)) ||
join(import.meta.dirname, "..", "..", "audio", "assets", "sfx");

export const BUNDLED_SFX_RECOVERY_COMMAND = "npx hyperframes skills update media-use";
Expand Down Expand Up @@ -59,37 +76,31 @@ export function inspectBundledSfxAssets(libraryDir = LIB_DIR) {
};
}

const normalize = (value) =>
String(value)
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();

export function extensionForBundledSfxFile(filename) {
return extname(filename) || ".mp3";
}

function score(intent, key, entry) {
const query = normalize(intent);
const name = normalize(key);
if (query === name) return 100;
if (query.includes(name) || name.includes(query)) return 50;
const haystack = new Set(normalize(`${key} ${entry.description || ""}`).split(/\s+/));
return query.split(/\s+/).filter((token) => token && haystack.has(token)).length;
}

export const bundledSfxProvider = {
async search(intent, ctx = {}) {
const libraryDir = ctx.libraryDir || LIB_DIR;
const health = inspectBundledSfxAssets(libraryDir);
if (!health.ok) throw new BundledSfxAssetsError(health);
const manifest = JSON.parse(readFileSync(join(libraryDir, "manifest.json"), "utf8"));

const ranked = Object.entries(manifest)
.map(([key, entry]) => ({ key, entry, score: score(intent, key, entry) }))
.filter(({ entry, score }) => entry?.file && score > 0)
.sort((a, b) => b.score - a.score || a.key.localeCompare(b.key));
const best = ranked[0];
const rows = Object.entries(manifest).map(([key, entry]) => ({
id: key,
kind: "sfx",
title: key,
description: entry?.description || key,
tags: ["sfx"],
file: entry?.file || "",
duration: entry?.duration,
}));
const bestRow = rankMediaRows(intent, rows)[0];
const best =
bestRow && manifest[bestRow.id]?.file
? { key: bestRow.id, entry: manifest[bestRow.id] }
: null;
if (!best) return null;

const localPath = join(libraryDir, best.entry.file);
Expand Down
Loading
Loading