diff --git a/README.md b/README.md index 4b2e805f6..4a224041f 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ HyperFrames ships 20 skills agents load on demand. Read `/hyperframes` first — Run `npx skills add heygen-com/hyperframes --full-depth` for the interactive picker, `npx skills add heygen-com/hyperframes --all --full-depth` to install all 20 at once (skips the picker), or `npx skills add heygen-com/hyperframes --skill --full-depth` for just one (bare name, no leading `/`). Keep `--full-depth` — it installs the current `main`; without it `skills add` fetches the skills.sh blob, which lags by hours. +Installs stay lean after that: `npx hyperframes init` keeps the **core set** fresh (the router, the `hyperframes-*` domain skills, and `media-use` — plus whatever is already installed; `/figma` stays on demand) and never expands a partial install; the creation workflows install **on demand** — the router runs `npx hyperframes skills update ` before entering one. Nothing re-pulls the full set behind your back. + ### Router | Skill | Use when | diff --git a/docs/guides/skills.mdx b/docs/guides/skills.mdx index 84037c192..394a572f9 100644 --- a/docs/guides/skills.mdx +++ b/docs/guides/skills.mdx @@ -44,6 +44,24 @@ The skills split into three groups: **Don't see a slash command after install?** Open a new agent session, or run `/skills` (Copilot CLI) / restart your agent's skill loader. Most agents pick up new skills on the next prompt. +## Keeping skills current + +After the first install, skills stay lean and current on their own — nothing re-pulls the full set behind your back: + +- **Core set** — the `/hyperframes` router, the `hyperframes-*` domain skills, and `/media-use`. `npx hyperframes init` (which every creation workflow runs when scaffolding) refreshes the core set plus anything else you already have installed. It never _expands_ the install — workflow skills you haven't used are not pulled — and re-running `init` on an up-to-date machine is a no-op. Offline (or rate-limited) it degrades gracefully and never hard-fails. +- **Workflow skills** (and `/figma`) — install **on demand**, when their workflow is first triggered. The `/hyperframes` router runs `npx hyperframes skills update ` before entering a workflow, so the one it routes to is guaranteed present. + +Check or refresh manually anytime: + +```bash +npx hyperframes skills check # non-zero exit if installed skills are stale or the core set is incomplete +npx hyperframes skills update # refresh the core set + everything installed (never expands) +npx hyperframes skills update # also install a specific workflow / domain skill on demand +npx hyperframes skills # install the full set explicitly +``` + +`skills check` reports workflow skills you haven't installed as _available on demand_, not as a failure. + ## Router The single entry point. Read `/hyperframes` before invoking anything else — it knows what's available and which workflow fits your request. diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index a921c002b..3866a4fbc 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -577,48 +577,46 @@ async function scaffoldProject( } /** - * Ensure the AI coding skills are present and current. Checks the installed - * skills against the latest published on GitHub and only (re)installs when - * something is outdated or missing — so re-running `init` on an up-to-date - * machine is a no-op. Best-effort: if the version check can't reach GitHub, it - * installs anyway. The install itself (`installAllSkills`) installs the full set - * once GLOBALLY (~/.claude/skills + ~/.agents/skills) and mirrors it into every - * other installed agent, so it is project-independent — the check is global-first - * to match. + * Keep the AI coding skills present and current — TARGETED, not the full + * set. Guarantees the core set (the `/hyperframes` entry router + shared + * domain skills) and refreshes any skill already installed; the end-user + * workflow skills are NOT pulled here — they install on demand when their + * workflow is triggered (`hyperframes skills update `, which the router + * runs before entering a workflow). Re-running `init` on an up-to-date machine + * is a no-op, and `init` never expands a deliberate partial install. + * Best-effort: offline, it degrades to a presence check and never breaks init. + * The install itself lands once GLOBALLY (~/.claude/skills + ~/.agents/skills) + * and mirrors into every other installed agent, so it is project-independent — + * the check is global-first to match. */ -async function ensureSkillsCurrent(destDir: string): Promise { - const { installAllSkills } = await import("./skills.js"); - const { checkSkills } = await import("../utils/skillsManifest.js"); +async function keepSkillsCurrent(destDir: string): Promise { + const { updateSkills } = await import("./skills.js"); console.log(); console.log(c.bold("Checking AI coding skills against GitHub...")); - let needsInstall = true; + // Wrap defensively (non-strict already swallows most failures): a + // skills-install failure can never break `init` itself — it warns and + // proceeds, since --skip-skills no longer escapes this path. try { - const result = await checkSkills({ cwd: destDir }); - needsInstall = result.updateAvailable; - } catch { - // Couldn't reach GitHub (offline, rate-limited) — install anyway. - } - - if (needsInstall) { - // installAllSkills installs the full set once globally and mirrors it into - // every installed agent's global dir — project-independent, so a freshly - // scaffolded project doesn't need any agent folders yet. - // - // Best-effort: installAllSkills (non-strict here) already swallows its own - // failures, but now that --skip-skills no longer escapes this path every - // init runs it — including offline ones, where checkSkills throws and we - // fall through to "install anyway". Wrap defensively so a skills-install - // failure can never break `init` itself; it only warns and proceeds. - try { - await installAllSkills({ cwd: destDir }); - } catch (err) { + const result = await updateSkills({ refreshInstalled: true, cwd: destDir }); + if (result.presenceOnly) { + // Freshness never got checked (GitHub unreachable) — don't claim + // "up to date"; the engine already reported what it could verify or + // blind-install. Point at the recovery command instead. console.log( - c.dim(`AI coding skills install skipped: ${err instanceof Error ? err.message : err}`), + c.dim("Skills freshness unverified — run `npx hyperframes skills update` when online."), + ); + } else if (result.installed.length === 0) { + console.log(c.success("AI coding skills are already up to date.")); + } else { + console.log( + c.dim("Workflow skills not installed here are added on demand, when first used."), ); } - } else { - console.log(c.success("AI coding skills are already up to date.")); + } catch (err) { + console.log( + c.dim(`AI coding skills install skipped: ${err instanceof Error ? err.message : err}`), + ); } } @@ -871,7 +869,7 @@ export default defineCommand({ } if (!skipSkills) { - await ensureSkillsCurrent(destDir); + await keepSkillsCurrent(destDir); } console.log(); @@ -1087,11 +1085,12 @@ export default defineCommand({ const files = readdirSync(destDir); clack.note(files.map((f) => c.accent(f)).join("\n"), c.success(`Created ${name}/`)); - // Check skills against GitHub and (re)install only if outdated or missing — - // init is the one place the full set is pulled. The --skip-skills flag is - // temporarily neutered (see above); CI/tests opt out via HYPERFRAMES_SKIP_SKILLS=1. + // Check skills against GitHub and refresh only what's stale — the core set + // plus anything already installed; workflow skills install on demand. The + // --skip-skills flag is temporarily neutered (see above); CI/tests opt out + // via HYPERFRAMES_SKIP_SKILLS=1. if (!skipSkills) { - await ensureSkillsCurrent(destDir); + await keepSkillsCurrent(destDir); } // Auto-launch studio preview diff --git a/packages/cli/src/commands/skills.test.ts b/packages/cli/src/commands/skills.test.ts index a674bff8b..6995f28bd 100644 --- a/packages/cli/src/commands/skills.test.ts +++ b/packages/cli/src/commands/skills.test.ts @@ -67,15 +67,38 @@ vi.mock("../telemetry/events.js", () => ({ trackSkillsInstallSkipped: (...args: unknown[]) => trackSkillsInstallSkipped(...args), })); -// `skills update` calls checkSkills() to find skills removed upstream, then -// prunes them. Mock it so these tests don't touch the real FS / network; the -// default returns nothing removed, and the prune test overrides per-call. -vi.mock("../utils/skillsManifest.js", () => ({ - checkSkills: vi.fn(async () => ({ skills: [] })), - // installAllSkills resolves the HyperFrames skill names (lock-attributed) to - // scope the mirror; pin it so these arg-shape tests don't read a real lock. - hyperframesSkillNames: vi.fn(() => ["hyperframes"]), -})); +// A realistic check result for the targeted paths (`update`, with or without names): +// one core skill outdated, one core missing, one on-demand workflow installed +// and current, one on-demand workflow not installed. `update` must refresh the +// two stale core skills, leave `pr-to-video` alone (on demand), and keep +// `embedded-captions` (installed + current) untouched. +const DEFAULT_CHECK = { + location: "/home/user/.claude/skills", + agent: "claude-code", + scope: "global", + updateAvailable: true, + summary: { current: 1, outdated: 1, missing: 2, coreMissing: 1, removed: 0 }, + skills: [ + { name: "hyperframes", status: "outdated" }, + { name: "hyperframes-core", status: "missing" }, + { name: "embedded-captions", status: "current" }, + { name: "pr-to-video", status: "missing" }, + ], + lockMissing: false, +}; + +// Mock only the impure exports; keep the real isCoreSkill (pure classifier). +// presentSkills echoes its input so the post-install presence verification +// passes without touching the real filesystem. +vi.mock("../utils/skillsManifest.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + checkSkills: vi.fn(async () => DEFAULT_CHECK), + hyperframesSkillNames: vi.fn(() => ["hyperframes"]), + presentSkills: vi.fn((names: readonly string[]) => [...names]), + }; +}); // The install fans out to other agents via mirrorGlobalSkills, which touches // the real $HOME. Stub it so these arg-shape tests never create symlinks in the @@ -84,10 +107,9 @@ vi.mock("../utils/skillsMirror.js", () => ({ mirrorGlobalSkills: vi.fn(() => ({ source: null, mirrored: [] })), })); -// The global install command this CLI runs (after `skills add `). -const GLOBAL_ARGS = [ - "--skill", - "*", +// The global install command this CLI runs (after `skills add ` and the +// per-name `--skill` selection). +const GLOBAL_ARGS_TAIL = [ "--global", "--agent", "claude-code", @@ -104,24 +126,55 @@ function setPlatform(platform: NodeJS.Platform): void { }); } -/** Invoke the `skills update` subcommand from a freshly-imported module. */ -async function runSkillsUpdate(args: Record = {}): Promise { +/** Invoke a `skills ` subcommand from a freshly-imported module. */ +async function runSkillsSub( + name: "update", + args: Record = {}, + positionals: string[] = [], +): Promise { const { default: skillsCmd } = await import("./skills.js"); const subs = skillsCmd.subCommands as unknown as Record; - expect(subs.update).toBeDefined(); - await subs.update!.run?.({ args, rawArgs: [], cmd: subs.update } as never); + expect(subs[name]).toBeDefined(); + await subs[name]!.run?.({ + args: { _: positionals, ...args }, + rawArgs: positionals, + cmd: subs[name], + } as never); +} + +const runSkillsUpdate = (args: Record = {}): Promise => + runSkillsSub("update", args); +const runSkillsUpdateWith = ( + positionals: string[], + args: Record = {}, +): Promise => runSkillsSub("update", args, positionals); + +/** The `--skill` values of a spawned `skills add` call. */ +function skillFlagValues(args: ReadonlyArray): string[] { + const values: string[] = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === "--skill") values.push(args[i + 1] ?? ""); + } + return values; } describe("hyperframes skills", () => { let prevExitCode: typeof process.exitCode; - beforeEach(() => { + beforeEach(async () => { state.execCalls = []; state.spawnCalls = []; state.spawnExitCode = 0; state.gitMissing = false; trackSkillsInstallSkipped.mockClear(); vi.resetModules(); + // vi.resetModules re-imports skills.js but the manifest mock's vi.fn + // instances persist — restore their default behavior for each test. + const { checkSkills, presentSkills } = await import("../utils/skillsManifest.js"); + vi.mocked(checkSkills).mockReset(); + vi.mocked(checkSkills).mockImplementation(async () => DEFAULT_CHECK as never); + vi.mocked(presentSkills).mockReset(); + vi.mocked(presentSkills).mockImplementation((names: readonly string[]) => [...names]); // Each test asserts on process.exitCode; isolate it from the runner's own. prevExitCode = process.exitCode; process.exitCode = 0; @@ -154,13 +207,27 @@ describe("hyperframes skills", () => { "linux", "npx", ["--version"], - ["skills", "add", "https://github.com/heygen-com/hyperframes", ...GLOBAL_ARGS], + [ + "skills", + "add", + "https://github.com/heygen-com/hyperframes", + "--skill", + "*", + ...GLOBAL_ARGS_TAIL, + ], ], [ "darwin", "npx", ["--version"], - ["skills", "add", "https://github.com/heygen-com/hyperframes", ...GLOBAL_ARGS], + [ + "skills", + "add", + "https://github.com/heygen-com/hyperframes", + "--skill", + "*", + ...GLOBAL_ARGS_TAIL, + ], ], [ "win32", @@ -174,11 +241,13 @@ describe("hyperframes skills", () => { "skills", "add", "https://github.com/heygen-com/hyperframes", - ...GLOBAL_ARGS, + "--skill", + "*", + ...GLOBAL_ARGS_TAIL, ], ], ] as const)( - "uses %s-compatible npx command for preflight and skills install", + "uses %s-compatible npx command for preflight and the full install", async (platform, expectedCommand, expectedPreflightArgs, expectedInstallArgs) => { setPlatform(platform); @@ -202,16 +271,23 @@ describe("hyperframes skills", () => { expect(process.exitCode).toBe(1); }); - it("skills update exits zero on a successful install", async () => { + it("skills update refreshes only the stale core + installed skills — never the full set", async () => { setPlatform("linux"); await runSkillsUpdate(); expect(process.exitCode).toBe(0); const args = state.spawnCalls[0]?.args ?? []; - // pulls the full set straight from GitHub, globally, as a faithful clone + // straight from GitHub, globally, as a faithful clone expect(args).toContain("https://github.com/heygen-com/hyperframes"); expect(args).toContain("--global"); expect(args).toContain("--copy"); expect(args).toContain("--full-depth"); + // targeted per-name selection: the stale core skills only + expect(skillFlagValues(args).sort()).toEqual(["hyperframes", "hyperframes-core"]); + // never the full-set wildcard, and never a missing on-demand workflow + expect(skillFlagValues(args)).not.toContain("*"); + expect(skillFlagValues(args)).not.toContain("pr-to-video"); + // installed-and-current skills are not re-fetched + expect(skillFlagValues(args)).not.toContain("embedded-captions"); // never the `--all` (= `--agent '*'`) spray expect(args).not.toContain("--all"); // `--agent` must be followed by a concrete key, never the `'*'` wildcard @@ -219,18 +295,57 @@ describe("hyperframes skills", () => { expect(agentValue).not.toBe("*"); }); - // `skills add --all` never deletes, so update must separately prune skills the - // manifest dropped (renames/removals) for `check || update` to fully reconcile. - it("skills update prunes skills removed upstream, in the attributed scope", async () => { + it("skills update refreshes an outdated installed workflow (but never expands)", async () => { setPlatform("linux"); const { checkSkills } = await import("../utils/skillsManifest.js"); vi.mocked(checkSkills).mockResolvedValueOnce({ - scope: "global", - skills: [{ name: "graphic-overlays", status: "removed" }], + ...DEFAULT_CHECK, + skills: [ + { name: "hyperframes", status: "current" }, + { name: "embedded-captions", status: "outdated" }, // installed workflow → refresh + { name: "pr-to-video", status: "missing" }, // not installed → leave for on-demand + ], + } as never); + + await runSkillsUpdate(); + + const args = state.spawnCalls[0]?.args ?? []; + expect(skillFlagValues(args)).toEqual(["embedded-captions"]); + }); + + it("skills update is a no-install no-op when everything is current", async () => { + setPlatform("linux"); + const { checkSkills } = await import("../utils/skillsManifest.js"); + vi.mocked(checkSkills).mockResolvedValue({ + ...DEFAULT_CHECK, + updateAvailable: false, + skills: [ + { name: "hyperframes", status: "current" }, + { name: "pr-to-video", status: "missing" }, // on demand — not an update + ], } as never); await runSkillsUpdate(); + expect(state.spawnCalls.some((s) => s.args.includes("add"))).toBe(false); + expect(process.exitCode).toBe(0); + }); + + // `skills add` never deletes, so update must separately prune skills the + // manifest dropped (renames/removals) for `check || update` to fully reconcile. + it("skills update prunes skills removed upstream, in the attributed scope", async () => { + setPlatform("linux"); + const { checkSkills } = await import("../utils/skillsManifest.js"); + // First call feeds the targeted install; the second is the prune detection. + vi.mocked(checkSkills) + .mockResolvedValueOnce(DEFAULT_CHECK as never) + .mockResolvedValueOnce({ + scope: "global", + skills: [{ name: "graphic-overlays", status: "removed" }], + } as never); + + await runSkillsUpdate(); + // install first, then a `skills remove` for the dropped skill expect(state.spawnCalls[0]?.args).toContain("add"); const removeCall = state.spawnCalls.find((s) => s.args.includes("remove")); @@ -247,10 +362,12 @@ describe("hyperframes skills", () => { it("skills update prunes in project scope without -g", async () => { setPlatform("linux"); const { checkSkills } = await import("../utils/skillsManifest.js"); - vi.mocked(checkSkills).mockResolvedValueOnce({ - scope: "project", - skills: [{ name: "graphic-overlays", status: "removed" }], - } as never); + vi.mocked(checkSkills) + .mockResolvedValueOnce(DEFAULT_CHECK as never) + .mockResolvedValueOnce({ + scope: "project", + skills: [{ name: "graphic-overlays", status: "removed" }], + } as never); await runSkillsUpdate(); @@ -272,11 +389,13 @@ describe("hyperframes skills", () => { it("skills update plumbs --source/--dir to its prune detection (parity with check)", async () => { setPlatform("linux"); const { checkSkills } = await import("../utils/skillsManifest.js"); - vi.mocked(checkSkills).mockResolvedValueOnce({ scope: "project", skills: [] } as never); await runSkillsUpdate({ source: "owner/repo", dir: "/custom/skills" }); - expect(checkSkills).toHaveBeenCalledWith({ source: "owner/repo", dir: "/custom/skills" }); + // The last checkSkills call is the prune's — the update engine's own check + // (first call) intentionally uses default detection, matching where the + // install actually lands. + expect(checkSkills).toHaveBeenLastCalledWith({ source: "owner/repo", dir: "/custom/skills" }); }); // Skill names come from lock-file JSON keys; a flag-like / shell-special name @@ -284,13 +403,15 @@ describe("hyperframes skills", () => { it("skills update never passes a non-slug skill name to remove", async () => { setPlatform("linux"); const { checkSkills } = await import("../utils/skillsManifest.js"); - vi.mocked(checkSkills).mockResolvedValueOnce({ - scope: "global", - skills: [ - { name: "graphic-overlays", status: "removed" }, - { name: "--config=evil.js", status: "removed" }, - ], - } as never); + vi.mocked(checkSkills) + .mockResolvedValueOnce(DEFAULT_CHECK as never) + .mockResolvedValueOnce({ + scope: "global", + skills: [ + { name: "graphic-overlays", status: "removed" }, + { name: "--config=evil.js", status: "removed" }, + ], + } as never); await runSkillsUpdate(); @@ -308,13 +429,15 @@ describe("hyperframes skills", () => { it("skills update spawns no remove when every removed name is rejected", async () => { setPlatform("linux"); const { checkSkills } = await import("../utils/skillsManifest.js"); - vi.mocked(checkSkills).mockResolvedValueOnce({ - scope: "global", - skills: [ - { name: "--config=evil.js", status: "removed" }, - { name: "../escape", status: "removed" }, - ], - } as never); + vi.mocked(checkSkills) + .mockResolvedValueOnce(DEFAULT_CHECK as never) + .mockResolvedValueOnce({ + scope: "global", + skills: [ + { name: "--config=evil.js", status: "removed" }, + { name: "../escape", status: "removed" }, + ], + } as never); await runSkillsUpdate(); @@ -368,3 +491,174 @@ describe("hyperframes skills", () => { expect(process.exitCode).toBe(1); }); }); + +// The router contract: `/hyperframes` picks a workflow, then runs +// `hyperframes skills update ` so the workflow's skill (and the core +// set it depends on) is guaranteed present and current before the agent reads +// it. Positional names are the ONLY way update expands an install. +describe("hyperframes skills update ", () => { + let prevExitCode: typeof process.exitCode; + + beforeEach(async () => { + state.execCalls = []; + state.spawnCalls = []; + state.spawnExitCode = 0; + state.gitMissing = false; + vi.resetModules(); + const { checkSkills, presentSkills } = await import("../utils/skillsManifest.js"); + vi.mocked(checkSkills).mockReset(); + vi.mocked(checkSkills).mockImplementation(async () => DEFAULT_CHECK as never); + vi.mocked(presentSkills).mockReset(); + vi.mocked(presentSkills).mockImplementation((names: readonly string[]) => [...names]); + prevExitCode = process.exitCode; + process.exitCode = 0; + }); + + afterEach(() => { + setPlatform(originalPlatform); + vi.restoreAllMocks(); + process.exitCode = prevExitCode; + }); + + it("installs the requested workflow plus the stale core set — nothing else", async () => { + setPlatform("linux"); + await runSkillsUpdateWith(["pr-to-video"]); + + expect(process.exitCode).toBe(0); + const args = state.spawnCalls[0]?.args ?? []; + expect(args).toContain("add"); + // requested workflow (missing) + the stale core skills; embedded-captions + // (installed + current) and the full-set wildcard must not appear. + expect(skillFlagValues(args).sort()).toEqual([ + "hyperframes", + "hyperframes-core", + "pr-to-video", + ]); + }); + + it("is a fast no-op (no install spawn) when everything is already current", async () => { + setPlatform("linux"); + const { checkSkills } = await import("../utils/skillsManifest.js"); + vi.mocked(checkSkills).mockResolvedValue({ + ...DEFAULT_CHECK, + updateAvailable: false, + skills: [ + { name: "hyperframes", status: "current" }, + { name: "hyperframes-core", status: "current" }, + { name: "pr-to-video", status: "current" }, + ], + } as never); + + await runSkillsUpdateWith(["pr-to-video"]); + + expect(state.spawnCalls).toHaveLength(0); + expect(process.exitCode).toBe(0); + }); + + it("fails loudly on a skill name the manifest doesn't ship", async () => { + setPlatform("linux"); + await runSkillsUpdateWith(["graphic-overlays"]); // renamed upstream → unknown + + expect(state.spawnCalls).toHaveLength(0); + expect(process.exitCode).toBe(1); + }); + + it("rejects flag-like skill names before any spawn", async () => { + setPlatform("linux"); + await runSkillsUpdateWith(["--config=evil.js"]); + + expect(state.spawnCalls).toHaveLength(0); + expect(process.exitCode).toBe(1); + }); + + it("offline with the skill already on disk: proceeds without installing", async () => { + setPlatform("linux"); + const { checkSkills } = await import("../utils/skillsManifest.js"); + vi.mocked(checkSkills).mockRejectedValue(new Error("offline")); + + await runSkillsUpdateWith(["pr-to-video"]); + + expect(state.spawnCalls).toHaveLength(0); + expect(process.exitCode).toBe(0); + }); + + it("offline with the skill absent: blind-installs it plus the fallback core set", async () => { + setPlatform("linux"); + const { checkSkills, presentSkills, FALLBACK_CORE_SKILLS } = + await import("../utils/skillsManifest.js"); + vi.mocked(checkSkills).mockRejectedValue(new Error("offline")); + // Absent before the install, present after it (the blind install worked). + vi.mocked(presentSkills) + .mockImplementationOnce(() => []) + .mockImplementation((names: readonly string[]) => [...names]); + + await runSkillsUpdateWith(["pr-to-video"]); + + // The offline guarantee must still cover the core tier the workflow + // depends on, not silently shrink to just the named skill. + const args = state.spawnCalls[0]?.args ?? []; + expect(skillFlagValues(args).sort()).toEqual(["pr-to-video", ...FALLBACK_CORE_SKILLS].sort()); + expect(process.exitCode).toBe(0); + }); + + // The `check || update` CI contract: offline, a bare update can't verify + // freshness — exiting 0 would let the chain pass while everything stays + // stale. It must fail loudly instead. + it("bare update offline exits non-zero instead of claiming success", async () => { + setPlatform("linux"); + const { checkSkills } = await import("../utils/skillsManifest.js"); + vi.mocked(checkSkills).mockRejectedValue(new Error("offline")); + + await runSkillsUpdate(); + + expect(state.spawnCalls.some((s) => s.args.includes("add"))).toBe(false); + expect(process.exitCode).toBe(1); + }); + + it("--json emits a parseable result on success", async () => { + setPlatform("linux"); + const logSpy = vi.spyOn(console, "log"); + + await runSkillsUpdateWith(["pr-to-video"], { json: true }); + + expect(process.exitCode).toBe(0); + // The engine logs install progress lines too; the JSON result is the last + // console.log of the run (the prune prints nothing when nothing was removed). + const last = String(logSpy.mock.calls.at(-1)?.[0] ?? ""); + const parsed = JSON.parse(last) as { installed?: string[] }; + expect(parsed.installed).toContain("pr-to-video"); + }); + + it("--json emits a parseable error object on failure", async () => { + setPlatform("linux"); + const logSpy = vi.spyOn(console, "log"); + + await runSkillsUpdateWith(["graphic-overlays"], { json: true }); // unknown name + + expect(process.exitCode).toBe(1); + const last = String(logSpy.mock.calls.at(-1)?.[0] ?? ""); + const parsed = JSON.parse(last) as { error?: string }; + expect(parsed.error).toMatch(/Unknown skill/); + }); + + it("exits non-zero when the targeted install fails", async () => { + setPlatform("linux"); + state.spawnExitCode = 1; + + await runSkillsUpdateWith(["pr-to-video"]); + + expect(process.exitCode).toBe(1); + }); + + it("exits non-zero when the skill is still missing after an install that exited 0", async () => { + setPlatform("linux"); + const { presentSkills } = await import("../utils/skillsManifest.js"); + // The install claims success but delivers nothing. + vi.mocked(presentSkills).mockImplementation(() => []); + + await runSkillsUpdateWith(["pr-to-video"]); + + expect(state.spawnCalls[0]?.args).toContain("add"); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/packages/cli/src/commands/skills.ts b/packages/cli/src/commands/skills.ts index 198fd4705..d7e7e87ff 100644 --- a/packages/cli/src/commands/skills.ts +++ b/packages/cli/src/commands/skills.ts @@ -6,7 +6,10 @@ import { buildNpxCommand } from "../utils/npxCommand.js"; import { withMeta } from "../utils/updateCheck.js"; import { checkSkills, + FALLBACK_CORE_SKILLS, hyperframesSkillNames, + isCoreSkill, + presentSkills, SKILLS_CLI_LOCK_PATHS_VERIFIED_AT, type SkillDiff, type SkillsCheckResult, @@ -19,7 +22,8 @@ export const examples: Example[] = [ ["Install all HyperFrames skills", "hyperframes skills"], ["Check whether installed skills are up to date", "hyperframes skills check"], ["Check, machine-readable (for agents / CI)", "hyperframes skills check --json"], - ["Update all skills to the latest (installs any missing)", "hyperframes skills update"], + ["Update the core set + everything already installed", "hyperframes skills update"], + ["Also install one workflow (on-demand install)", "hyperframes skills update pr-to-video"], ]; function hasNpx(): boolean { @@ -52,7 +56,7 @@ function spawnNpx(args: string[], opts: { cwd?: string } = {}): Promise { const child = spawn(npx.command, npx.args, { stdio: "inherit", // We install with --full-depth (a full `git clone` of the repo, the only - // path that bypasses the laggy skills.sh blob — see GLOBAL_INSTALL_ARGS), + // path that bypasses the laggy skills.sh blob — see GLOBAL_INSTALL_ARGS_TAIL), // which is heavier than the blob fetch, so allow more headroom. timeout: 300_000, cwd: opts.cwd, @@ -94,9 +98,7 @@ function spawnNpx(args: string[], opts: { cwd?: string } = {}): Promise { // skills.sh registry blob, which lags GitHub main by hours, so a fresh install // would read as several skills "outdated" (verified: blob → ~9 outdated; // --full-depth → all current). -const GLOBAL_INSTALL_ARGS = [ - "--skill", - "*", +const GLOBAL_INSTALL_ARGS_TAIL = [ "--global", "--agent", "claude-code", @@ -106,11 +108,25 @@ const GLOBAL_INSTALL_ARGS = [ "--yes", ]; +/** All skills, or an explicit list of skill names to install. */ +type SkillSelection = "*" | readonly string[]; + +// The upstream CLI takes one `--skill` flag per name (`--skill a --skill b`), +// with `'*'` meaning "all skills" — see vercel-labs/skills `add --skill`. +function skillSelectionArgs(selection: SkillSelection): string[] { + const names = selection === "*" ? ["*"] : selection; + return names.flatMap((name) => ["--skill", name]); +} + function runSkillsAdd( source: string, - opts: { cwd?: string; extraArgs?: string[] } = {}, + selection: SkillSelection, + opts: { cwd?: string } = {}, ): Promise { - return spawnNpx(["skills", "add", source, ...(opts.extraArgs ?? GLOBAL_INSTALL_ARGS)], opts); + return spawnNpx( + ["skills", "add", source, ...skillSelectionArgs(selection), ...GLOBAL_INSTALL_ARGS_TAIL], + opts, + ); } // Skill names are kebab-case directory names. Refuse anything that isn't one @@ -135,7 +151,7 @@ function runSkillsRemove(names: string[], opts: { global: boolean }): Promise !PLAIN_SKILL_NAME.test(n)); + if (rejected.length) { + clack.log.warn(c.warn(`Skipping unexpected skill name(s): ${rejected.join(", ")}`)); + } + const safe = selection.filter((n) => PLAIN_SKILL_NAME.test(n)); + return safe.length > 0 ? safe : null; +} + +async function installSkills( + selection: SkillSelection, + opts: { cwd?: string; strict?: boolean } = {}, ): Promise { + const safeSelection = sanitizeSelection(selection); + if (safeSelection === null) return; + if (!skillsToolingReady(opts.strict ?? false)) return; for (const source of SOURCES) { @@ -213,7 +247,7 @@ export async function installAllSkills( console.log(c.bold(`Installing ${source.name} skills...`)); console.log(); try { - await runSkillsAdd(source.url, opts); + await runSkillsAdd(source.url, safeSelection, opts); } catch (err) { if (opts.strict) throw err instanceof Error ? err : new Error(String(err)); console.log(c.dim(`${source.name} skills skipped`)); @@ -223,6 +257,174 @@ export async function installAllSkills( mirrorToInstalledAgents(); } +// ── targeted install engine ─────────────────────────────────────────────────────────────────── + +/** What an `updateSkills` run guaranteed, and what it had to do to get there. */ +export interface UpdateSkillsResult { + /** Every skill this run guaranteed: requested + core (+ installed, when refreshing). */ + targets: string[]; + /** Targets that were (re)installed by this run. */ + installed: string[]; + /** Targets that were already current — nothing fetched for them. */ + current: string[]; + /** + * Requested names the latest manifest doesn't ship (typo, or renamed + * upstream). Only ever non-empty on a non-strict run: a strict run throws on + * unknown names before returning, so strict callers never observe this — a + * future caller reading `unknown` should not expect it under `strict: true`. + */ + unknown: string[]; + /** True when freshness couldn't be checked (offline) and only presence was verified. */ + presenceOnly: boolean; +} + +/** + * The targeted install engine behind `init` and `skills update [names...]` — + * the replacement for "anything stale ⇒ re-pull the full skill set". It + * guarantees a small, explicit set is installed and current: + * + * - the requested names (a workflow being routed to, e.g. `pr-to-video`), + * - the core set (entry router + shared domain skills — see skillsManifest), + * - with `refreshInstalled`, whatever is already installed (refreshed, so an + * update never *expands* a deliberate partial install). + * + * Only targets that are actually missing or outdated are passed to + * `skills add` (one spawn, one `--skill` flag per name); when everything is + * current the call is a fast no-op with no install. When the manifest is + * unreachable, freshness is unknowable and the run degrades to the presence + * half of the guarantee — see updateSkillsOffline. + */ +export async function updateSkills( + opts: { + requested?: readonly string[]; + refreshInstalled?: boolean; + strict?: boolean; + cwd?: string; + } = {}, +): Promise { + const requested = [...new Set(opts.requested ?? [])]; + const strict = opts.strict ?? false; + + let check: SkillsCheckResult | null = null; + try { + check = await checkSkills({ cwd: opts.cwd }); + } catch { + check = null; // manifest unreachable (offline / rate-limited) — presence mode below + } + if (!check) return updateSkillsOffline(requested, { strict, cwd: opts.cwd }); + + // "removed" entries are lock-attributed leftovers, not manifest skills — + // they are `skills update`'s prune concern, never an update target. + const manifestSkills = check.skills.filter((s) => s.status !== "removed"); + const manifestNames = new Set(manifestSkills.map((s) => s.name)); + + const unknown = requested.filter((name) => !manifestNames.has(name)); + if (unknown.length) { + const message = + `Unknown skill(s): ${unknown.join(", ")}. ` + + `Available: ${[...manifestNames].sort().join(", ")}`; + if (strict) throw new Error(message); + clack.log.warn(c.warn(message)); + } + + const targets = manifestSkills.filter( + (s) => + requested.includes(s.name) || + isCoreSkill(s.name) || + (opts.refreshInstalled === true && s.status !== "missing"), + ); + const toInstall = targets.filter((s) => s.status === "missing" || s.status === "outdated"); + const result: UpdateSkillsResult = { + targets: targets.map((s) => s.name), + installed: toInstall.map((s) => s.name), + current: targets.filter((s) => s.status === "current").map((s) => s.name), + unknown, + presenceOnly: false, + }; + + if (toInstall.length > 0) { + await installSkills(result.installed, { cwd: opts.cwd, strict }); + verifyInstalled(result.installed, { strict, cwd: opts.cwd }); + } + return result; +} + +/** + * The presence half of the guarantee, after an install claims success: every + * name must now exist on disk. Catches the "install exited 0 but delivered + * nothing" failure mode, which would otherwise surface much later as a + * workflow reading skill files that aren't there. + * + * Strictness mirrors the caller's tolerance: a strict run (the `check || + * update` CI contract, the router's trigger-time guarantee) throws so the + * failure is loud; a non-strict run (init) only warns and proceeds, since a + * skills hiccup must never break scaffolding. + */ +function verifyInstalled(names: readonly string[], opts: { strict: boolean; cwd?: string }): void { + const present = new Set(presentSkills(names, { cwd: opts.cwd })); + const absent = names.filter((name) => !present.has(name)); + if (absent.length === 0) return; + const message = `Skill(s) still missing after install: ${absent.join(", ")}`; + if (opts.strict) throw new Error(message); + clack.log.warn(c.warn(message)); +} + +/** + * Offline degradation for updateSkills: the manifest is unreachable, so + * freshness is unknowable. What can still be honored is the PRESENCE half of + * the guarantee, extended to the pinned FALLBACK_CORE_SKILLS list so the + * router / preamble promise ("this workflow plus the core set it depends on") + * doesn't silently shrink to just the named skill on degraded networks — + * raw.githubusercontent.com blocked while `git clone` works is a real + * corporate-proxy shape, which is exactly when the blind install below can + * still succeed. + * + * - Named run (`update `): presence-check requested + fallback + * core, blind-install whatever is absent (a truly dead network fails the + * clone fast, and `strict` decides how loudly). Stale-but-present + * proceeds — blocking a build on a network hiccup is worse than running + * one release behind. + * - Bare run (nothing requested): the whole job was freshness, and presence + * can't prove it. strict — the documented `check || update` CI contract — + * must fail loudly rather than exit 0 while everything stays stale. + * Non-strict (init) still presence-checks the fallback core, so a fresh + * machine gets a best-effort core install instead of nothing. + */ +async function updateSkillsOffline( + requested: readonly string[], + opts: { strict: boolean; cwd?: string }, +): Promise { + if (requested.length === 0 && opts.strict) { + throw new Error( + "can't check skills freshness (GitHub manifest unreachable) — refusing to report success. Retry when online.", + ); + } + + const targets = [...new Set([...requested, ...FALLBACK_CORE_SKILLS])]; + const present = new Set(presentSkills(targets, { cwd: opts.cwd })); + const absent = targets.filter((name) => !present.has(name)); + + console.log( + c.dim( + absent.length === 0 + ? "Skills freshness check unavailable (offline?) — installed skills found, continuing without a refresh." + : `Skills freshness check unavailable (offline?) — attempting a blind install of: ${absent.join(", ")}`, + ), + ); + + if (absent.length > 0) { + await installSkills(absent, { cwd: opts.cwd, strict: opts.strict }); + verifyInstalled(absent, opts); + } + return { + targets, + installed: absent, + current: [...present], + unknown: [], + presenceOnly: true, + }; +} + // ── check ──────────────────────────────────────────────────────────────────── /** Print a labelled list of skills (nothing if empty), each line uniformly coloured. */ @@ -232,8 +434,9 @@ function printSkillSection( title: string, mark: string, color: (s: string) => string, + filter: (s: SkillDiff) => boolean = () => true, ): void { - const items = result.skills.filter((s) => s.status === status); + const items = result.skills.filter((s) => s.status === status && filter(s)); if (!items.length) return; console.log(); console.log(` ${color(title)}`); @@ -256,14 +459,31 @@ function renderCheck(result: SkillsCheckResult): void { console.log(` ${c.bold("Location")} ${c.dim(result.location)} ${c.dim(`(${result.agent})`)}`); console.log(); + const onDemandMissing = summary.missing - summary.coreMissing; const parts = [c.success(`✓ ${summary.current} current`)]; if (summary.outdated) parts.push(c.warn(`↑ ${summary.outdated} outdated`)); - if (summary.missing) parts.push(c.dim(`◦ ${summary.missing} not installed`)); + if (summary.coreMissing) parts.push(c.warn(`◦ ${summary.coreMissing} core not installed`)); + if (onDemandMissing) parts.push(c.dim(`◦ ${onDemandMissing} available on demand`)); if (summary.removed) parts.push(c.warn(`✗ ${summary.removed} removed upstream`)); console.log(` ${parts.join(" ")}`); printSkillSection(result, "outdated", "Outdated:", "↑", c.warn); - printSkillSection(result, "missing", "Not installed:", "◦", c.dim); + printSkillSection( + result, + "missing", + "Core not installed (skills update installs these):", + "◦", + c.warn, + (s) => isCoreSkill(s.name), + ); + printSkillSection( + result, + "missing", + "Available on demand (installed when their workflow first runs):", + "◦", + c.dim, + (s) => !isCoreSkill(s.name), + ); printSkillSection( result, "removed", @@ -321,17 +541,75 @@ const checkCommand = defineCommand({ // ── update ─────────────────────────────────────────────────────────────────── +/** + * Positional skill names from argv, split into plain-slug names and rejected + * tokens — each name is spread into a spawn as a `--skill` value, so + * flag-like tokens are refused up front (the caller reports and exits). + */ +function requestedNamesFrom(positionals: readonly unknown[]): { + requested: string[]; + rejected: string[]; +} { + const names = positionals.map(String).filter((n) => n.length > 0); + return { + requested: names.filter((n) => PLAIN_SKILL_NAME.test(n)), + rejected: names.filter((n) => !PLAIN_SKILL_NAME.test(n)), + }; +} + +/** Result line(s) for `skills update` — JSON for agents, one calm line for humans. */ +function reportUpdate( + result: UpdateSkillsResult, + requested: readonly string[], + json: boolean, +): void { + if (json) { + console.log(JSON.stringify(withMeta(result), null, 2)); + return; + } + if (result.installed.length > 0) { + console.log( + c.success( + `Installed/updated ${result.installed.length} skill(s): ${result.installed.join(", ")}`, + ), + ); + } else if (result.presenceOnly) { + // Freshness was never checked (offline degrade) — "up to date" would be a + // claim we can't back. Presence is all that was verified. + console.log(c.warn("Freshness unknown (GitHub unreachable) — verified presence only.")); + } else { + console.log(c.success("Installed skills are already up to date.")); + } + // The named skills are the caller's actual question ("is my workflow ready?") + // — answer it explicitly, whatever the install had to do. + if (requested.length) console.log(c.success(`◇ Ready: ${requested.join(", ")}`)); +} + +/** + * Failure line for `skills update`. In --json mode the failure must land on + * stdout as JSON (an agent piping to a parser gets structure, not clack + * prose); the human path keeps the clack error. + */ +function reportUpdateFailure(message: string, json: boolean): void { + if (json) { + console.log(JSON.stringify(withMeta({ error: message }), null, 2)); + return; + } + clack.log.error(c.error(message)); +} + const updateCommand = defineCommand({ meta: { name: "update", description: - "Update all HyperFrames skills to the latest — installs any not yet present, removes any no longer published", + "Update the core set plus every installed HyperFrames skill to the latest, and remove any no longer published. Pass skill names to also install those (how workflow skills install on demand) — without names it never expands a partial install", }, // Mirror `check`'s flags: the prune step runs the same removed-detection, so it // must respect the same overrides. Without these, `update`'s internal // checkSkills() fell back to defaults — pruning the auto-detected install // against the default manifest even when the user pointed `check` elsewhere. args: { + json: { type: "boolean", description: "Output as JSON", default: false }, dir: { type: "string", description: @@ -347,27 +625,44 @@ const updateCommand = defineCommand({ const dir = args.dir; const source = args.source; - // The install re-fetches every skill to the latest AND installs ones not yet - // present — so "update" pulls the full set, not just what is already - // installed. This is where `init` and the stale-skills nudge both lead. - // runSkillsAdd resolves the agent target set itself (existing project - // folders → installed CLIs → a Claude-Code + `.agents` floor); we no longer - // spray to every agent via `--all`. + // Positional skill names (e.g. `hyperframes skills update pr-to-video`) are + // the ONLY way update expands an install: each named skill is guaranteed + // present and current. This is the router's trigger-time step — the + // /hyperframes router runs it after picking a workflow, before reading the + // workflow's skill. + const { requested, rejected } = requestedNamesFrom(args._ ?? []); + if (rejected.length) { + reportUpdateFailure(`Invalid skill name(s): ${rejected.join(", ")}`, args.json === true); + process.exitCode = 1; + return; + } + + // Targeted, not full-set: refresh the core set (entry router + shared + // domain skills) plus whatever is already installed, plus anything named + // above. Without names a deliberate partial install stays partial + // (refreshed, but never expanded) — the end-user workflow skills install + // on demand, when their workflow is + // triggered. This is where `init` and the stale-skills nudge both lead; + // pulling the complete skill set here is exactly what users complained + // about. Explicit full set: `hyperframes skills` or `npx skills add + // heygen-com/hyperframes --all`. // // Note: the upstream `skills add` CLI has no `--dir` flag (it installs into // the resolved agent dirs), so `--dir` here scopes only the *prune* detection // below, not the install. `--source` likewise drives where the prune's // "latest" manifest comes from; the install always targets the canonical - // HyperFrames repo so `update` reliably pulls the published set. + // HyperFrames repo so `update` reliably refreshes the published skills. // // strict: this is the documented recovery path for the agent/CI contract - // `hyperframes skills check || hyperframes skills update`. If the install - // fails (no npx, `skills add` exits non-zero) it must exit non-zero too — - // otherwise the `||` chain passes while nothing actually changed. + // `hyperframes skills check || hyperframes skills update`, and the router's + // trigger-time guarantee. If the install fails (no npx, `skills add` exits + // non-zero, a named skill still absent afterwards) it must exit non-zero + // too — otherwise the `||` chain passes while nothing actually changed. try { - await installAllSkills({ strict: true }); + const result = await updateSkills({ requested, refreshInstalled: true, strict: true }); + reportUpdate(result, requested, args.json === true); } catch (err) { - clack.log.error(c.error(`Update failed: ${(err as Error).message}`)); + reportUpdateFailure(`Update failed: ${(err as Error).message}`, args.json === true); process.exitCode = 1; return; } @@ -413,6 +708,6 @@ export default defineCommand({ // citty runs this parent handler even when a subcommand matches; guard on // the positional so bare `hyperframes skills` installs, while // `hyperframes skills check|update` does not also re-install. - if (!args._?.[0]) await installAllSkills(); + if (!args._?.[0]) await installSkills("*"); }, }); diff --git a/packages/cli/src/templates/_shared/AGENTS.md b/packages/cli/src/templates/_shared/AGENTS.md index a75560985..a30e02dbe 100644 --- a/packages/cli/src/templates/_shared/AGENTS.md +++ b/packages/cli/src/templates/_shared/AGENTS.md @@ -23,8 +23,11 @@ The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes- > **Tailwind v4 projects** (`hyperframes init --tailwind`): see `/hyperframes-core` → `references/tailwind.md`. -> **Skills not available or need updating?** Run `npx skills add heygen-com/hyperframes` -> and restart the agent session so the new skills load. +> **Skill missing or stale?** Run `npx hyperframes skills update ` to install/refresh +> the specific skill you need (the `/hyperframes` router does this automatically before +> entering a workflow), or bare `npx hyperframes skills update` to refresh the core set plus +> everything already installed — neither pulls the full set. Restart the agent session so +> newly installed skills load. ## Commands diff --git a/packages/cli/src/templates/_shared/CLAUDE.md b/packages/cli/src/templates/_shared/CLAUDE.md index a75560985..a30e02dbe 100644 --- a/packages/cli/src/templates/_shared/CLAUDE.md +++ b/packages/cli/src/templates/_shared/CLAUDE.md @@ -23,8 +23,11 @@ The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes- > **Tailwind v4 projects** (`hyperframes init --tailwind`): see `/hyperframes-core` → `references/tailwind.md`. -> **Skills not available or need updating?** Run `npx skills add heygen-com/hyperframes` -> and restart the agent session so the new skills load. +> **Skill missing or stale?** Run `npx hyperframes skills update ` to install/refresh +> the specific skill you need (the `/hyperframes` router does this automatically before +> entering a workflow), or bare `npx hyperframes skills update` to refresh the core set plus +> everything already installed — neither pulls the full set. Restart the agent session so +> newly installed skills load. ## Commands diff --git a/packages/cli/src/utils/skillsManifest.test.ts b/packages/cli/src/utils/skillsManifest.test.ts index 6a8cdad94..c3c7682f0 100644 --- a/packages/cli/src/utils/skillsManifest.test.ts +++ b/packages/cli/src/utils/skillsManifest.test.ts @@ -1,12 +1,16 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { hashSkillBundle, buildManifest, checkSkills, diffSkills, + FALLBACK_CORE_SKILLS, + isCoreSkill, + presentSkills, skillsAttributedToSource, type SkillsManifest, type SkillEntry, @@ -70,6 +74,40 @@ describe("buildManifest", () => { }); }); +describe("isCoreSkill", () => { + it("classifies the entry router, hyperframes-* domain skills, and media-use as core", () => { + expect(isCoreSkill("hyperframes")).toBe(true); + expect(isCoreSkill("hyperframes-core")).toBe(true); + expect(isCoreSkill("hyperframes-animation")).toBe(true); + expect(isCoreSkill("media-use")).toBe(true); + // End-user workflows and optional integrations install on demand. + expect(isCoreSkill("pr-to-video")).toBe(false); + expect(isCoreSkill("embedded-captions")).toBe(false); + expect(isCoreSkill("figma")).toBe(false); + }); +}); + +describe("FALLBACK_CORE_SKILLS pin", () => { + // The fallback list exists because isCoreSkill is a pattern and the offline + // path can't enumerate a pattern. This pins the list to the repo's actual + // skills/ tree so it can't drift silently when core membership changes. + it("matches the core skills present in the repo's skills/ tree exactly", () => { + const skillsRoot = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "..", + "..", + "skills", + ); + const onDisk = readdirSync(skillsRoot).filter((n) => + existsSync(join(skillsRoot, n, "SKILL.md")), + ); + const coreOnDisk = onDisk.filter((n) => isCoreSkill(n)).sort(); + expect([...FALLBACK_CORE_SKILLS].sort()).toEqual(coreOnDisk); + }); +}); + describe("diffSkills", () => { const latest: SkillsManifest = { source: "test", @@ -94,14 +132,10 @@ describe("diffSkills", () => { changed: "outdated", gone: "missing", }); - expect(diff.summary).toEqual({ current: 1, outdated: 1, missing: 1 }); + expect(diff.summary).toEqual({ current: 1, outdated: 1, missing: 1, coreMissing: 0 }); }); - it("flags updateAvailable when a skill is outdated OR missing", () => { - // The full set is the goal, so missing skills now count too. - const missingOnly = diffSkills({ keep: { hash: "h1", files: 1 } }, latest); - expect(missingOnly.updateAvailable).toBe(true); - + it("flags updateAvailable for anything outdated", () => { const hasOutdated = diffSkills({ changed: { hash: "X", files: 1 } }, latest); expect(hasOutdated.updateAvailable).toBe(true); @@ -128,6 +162,57 @@ describe("diffSkills", () => { ); expect(withExtra.updateAvailable).toBe(false); }); + + it("a missing on-demand skill is NOT an update — a missing core skill is", () => { + // The old semantics ("full set is the goal") made any missing skill flip + // updateAvailable, which re-pulled all skills onto deliberate partial + // installs. On-demand skills now install when their workflow triggers. + const withCore: SkillsManifest = { + source: "test", + skills: { + hyperframes: { hash: "e1", files: 1 }, // core: entry router + "pr-to-video": { hash: "w1", files: 1 }, // on-demand workflow + }, + }; + + // Core current, workflow missing → partial install is fine, no update. + const workflowMissing = diffSkills({ hyperframes: { hash: "e1", files: 1 } }, withCore); + expect(workflowMissing.updateAvailable).toBe(false); + expect(workflowMissing.summary).toEqual({ + current: 1, + outdated: 0, + missing: 1, + coreMissing: 0, + }); + + // Core itself missing → every workflow needs it, so that IS an update. + const coreMissing = diffSkills({ "pr-to-video": { hash: "w1", files: 1 } }, withCore); + expect(coreMissing.updateAvailable).toBe(true); + expect(coreMissing.summary).toEqual({ current: 1, outdated: 0, missing: 1, coreMissing: 1 }); + }); +}); + +describe("presentSkills", () => { + it("returns only the names present in the located install", () => { + const home = join(root, "home"); + const project = join(root, "project"); + mkdirSync(project, { recursive: true }); + const skillsDir = join(home, ".claude/skills"); + mkdirSync(join(skillsDir, "hyperframes"), { recursive: true }); + writeFileSync(join(skillsDir, "hyperframes", "SKILL.md"), "# hyperframes"); + + expect(presentSkills(["hyperframes", "pr-to-video"], { cwd: project, home })).toEqual([ + "hyperframes", + ]); + }); + + it("returns [] when no install exists at all", () => { + const home = join(root, "home"); + const project = join(root, "project"); + mkdirSync(home, { recursive: true }); + mkdirSync(project, { recursive: true }); + expect(presentSkills(["hyperframes"], { cwd: project, home })).toEqual([]); + }); }); describe("checkSkills install detection", () => { @@ -213,11 +298,22 @@ describe("checkSkills install detection", () => { const home = join(root, "home"); mkdirSync(project, { recursive: true }); mkdirSync(home, { recursive: true }); - const source = writeManifest(root); + // A manifest with a core skill: a truly fresh machine is missing the core + // set, and THAT (not the missing on-demand skills) makes the update + // available. + const source = join(root, "manifest-core.json"); + writeFileSync( + source, + JSON.stringify({ + source: "test", + skills: { hyperframes: { hash: "x", files: 1 }, alpha: { hash: "y", files: 1 } }, + }), + ); const res = await checkSkills({ source, cwd: project, home }); expect(res.location).toBeNull(); expect(res.summary.missing).toBe(2); + expect(res.summary.coreMissing).toBe(1); expect(res.updateAvailable).toBe(true); }); @@ -349,7 +445,13 @@ describe("checkSkills removed-upstream detection", () => { writeGlobalLock(home, { alpha: { source: "test" }, gamma: { source: "test" } }); const res = await checkSkills(opts); - expect(res.summary).toEqual({ current: 1, outdated: 0, missing: 0, removed: 1 }); + expect(res.summary).toEqual({ + current: 1, + outdated: 0, + missing: 0, + coreMissing: 0, + removed: 1, + }); expect(res.updateAvailable).toBe(true); }); diff --git a/packages/cli/src/utils/skillsManifest.ts b/packages/cli/src/utils/skillsManifest.ts index f6a9dc18e..e58883dca 100644 --- a/packages/cli/src/utils/skillsManifest.ts +++ b/packages/cli/src/utils/skillsManifest.ts @@ -72,7 +72,8 @@ export interface SkillDiff { /** The pure manifest diff (current / outdated / missing — what `diffSkills` returns). */ export interface SkillsDiff { updateAvailable: boolean; - summary: { current: number; outdated: number; missing: number }; + /** `coreMissing` ⊆ `missing`: the missing skills that are core (see "Skill tiers"). */ + summary: { current: number; outdated: number; missing: number; coreMissing: number }; skills: SkillDiff[]; } @@ -84,7 +85,13 @@ export interface SkillsCheckResult { /** Scope of the located install — so a caller prunes in the same scope it attributed from. */ scope: "project" | "global" | null; updateAvailable: boolean; - summary: { current: number; outdated: number; missing: number; removed: number }; + summary: { + current: number; + outdated: number; + missing: number; + coreMissing: number; + removed: number; + }; skills: SkillDiff[]; /** * True when an install was located but the upstream skills lock was absent at @@ -100,6 +107,50 @@ const DEFAULT_REPO_SLUG = "heygen-com/hyperframes"; export const MANIFEST_FILE = "skills-manifest.json"; const FETCH_TIMEOUT_MS = 4000; +// ── Skill tiers ────────────────────────────────────────────────────────────── +// +// Two tiers decide what installs eagerly vs on demand: +// +// core — the `/hyperframes` entry router plus the shared domain skills +// (`hyperframes-*`, `media-use`) that every creation workflow +// references structurally (sibling `../hyperframes-animation/…` +// paths, "call /media-use" preambles). These must be present and +// current for ANY workflow to run, so `init` / `skills update` +// keep them fresh. +// on-demand — everything else: the end-user workflow skills (pr-to-video, +// embedded-captions, …) and optional integrations (figma). They +// install lazily, when their workflow is actually triggered +// (`hyperframes skills update `), instead of being sprayed +// onto every machine that runs `init`. + +/** The entry/router skill — the capability map that routes every request. */ +const ENTRY_SKILL = "hyperframes"; + +/** True for skills every workflow depends on (see "Skill tiers" above). */ +export function isCoreSkill(name: string): boolean { + return name === ENTRY_SKILL || name.startsWith("hyperframes-") || name === "media-use"; +} + +/** + * Pinned enumeration of the core tier, used ONLY when the live manifest is + * unreachable (offline / rate-limited): isCoreSkill is a pattern, and a + * pattern can't be enumerated without a name list. Best-effort by design — + * if this lags the skills/ tree, the degraded path misses (or over-asks for) + * a core skill until the next release, and the online path self-corrects on + * the next run. A unit test pins this list to the repo's skills/ tree so it + * can't drift silently; update it when core membership changes. + */ +export const FALLBACK_CORE_SKILLS: readonly string[] = [ + "hyperframes", + "hyperframes-animation", + "hyperframes-cli", + "hyperframes-core", + "hyperframes-creative", + "hyperframes-keyframes", + "hyperframes-registry", + "media-use", +]; + // ── Hashing ──────────────────────────────────────────────────────────────── function listFilesSorted(dir: string): string[] { @@ -283,6 +334,20 @@ function locateInstall( return null; } +/** + * Names from `skillNames` that are present (their SKILL.md exists) in the + * located install. Local-only — no manifest fetch — so callers can verify the + * presence half of an install guarantee even when GitHub is unreachable. + */ +export function presentSkills( + skillNames: readonly string[], + opts: { dir?: string; cwd?: string; home?: string } = {}, +): string[] { + const root = locateInstall([...skillNames], opts); + if (!root) return []; + return skillNames.filter((name) => existsSync(join(root.dir, name, "SKILL.md"))); +} + /** Hash every manifest skill that is installed under `root`. */ function hashInstalled(root: SkillRoot, skillNames: string[]): Record { const out: Record = {}; @@ -304,7 +369,7 @@ export function diffSkills( // one "ours but removed" via the lock's source attribution, never the bare // directory name — `.../skills` is shared across sources. const skills: SkillDiff[] = []; - const summary = { current: 0, outdated: 0, missing: 0 }; + const summary = { current: 0, outdated: 0, missing: 0, coreMissing: 0 }; for (const name of Object.keys(latest.skills).sort()) { const latestEntry = latest.skills[name]!; @@ -316,7 +381,10 @@ export function diffSkills( if (status === "current") summary.current++; else if (status === "outdated") summary.outdated++; - else summary.missing++; + else { + summary.missing++; + if (isCoreSkill(name)) summary.coreMissing++; + } skills.push({ name, @@ -327,9 +395,14 @@ export function diffSkills( } return { - // The full skill set is the goal — `init` and `skills update` both pull the - // complete set, so anything outdated OR missing means an update is available. - updateAvailable: summary.outdated > 0 || summary.missing > 0, + // "Update available" means the install is stale, not merely partial: + // anything installed-but-outdated, or a missing CORE skill (the entry + // router + shared domain skills every workflow needs). A missing + // on-demand skill is NOT an update — it installs when its workflow is + // triggered (`hyperframes skills update `). Counting it here is what + // used to make `init` re-pull the full skill set onto machines that + // deliberately installed a subset. + updateAvailable: summary.outdated > 0 || summary.coreMissing > 0, summary, skills, }; diff --git a/packages/cli/src/utils/skillsUpdateCheck.ts b/packages/cli/src/utils/skillsUpdateCheck.ts index e3f9a2e36..d29c2f3a0 100644 --- a/packages/cli/src/utils/skillsUpdateCheck.ts +++ b/packages/cli/src/utils/skillsUpdateCheck.ts @@ -42,13 +42,15 @@ async function refreshSkillsCache(): Promise { config.lastSkillsCheck = new Date().toISOString(); config.skillsUpdateAvailable = result.updateAvailable; config.skillsOutdatedCount = result.summary.outdated; - config.skillsMissingCount = result.summary.missing; + // Core-missing only: skills that install on demand (workflows not yet + // triggered on this machine) are not "missing" worth nagging about. + config.skillsMissingCount = result.summary.coreMissing; writeConfig(config); } return { updateAvailable: result.updateAvailable, outdated: result.summary.outdated, - missing: result.summary.missing, + missing: result.summary.coreMissing, }; } diff --git a/skills-manifest.json b/skills-manifest.json index 199389682..739bb4993 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -2,23 +2,23 @@ "source": "heygen-com/hyperframes", "skills": { "embedded-captions": { - "hash": "d870992d206c9ddc", + "hash": "36a43fc349052ab0", "files": 144 }, "faceless-explainer": { - "hash": "58b1901b8ef7a84e", + "hash": "027ae009f5255130", "files": 18 }, "figma": { - "hash": "aabcd697d0823efb", + "hash": "ee1408c58b76db8e", "files": 1 }, "general-video": { - "hash": "a4d2b4ed6d8a0643", + "hash": "c266136f4ffa2157", "files": 1 }, "hyperframes": { - "hash": "547fd3b432a028bb", + "hash": "42ba4448d7552099", "files": 1 }, "hyperframes-animation": { @@ -50,35 +50,35 @@ "files": 103 }, "motion-graphics": { - "hash": "7452272d8da8934d", + "hash": "2bd92504783e8a0c", "files": 23 }, "music-to-video": { - "hash": "5c9d4b3307c40a33", + "hash": "3e1e91e93a7a80d0", "files": 132 }, "pr-to-video": { - "hash": "30a91ac16fae4737", + "hash": "088f89960ecaf0ed", "files": 22 }, "product-launch-video": { - "hash": "8499a01e77b41c6c", + "hash": "cc8f9e4aeb87aa4b", "files": 20 }, "remotion-to-hyperframes": { - "hash": "6d4b352ecd46c8fb", + "hash": "aa599f027db4b994", "files": 70 }, "slideshow": { - "hash": "764746e9199213b6", + "hash": "3dd62c326ccf25eb", "files": 2 }, "talking-head-recut": { - "hash": "fbb95a4c6062c41a", + "hash": "ff57c239045342a9", "files": 27 }, "website-to-video": { - "hash": "76c4bae653b29e2f", + "hash": "baa2f956f3b7a12d", "files": 32 } } diff --git a/skills/dashboard.html b/skills/dashboard.html new file mode 100644 index 000000000..e69de29bb diff --git a/skills/embedded-captions/SKILL.md b/skills/embedded-captions/SKILL.md index b128a8393..8b694b43d 100644 --- a/skills/embedded-captions/SKILL.md +++ b/skills/embedded-captions/SKILL.md @@ -5,6 +5,8 @@ metadata: tags: captions, embedded-captions, occlusion, matting, talking-head, rembg-matting, whisper, ffmpeg, cinematic --- +> **First, keep this skill fresh — run silently, don't ask:** `npx hyperframes skills update embedded-captions`. A fast no-op when everything is current; otherwise it refreshes this skill plus the core domain skills it depends on before you rely on them. + # Embedded Captions **One catalog, picked up front** ([CATALOG.md](CATALOG.md) — 36 identities; the engines behind it are backend detail). **Standard** (default) builds a clean verbatim **rail** (lower-third subtitle carrying most text) + an **embed** climax composited _into_ the scene behind the subject at the peak. **Cinematic** is pure embed — no rail, every caption composited behind the subject (hero typography, accumulation, occlusion as the effect). **Theme** is a complete themed constitution — body paradigm × hero setpiece × front fx × plate reaction, composed from registries ([themes/README.md](themes/README.md)): `ordnance` `terminal` `neonsign` `stardust` `stomp`. Most explainer / voiceover is **Standard**; **embed is the scarce, earned peak** — embedding every word is the common mistake; Theme is for VFX-grade asks ("炸", "特效", "像 AE 做的"). diff --git a/skills/faceless-explainer/SKILL.md b/skills/faceless-explainer/SKILL.md index 8c065ebe6..2a11bbaa7 100644 --- a/skills/faceless-explainer/SKILL.md +++ b/skills/faceless-explainer/SKILL.md @@ -3,6 +3,8 @@ name: faceless-explainer description: "Turn arbitrary text — an article, notes, a topic, a brief — into a faceless explainer video: there is no site or footage to capture, so the visuals are invented per scene (typography, abstract graphics, diagrams, data-viz). Use for topic explainers, concept breakdowns, how-tos, listicles. Not a product promo (/product-launch-video) or a site tour (/website-to-video). Unclear → /hyperframes." --- +> **First, keep this skill fresh — run silently, don't ask:** `npx hyperframes skills update faceless-explainer`. A fast no-op when everything is current; otherwise it refreshes this skill plus the core domain skills it depends on before you rely on them. + > **media-use**: Before sourcing audio/images/logos, call `/media-use` to resolve BGM/SFX/images from the HeyGen catalog and brand logos from their official sources. Run `--adopt` first to register existing assets. See `/media-use` skill. # Faceless Explainer to HyperFrames diff --git a/skills/figma/SKILL.md b/skills/figma/SKILL.md index b1ab210eb..3184b2c5a 100644 --- a/skills/figma/SKILL.md +++ b/skills/figma/SKILL.md @@ -3,6 +3,8 @@ name: figma description: Import Figma content into a HyperFrames composition — rendered assets, brand tokens, components, storyboard sections → reconstructed motion (frames read as states, not slides) (REST/CLI), Figma Motion animations (MCP), and shaders (MCP source / native export). Use when the user pastes a figma.com link or asks to bring a Figma design, frame, logo, brand, or animation into a video/composition. --- +> **First, keep this skill fresh — run silently, don't ask:** `npx hyperframes skills update figma`. A fast no-op when everything is current; otherwise it refreshes this skill plus the core domain skills it depends on before you rely on them. + # Figma → HyperFrames Bring the user's Figma work into a composition. **Split by capability** (design spec §2): diff --git a/skills/general-video/SKILL.md b/skills/general-video/SKILL.md index 3a44c2947..7cd7d6d55 100644 --- a/skills/general-video/SKILL.md +++ b/skills/general-video/SKILL.md @@ -8,6 +8,8 @@ description: > metadata: { "tags": "orchestrator, general-video, fallback, freeform, composition-authoring" } --- +> **First, keep this skill fresh — run silently, don't ask:** `npx hyperframes skills update general-video`. A fast no-op when everything is current; otherwise it refreshes this skill plus the core domain skills it depends on before you rely on them. + > **media-use**: Before sourcing audio/images/logos, call `/media-use` to resolve BGM/SFX/images from the HeyGen catalog and brand logos from their official sources. Run `--adopt` first to register existing assets. See `/media-use` skill. # general-video — general video workflow diff --git a/skills/hyperframes/SKILL.md b/skills/hyperframes/SKILL.md index 2f2302a03..271575840 100644 --- a/skills/hyperframes/SKILL.md +++ b/skills/hyperframes/SKILL.md @@ -75,23 +75,30 @@ Routing needs to know **what the video is about** — its input and subject. If - **A presentation / pitch deck / interactive deck** (discrete slides, navigation, presenter mode) → `/slideshow` — output is a navigable deck, not a rendered video. An explicit "slideshow" request proceeds directly; an adjacent trigger ("deck / slides / presentation / convert this page") makes `/slideshow` confirm it's a slideshow before authoring, and switch to the appropriate non-slideshow workflow if not. - **Length is a guide, not a gate** — intent picks the workflow; go to `/general-video` only when the piece is clearly longer than ~3 min, or is a static / loop / custom format. -## If the matched workflow isn't installed +## After picking — guarantee the workflow is installed -Once you've picked a workflow, check it's actually available to you. If the matched workflow skill isn't installed, don't fall back to guessing — tell the user to install it first: +Once you've picked a workflow, run the update step **before reading its skill** — workflow skills install on demand, so the one you matched may not be on this machine yet (its trigger phrases live in this router precisely so you can route to skills that aren't installed): -- **Just this workflow:** `npx skills add heygen-com/hyperframes --skill ` (e.g. `--skill pr-to-video` — bare name, no leading `/`). -- **All workflows at once:** `npx skills add heygen-com/hyperframes --all` (core + every workflow, skips the picker). +```bash +npx hyperframes skills update +``` -After they run it, re-read the workflow's skill and continue. +Bare name, no leading `/` — e.g. `npx hyperframes skills update pr-to-video`. Naming a skill guarantees it **plus the core domain skills** every workflow depends on are installed and current: a fast no-op when everything already is, a targeted install of just the missing/stale skills when not — never the full set. Then read the workflow's skill and continue. The same command works for an on-demand domain skill from the capability map (e.g. `npx hyperframes skills update figma`). + +If the command fails, surface its error to the user instead of improvising the workflow from memory. Manual fallback (no HyperFrames CLI available): `npx skills add heygen-com/hyperframes --skill `; everything at once: `npx skills add heygen-com/hyperframes --all`. ## Keeping skills current -HyperFrames skills are versioned. `npx hyperframes init` checks the installed skills against the latest on GitHub and installs/refreshes the **full** set whenever anything is out of date or missing — so a freshly init'd project always has the complete, latest set (and re-running init on an up-to-date project is a no-op). The check is a quick GitHub round-trip; offline (or rate-limited) it falls back to installing after a short timeout, so init never hard-fails on a network hiccup. The creation workflows scaffold with `init`, so starting a new project always runs this check and pulls our latest skills from GitHub when they're stale. The `--skip-skills` flag is currently neutered (a temporary measure while the skills.sh registry catches up): passing it no longer skips the check, so every `init` checks GitHub. CI/tests opt out via the `HYPERFRAMES_SKIP_SKILLS=1` env var. +HyperFrames skills are versioned and install **lazily**: the core set eagerly, the workflows on first use. + +- **Core set** — this router, the `hyperframes-*` domain skills, and `media-use`. `npx hyperframes init` (which every creation workflow runs when scaffolding) checks GitHub and refreshes the core set plus anything else already installed. It never _expands_ the install — workflow skills you haven't used are not pulled. Re-running init on an up-to-date machine is a no-op; offline (or rate-limited) it degrades gracefully and never hard-fails. The `--skip-skills` flag is currently neutered (a temporary measure while the skills.sh registry catches up); CI/tests opt out via the `HYPERFRAMES_SKIP_SKILLS=1` env var. +- **Workflow skills** — installed and refreshed at trigger time by the update step above (`skills update `). If a task is behaving unexpectedly, or before a long build, confirm the installed skills are current: -- **Check:** `npx hyperframes skills check` (add `--json` for a machine-readable verdict; exits non-zero when anything is outdated **or missing**). -- **Update:** `npx hyperframes skills update` — pulls the full set to the latest, **installing any not yet present** (same as init's install step). +- **Check:** `npx hyperframes skills check` (add `--json` for a machine-readable verdict; exits non-zero when anything installed is outdated or the core set is incomplete — workflow skills not yet installed are reported as _available on demand_, not as a failure). +- **Update:** `npx hyperframes skills update` — refreshes the core set plus everything installed to the latest, and removes skills no longer published. Without names it never installs workflows you haven't used; naming skills (`skills update `) additionally installs those. +- **Full set, explicitly:** `npx hyperframes skills` (or `npx skills add heygen-com/hyperframes --all`). The CLI also surfaces a one-line reminder when a `render` / `lint` / `validate` run detects stale skills. diff --git a/skills/motion-graphics/SKILL.md b/skills/motion-graphics/SKILL.md index 402619d6d..9c9934434 100644 --- a/skills/motion-graphics/SKILL.md +++ b/skills/motion-graphics/SKILL.md @@ -15,6 +15,8 @@ metadata: } --- +> **First, keep this skill fresh — run silently, don't ask:** `npx hyperframes skills update motion-graphics`. A fast no-op when everything is current; otherwise it refreshes this skill plus the core domain skills it depends on before you rely on them. + # motion-graphics — dispatch entry > **Confirm the route before Step 0.** This skill makes a **short, design-led, unnarrated motion graphic** (motion is the message; ~under 10s, no voice-over). A **longer, multi-scene, or narrated** treatment → `/general-video`; a **narrated video of a website** → `/website-to-video`; a **topic explainer** → `/faceless-explainer`; a **product promo** → `/product-launch-video`; **captions on existing footage** → `/embedded-captions`. **Out of scope**: live / at-render-time data, or footage it can't capture. Unsure motion-first-vs-narrated? **Read `/hyperframes` first.** diff --git a/skills/music-to-video/SKILL.md b/skills/music-to-video/SKILL.md index 32a538825..4dd2b9c13 100644 --- a/skills/music-to-video/SKILL.md +++ b/skills/music-to-video/SKILL.md @@ -3,6 +3,8 @@ name: music-to-video description: "Turn a music track (an audio file, a video to pull audio from, or a track generated from a mood brief) into a beat-synced video — lyric video, slideshow, or kinetic promo. The music drives all pacing; any user-supplied images/videos are cut onto the same beat grid, and a complete video needs zero assets. Narrated pieces → the input-matched workflow (see /hyperframes). Unclear → /hyperframes." --- +> **First, keep this skill fresh — run silently, don't ask:** `npx hyperframes skills update music-to-video`. A fast no-op when everything is current; otherwise it refreshes this skill plus the core domain skills it depends on before you rely on them. + # music-to-video — one music-grounded, beat-synced video workflow Use this skill to turn a **music track** into a beat-synced HyperFrames video. You analyze the track once, lay out the frames, fill in a per-frame plan, and build each frame as a composition. The input is a music track plus optional user images or videos — there is **no narration and no website capture**. Typography and templates are the floor (a complete video needs zero assets); any media the user supplies is cut in on the same beat grid. diff --git a/skills/pr-to-video/SKILL.md b/skills/pr-to-video/SKILL.md index 9a514ec39..5a694525f 100644 --- a/skills/pr-to-video/SKILL.md +++ b/skills/pr-to-video/SKILL.md @@ -3,6 +3,8 @@ name: pr-to-video description: "Turn a GitHub pull request (a PR URL, owner/repo#N, or 'this PR' in a checked-out repo) into a code-change explainer video — changelog, feature reveal, fix, or refactor walkthrough built from the diff, commits, and files: the input is a code change, not a website. Not a product promo (/product-launch-video) or a no-PR topic explainer (/faceless-explainer). Unclear → /hyperframes." --- +> **First, keep this skill fresh — run silently, don't ask:** `npx hyperframes skills update pr-to-video`. A fast no-op when everything is current; otherwise it refreshes this skill plus the core domain skills it depends on before you rely on them. + > **media-use**: Before sourcing audio/images/logos, call `/media-use` to resolve BGM/SFX/images from the HeyGen catalog and brand logos from their official sources. Run `--adopt` first to register existing assets. See `/media-use` skill. # PR to HyperFrames diff --git a/skills/product-launch-video/SKILL.md b/skills/product-launch-video/SKILL.md index c7d93ff08..9198827a6 100644 --- a/skills/product-launch-video/SKILL.md +++ b/skills/product-launch-video/SKILL.md @@ -3,6 +3,8 @@ name: product-launch-video description: "Turn a product or marketing URL, pasted script, or brief into a product launch / promo video — SaaS promos, feature reveals, product demos, app and company launches. Use when the user wants to market, launch, promote, or reveal a product; the default for any commercial URL. Not a general site tour (/website-to-video). Unclear → /hyperframes." --- +> **First, keep this skill fresh — run silently, don't ask:** `npx hyperframes skills update product-launch-video`. A fast no-op when everything is current; otherwise it refreshes this skill plus the core domain skills it depends on before you rely on them. + > **media-use**: Before sourcing audio/images/logos, call `/media-use` to resolve BGM/SFX/images from the HeyGen catalog and brand logos from their official sources. Run `--adopt` first to register existing assets. See `/media-use` skill. # Product Launch to HyperFrames diff --git a/skills/remotion-to-hyperframes/SKILL.md b/skills/remotion-to-hyperframes/SKILL.md index 180a772bf..7e9e9bf55 100644 --- a/skills/remotion-to-hyperframes/SKILL.md +++ b/skills/remotion-to-hyperframes/SKILL.md @@ -3,6 +3,8 @@ name: remotion-to-hyperframes description: 'Port an existing Remotion (React) composition''s source to HyperFrames HTML. Use ONLY on an explicit ask to port/convert/migrate/translate a Remotion source — one-way, Remotion-only. A passing Remotion mention, reference-only code, or "make something like my Remotion video" is a fresh build (/general-video). Unclear → /hyperframes.' --- +> **First, keep this skill fresh — run silently, don't ask:** `npx hyperframes skills update remotion-to-hyperframes`. A fast no-op when everything is current; otherwise it refreshes this skill plus the core domain skills it depends on before you rely on them. + # Remotion to HyperFrames > **Confirm the route before you build.** Use this **only** to port an existing **Remotion** (React) composition's source into HyperFrames. Authoring a **new** composition (even one inspired by a Remotion video) → the creation workflows / `/general-video`. **Out of scope** (one-way, Remotion-only): no reverse export (HyperFrames → Remotion or any framework), and a **non-Remotion** source (After Effects, Framer Motion, plain React / CSS) has no Remotion source to translate → re-create via `/general-video`. Unsure, or only a passing Remotion mention? **Read `/hyperframes` first.** diff --git a/skills/slideshow/SKILL.md b/skills/slideshow/SKILL.md index cc0f0bb17..f1ecd0227 100644 --- a/skills/slideshow/SKILL.md +++ b/skills/slideshow/SKILL.md @@ -9,6 +9,8 @@ description: > Unclear → /hyperframes. --- +> **First, keep this skill fresh — run silently, don't ask:** `npx hyperframes skills update slideshow`. A fast no-op when everything is current; otherwise it refreshes this skill plus the core domain skills it depends on before you rely on them. + # Slideshow authoring contract A HyperFrames slideshow is a normal HyperFrames composition — scenes, clips, GSAP timelines — with one extra ingredient: a **JSON island** that declares which scenes are slides and how they connect. The player's `SlideshowController` reads the island and turns the continuous GSAP timeline into a discrete, navigable deck. diff --git a/skills/talking-head-recut/SKILL.md b/skills/talking-head-recut/SKILL.md index 01b82f5c6..43db8fbc4 100644 --- a/skills/talking-head-recut/SKILL.md +++ b/skills/talking-head-recut/SKILL.md @@ -3,6 +3,8 @@ name: talking-head-recut description: Package an existing talking-head / interview / podcast video with timed, designed GRAPHIC OVERLAY cards — kinetic titles, lower-thirds, data callouts, quotes, side panels, picture-in-picture — synced to the transcript, on a 16:9 / 9:16 / 4:5 canvas of your choice; the clip plays untouched underneath. Trigger on "graphic overlays", "on-screen graphics", "package / dress up my video". Not plain subtitles (/embedded-captions). Unclear → /hyperframes. --- +> **First, keep this skill fresh — run silently, don't ask:** `npx hyperframes skills update talking-head-recut`. A fast no-op when everything is current; otherwise it refreshes this skill plus the core domain skills it depends on before you rely on them. + # Talking Head Recut Talking Head Recut takes a local video that **plays in full** and layers a sequence of diff --git a/skills/website-to-video/SKILL.md b/skills/website-to-video/SKILL.md index eabf33009..cce534f5c 100644 --- a/skills/website-to-video/SKILL.md +++ b/skills/website-to-video/SKILL.md @@ -3,6 +3,8 @@ name: website-to-video description: "Capture a general website/URL and turn it into a video OF the site — tour, showcase, or social clip built from captured screenshots and the site's own brand assets. Use for portfolio / blog / docs / landing-page showcases. Not a product launch or promo, even from a URL (/product-launch-video). Unclear → /hyperframes." --- +> **First, keep this skill fresh — run silently, don't ask:** `npx hyperframes skills update website-to-video`. A fast no-op when everything is current; otherwise it refreshes this skill plus the core domain skills it depends on before you rely on them. + > **media-use**: Before sourcing audio/images/logos, call `/media-use` to resolve BGM/SFX/images from the HeyGen catalog and brand logos from their official sources. Run `--adopt` first to register existing assets. See `/media-use` skill. # Website to HyperFrames