fix(cli,skills): install workflow skills on demand instead of re-pulling the full set (#2012)

* fix(cli,skills): install workflow skills on demand instead of re-pulling the full set

Users report every init re-pulls all 21 skills into ~/.agents/skills
whenever anything is stale or missing - heavy, noisy, and it re-expands
deliberate partial installs.

Split the set into two tiers:

- core: the /hyperframes router + hyperframes-* domain skills +
  media-use, which every workflow references structurally. init and
  bare 'skills update' keep these (plus anything already installed)
  fresh, and never expand the install.
- on demand: the end-user workflow skills (and figma). They install at
  trigger time via 'skills update <name...>' - positional names are
  the only way update expands an install: one targeted
  'skills add --skill <name>' covering only stale/missing targets, a
  fast no-op when current, presence-verified after install, exit 1 on
  unknown names, and a presence-only degrade when GitHub is
  unreachable.

The /hyperframes router now runs 'skills update <workflow>' after
routing and before reading the workflow skill, so a routed workflow is
guaranteed present even on a machine that only has the core set. Each
on-demand skill also opens with the same self-maintenance step (run
'npx hyperframes skills update <name>' silently), so a workflow
triggered directly - without the router - still refreshes itself and
restores any missing core skill before relying on it.

When the manifest is unreachable (offline / rate-limited) the engine
degrades honestly instead of claiming success: named runs presence-check
the request plus a pinned fallback core list (unit-pinned to skills/)
and blind-install whatever is absent; a bare strict update fails loudly
so the 'check || update' chain can't pass while everything stays stale;
init reports the skipped freshness check. --json emits structured
errors on failure paths.

skills check still lists every skill, but exits non-zero only for
stale installed skills, an incomplete core set, or removed leftovers -
workflow skills not yet installed are reported as available on demand.
Bare 'hyperframes skills' (and 'skills add --all') remain the explicit
full-set installs.

Verified end-to-end with a sandboxed $HOME: fresh init installs the 9
core skills only; 'skills update slideshow' adds exactly that skill
(no-op on re-run, exit 1 on unknown names); bare update refreshes
without expanding; a live Claude Code run routed PR-to-video, executed
the router's update step, and the workflow skill appeared before use;
and a second live run triggered an installed workflow directly, whose
opening maintenance step restored a deliberately removed core skill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(skills): clarify update-engine contracts + document lazy-install model

- skills.ts: note the UpdateSkillsResult.unknown strict-mode contract,
  verifyInstalled's non-strict (warn-not-throw) intent, and that a
  partial install stays "refreshed but never expanded" (review nits).
- docs/guides/skills.mdx: add a "Keeping skills current" section covering
  the core-eager / workflow-on-demand model and the skills check|update
  commands, per the repo's catalog-maintenance rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: kiritowoo <295860553+kiritowoo@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Miao Yang <miao.yang@heygen.com>
This commit is contained in:
kiritowoo
2026-07-09 01:30:51 +08:00
committed by GitHub
co-authored by Claude Opus 4.8 kiritowoo Miao Yang
parent e52bfc246a
commit 81884a7495
25 changed files with 982 additions and 160 deletions
+2
View File
@@ -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 <name> --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. 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 <name> --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 <workflow>` before entering one. Nothing re-pulls the full set behind your back.
### Router ### Router
| Skill | Use when | | Skill | Use when |
+18
View File
@@ -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. **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.
</Tip> </Tip>
## 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 <workflow>` 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 <name> # 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 ## Router
The single entry point. Read `/hyperframes` before invoking anything else — it knows what's available and which workflow fits your request. The single entry point. Read `/hyperframes` before invoking anything else — it knows what's available and which workflow fits your request.
+38 -39
View File
@@ -577,48 +577,46 @@ async function scaffoldProject(
} }
/** /**
* Ensure the AI coding skills are present and current. Checks the installed * Keep the AI coding skills present and current TARGETED, not the full
* skills against the latest published on GitHub and only (re)installs when * set. Guarantees the core set (the `/hyperframes` entry router + shared
* something is outdated or missing so re-running `init` on an up-to-date * domain skills) and refreshes any skill already installed; the end-user
* machine is a no-op. Best-effort: if the version check can't reach GitHub, it * workflow skills are NOT pulled here they install on demand when their
* installs anyway. The install itself (`installAllSkills`) installs the full set * workflow is triggered (`hyperframes skills update <name>`, which the router
* once GLOBALLY (~/.claude/skills + ~/.agents/skills) and mirrors it into every * runs before entering a workflow). Re-running `init` on an up-to-date machine
* other installed agent, so it is project-independent the check is global-first * is a no-op, and `init` never expands a deliberate partial install.
* to match. * 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<void> { async function keepSkillsCurrent(destDir: string): Promise<void> {
const { installAllSkills } = await import("./skills.js"); const { updateSkills } = await import("./skills.js");
const { checkSkills } = await import("../utils/skillsManifest.js");
console.log(); console.log();
console.log(c.bold("Checking AI coding skills against GitHub...")); 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 { try {
const result = await checkSkills({ cwd: destDir }); const result = await updateSkills({ refreshInstalled: true, cwd: destDir });
needsInstall = result.updateAvailable; if (result.presenceOnly) {
} catch { // Freshness never got checked (GitHub unreachable) — don't claim
// Couldn't reach GitHub (offline, rate-limited) — install anyway. // "up to date"; the engine already reported what it could verify or
} // blind-install. Point at the recovery command instead.
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) {
console.log( 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 { } catch (err) {
console.log(c.success("AI coding skills are already up to date.")); console.log(
c.dim(`AI coding skills install skipped: ${err instanceof Error ? err.message : err}`),
);
} }
} }
@@ -871,7 +869,7 @@ export default defineCommand({
} }
if (!skipSkills) { if (!skipSkills) {
await ensureSkillsCurrent(destDir); await keepSkillsCurrent(destDir);
} }
console.log(); console.log();
@@ -1087,11 +1085,12 @@ export default defineCommand({
const files = readdirSync(destDir); const files = readdirSync(destDir);
clack.note(files.map((f) => c.accent(f)).join("\n"), c.success(`Created ${name}/`)); 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 — // Check skills against GitHub and refresh only what's stale — the core set
// init is the one place the full set is pulled. The --skip-skills flag is // plus anything already installed; workflow skills install on demand. The
// temporarily neutered (see above); CI/tests opt out via HYPERFRAMES_SKIP_SKILLS=1. // --skip-skills flag is temporarily neutered (see above); CI/tests opt out
// via HYPERFRAMES_SKIP_SKILLS=1.
if (!skipSkills) { if (!skipSkills) {
await ensureSkillsCurrent(destDir); await keepSkillsCurrent(destDir);
} }
// Auto-launch studio preview // Auto-launch studio preview
+343 -49
View File
@@ -67,15 +67,38 @@ vi.mock("../telemetry/events.js", () => ({
trackSkillsInstallSkipped: (...args: unknown[]) => trackSkillsInstallSkipped(...args), trackSkillsInstallSkipped: (...args: unknown[]) => trackSkillsInstallSkipped(...args),
})); }));
// `skills update` calls checkSkills() to find skills removed upstream, then // A realistic check result for the targeted paths (`update`, with or without names):
// prunes them. Mock it so these tests don't touch the real FS / network; the // one core skill outdated, one core missing, one on-demand workflow installed
// default returns nothing removed, and the prune test overrides per-call. // and current, one on-demand workflow not installed. `update` must refresh the
vi.mock("../utils/skillsManifest.js", () => ({ // two stale core skills, leave `pr-to-video` alone (on demand), and keep
checkSkills: vi.fn(async () => ({ skills: [] })), // `embedded-captions` (installed + current) untouched.
// installAllSkills resolves the HyperFrames skill names (lock-attributed) to const DEFAULT_CHECK = {
// scope the mirror; pin it so these arg-shape tests don't read a real lock. location: "/home/user/.claude/skills",
hyperframesSkillNames: vi.fn(() => ["hyperframes"]), 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<typeof import("../utils/skillsManifest.js")>();
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 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 // 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: [] })), mirrorGlobalSkills: vi.fn(() => ({ source: null, mirrored: [] })),
})); }));
// The global install command this CLI runs (after `skills add <url>`). // The global install command this CLI runs (after `skills add <url>` and the
const GLOBAL_ARGS = [ // per-name `--skill` selection).
"--skill", const GLOBAL_ARGS_TAIL = [
"*",
"--global", "--global",
"--agent", "--agent",
"claude-code", "claude-code",
@@ -104,24 +126,55 @@ function setPlatform(platform: NodeJS.Platform): void {
}); });
} }
/** Invoke the `skills update` subcommand from a freshly-imported module. */ /** Invoke a `skills <name>` subcommand from a freshly-imported module. */
async function runSkillsUpdate(args: Record<string, unknown> = {}): Promise<void> { async function runSkillsSub(
name: "update",
args: Record<string, unknown> = {},
positionals: string[] = [],
): Promise<void> {
const { default: skillsCmd } = await import("./skills.js"); const { default: skillsCmd } = await import("./skills.js");
const subs = skillsCmd.subCommands as unknown as Record<string, typeof skillsCmd>; const subs = skillsCmd.subCommands as unknown as Record<string, typeof skillsCmd>;
expect(subs.update).toBeDefined(); expect(subs[name]).toBeDefined();
await subs.update!.run?.({ args, rawArgs: [], cmd: subs.update } as never); await subs[name]!.run?.({
args: { _: positionals, ...args },
rawArgs: positionals,
cmd: subs[name],
} as never);
}
const runSkillsUpdate = (args: Record<string, unknown> = {}): Promise<void> =>
runSkillsSub("update", args);
const runSkillsUpdateWith = (
positionals: string[],
args: Record<string, unknown> = {},
): Promise<void> => runSkillsSub("update", args, positionals);
/** The `--skill` values of a spawned `skills add` call. */
function skillFlagValues(args: ReadonlyArray<string>): 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", () => { describe("hyperframes skills", () => {
let prevExitCode: typeof process.exitCode; let prevExitCode: typeof process.exitCode;
beforeEach(() => { beforeEach(async () => {
state.execCalls = []; state.execCalls = [];
state.spawnCalls = []; state.spawnCalls = [];
state.spawnExitCode = 0; state.spawnExitCode = 0;
state.gitMissing = false; state.gitMissing = false;
trackSkillsInstallSkipped.mockClear(); trackSkillsInstallSkipped.mockClear();
vi.resetModules(); 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. // Each test asserts on process.exitCode; isolate it from the runner's own.
prevExitCode = process.exitCode; prevExitCode = process.exitCode;
process.exitCode = 0; process.exitCode = 0;
@@ -154,13 +207,27 @@ describe("hyperframes skills", () => {
"linux", "linux",
"npx", "npx",
["--version"], ["--version"],
["skills", "add", "https://github.com/heygen-com/hyperframes", ...GLOBAL_ARGS], [
"skills",
"add",
"https://github.com/heygen-com/hyperframes",
"--skill",
"*",
...GLOBAL_ARGS_TAIL,
],
], ],
[ [
"darwin", "darwin",
"npx", "npx",
["--version"], ["--version"],
["skills", "add", "https://github.com/heygen-com/hyperframes", ...GLOBAL_ARGS], [
"skills",
"add",
"https://github.com/heygen-com/hyperframes",
"--skill",
"*",
...GLOBAL_ARGS_TAIL,
],
], ],
[ [
"win32", "win32",
@@ -174,11 +241,13 @@ describe("hyperframes skills", () => {
"skills", "skills",
"add", "add",
"https://github.com/heygen-com/hyperframes", "https://github.com/heygen-com/hyperframes",
...GLOBAL_ARGS, "--skill",
"*",
...GLOBAL_ARGS_TAIL,
], ],
], ],
] as const)( ] 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) => { async (platform, expectedCommand, expectedPreflightArgs, expectedInstallArgs) => {
setPlatform(platform); setPlatform(platform);
@@ -202,16 +271,23 @@ describe("hyperframes skills", () => {
expect(process.exitCode).toBe(1); 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"); setPlatform("linux");
await runSkillsUpdate(); await runSkillsUpdate();
expect(process.exitCode).toBe(0); expect(process.exitCode).toBe(0);
const args = state.spawnCalls[0]?.args ?? []; 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("https://github.com/heygen-com/hyperframes");
expect(args).toContain("--global"); expect(args).toContain("--global");
expect(args).toContain("--copy"); expect(args).toContain("--copy");
expect(args).toContain("--full-depth"); 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 // never the `--all` (= `--agent '*'`) spray
expect(args).not.toContain("--all"); expect(args).not.toContain("--all");
// `--agent` must be followed by a concrete key, never the `'*'` wildcard // `--agent` must be followed by a concrete key, never the `'*'` wildcard
@@ -219,15 +295,54 @@ describe("hyperframes skills", () => {
expect(agentValue).not.toBe("*"); expect(agentValue).not.toBe("*");
}); });
// `skills add --all` never deletes, so update must separately prune skills the 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({
...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. // 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 prunes skills removed upstream, in the attributed scope", async () => {
setPlatform("linux"); setPlatform("linux");
const { checkSkills } = await import("../utils/skillsManifest.js"); const { checkSkills } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockResolvedValueOnce({ // First call feeds the targeted install; the second is the prune detection.
scope: "global", vi.mocked(checkSkills)
skills: [{ name: "graphic-overlays", status: "removed" }], .mockResolvedValueOnce(DEFAULT_CHECK as never)
} as never); .mockResolvedValueOnce({
scope: "global",
skills: [{ name: "graphic-overlays", status: "removed" }],
} as never);
await runSkillsUpdate(); await runSkillsUpdate();
@@ -247,10 +362,12 @@ describe("hyperframes skills", () => {
it("skills update prunes in project scope without -g", async () => { it("skills update prunes in project scope without -g", async () => {
setPlatform("linux"); setPlatform("linux");
const { checkSkills } = await import("../utils/skillsManifest.js"); const { checkSkills } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockResolvedValueOnce({ vi.mocked(checkSkills)
scope: "project", .mockResolvedValueOnce(DEFAULT_CHECK as never)
skills: [{ name: "graphic-overlays", status: "removed" }], .mockResolvedValueOnce({
} as never); scope: "project",
skills: [{ name: "graphic-overlays", status: "removed" }],
} as never);
await runSkillsUpdate(); await runSkillsUpdate();
@@ -272,11 +389,13 @@ describe("hyperframes skills", () => {
it("skills update plumbs --source/--dir to its prune detection (parity with check)", async () => { it("skills update plumbs --source/--dir to its prune detection (parity with check)", async () => {
setPlatform("linux"); setPlatform("linux");
const { checkSkills } = await import("../utils/skillsManifest.js"); const { checkSkills } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockResolvedValueOnce({ scope: "project", skills: [] } as never);
await runSkillsUpdate({ source: "owner/repo", dir: "/custom/skills" }); 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 // 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 () => { it("skills update never passes a non-slug skill name to remove", async () => {
setPlatform("linux"); setPlatform("linux");
const { checkSkills } = await import("../utils/skillsManifest.js"); const { checkSkills } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockResolvedValueOnce({ vi.mocked(checkSkills)
scope: "global", .mockResolvedValueOnce(DEFAULT_CHECK as never)
skills: [ .mockResolvedValueOnce({
{ name: "graphic-overlays", status: "removed" }, scope: "global",
{ name: "--config=evil.js", status: "removed" }, skills: [
], { name: "graphic-overlays", status: "removed" },
} as never); { name: "--config=evil.js", status: "removed" },
],
} as never);
await runSkillsUpdate(); await runSkillsUpdate();
@@ -308,13 +429,15 @@ describe("hyperframes skills", () => {
it("skills update spawns no remove when every removed name is rejected", async () => { it("skills update spawns no remove when every removed name is rejected", async () => {
setPlatform("linux"); setPlatform("linux");
const { checkSkills } = await import("../utils/skillsManifest.js"); const { checkSkills } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockResolvedValueOnce({ vi.mocked(checkSkills)
scope: "global", .mockResolvedValueOnce(DEFAULT_CHECK as never)
skills: [ .mockResolvedValueOnce({
{ name: "--config=evil.js", status: "removed" }, scope: "global",
{ name: "../escape", status: "removed" }, skills: [
], { name: "--config=evil.js", status: "removed" },
} as never); { name: "../escape", status: "removed" },
],
} as never);
await runSkillsUpdate(); await runSkillsUpdate();
@@ -368,3 +491,174 @@ describe("hyperframes skills", () => {
expect(process.exitCode).toBe(1); expect(process.exitCode).toBe(1);
}); });
}); });
// The router contract: `/hyperframes` picks a workflow, then runs
// `hyperframes skills update <workflow>` 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 <names>", () => {
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);
});
});
+323 -28
View File
@@ -6,7 +6,10 @@ import { buildNpxCommand } from "../utils/npxCommand.js";
import { withMeta } from "../utils/updateCheck.js"; import { withMeta } from "../utils/updateCheck.js";
import { import {
checkSkills, checkSkills,
FALLBACK_CORE_SKILLS,
hyperframesSkillNames, hyperframesSkillNames,
isCoreSkill,
presentSkills,
SKILLS_CLI_LOCK_PATHS_VERIFIED_AT, SKILLS_CLI_LOCK_PATHS_VERIFIED_AT,
type SkillDiff, type SkillDiff,
type SkillsCheckResult, type SkillsCheckResult,
@@ -19,7 +22,8 @@ export const examples: Example[] = [
["Install all HyperFrames skills", "hyperframes skills"], ["Install all HyperFrames skills", "hyperframes skills"],
["Check whether installed skills are up to date", "hyperframes skills check"], ["Check whether installed skills are up to date", "hyperframes skills check"],
["Check, machine-readable (for agents / CI)", "hyperframes skills check --json"], ["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 { function hasNpx(): boolean {
@@ -52,7 +56,7 @@ function spawnNpx(args: string[], opts: { cwd?: string } = {}): Promise<void> {
const child = spawn(npx.command, npx.args, { const child = spawn(npx.command, npx.args, {
stdio: "inherit", stdio: "inherit",
// We install with --full-depth (a full `git clone` of the repo, the only // 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. // which is heavier than the blob fetch, so allow more headroom.
timeout: 300_000, timeout: 300_000,
cwd: opts.cwd, cwd: opts.cwd,
@@ -94,9 +98,7 @@ function spawnNpx(args: string[], opts: { cwd?: string } = {}): Promise<void> {
// skills.sh registry blob, which lags GitHub main by hours, so a fresh install // skills.sh registry blob, which lags GitHub main by hours, so a fresh install
// would read as several skills "outdated" (verified: blob → ~9 outdated; // would read as several skills "outdated" (verified: blob → ~9 outdated;
// --full-depth → all current). // --full-depth → all current).
const GLOBAL_INSTALL_ARGS = [ const GLOBAL_INSTALL_ARGS_TAIL = [
"--skill",
"*",
"--global", "--global",
"--agent", "--agent",
"claude-code", "claude-code",
@@ -106,11 +108,25 @@ const GLOBAL_INSTALL_ARGS = [
"--yes", "--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( function runSkillsAdd(
source: string, source: string,
opts: { cwd?: string; extraArgs?: string[] } = {}, selection: SkillSelection,
opts: { cwd?: string } = {},
): Promise<void> { ): Promise<void> {
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 // 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<vo
} }
// Use the full GitHub URL (not the `owner/repo` slug) as the clone source. The // Use the full GitHub URL (not the `owner/repo` slug) as the clone source. The
// freshness comes from --full-depth (see GLOBAL_INSTALL_ARGS), which clones the // freshness comes from --full-depth (see GLOBAL_INSTALL_ARGS_TAIL), which clones the
// repo at latest `main`; the URL just names what to clone. Our freshness check // repo at latest `main`; the URL just names what to clone. Our freshness check
// resolves "latest" straight from GitHub too, so install and check agree. // resolves "latest" straight from GitHub too, so install and check agree.
const SOURCES = [{ name: "HyperFrames", url: "https://github.com/heygen-com/hyperframes" }]; const SOURCES = [{ name: "HyperFrames", url: "https://github.com/heygen-com/hyperframes" }];
@@ -203,9 +219,27 @@ function skillsToolingReady(strict: boolean): boolean {
return true; return true;
} }
export async function installAllSkills( // Skill names can originate from a fetched manifest or agent argv, and each is
opts: { cwd?: string; extraArgs?: string[]; strict?: boolean } = {}, // spread into a spawn as a `--skill` value — apply the same slug guard as
// runSkillsRemove so a flag-like name can't smuggle into the command. Returns
// null when nothing valid is left to install.
function sanitizeSelection(selection: SkillSelection): SkillSelection | null {
if (selection === "*") return selection;
const rejected = selection.filter((n) => !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<void> { ): Promise<void> {
const safeSelection = sanitizeSelection(selection);
if (safeSelection === null) return;
if (!skillsToolingReady(opts.strict ?? false)) return; if (!skillsToolingReady(opts.strict ?? false)) return;
for (const source of SOURCES) { for (const source of SOURCES) {
@@ -213,7 +247,7 @@ export async function installAllSkills(
console.log(c.bold(`Installing ${source.name} skills...`)); console.log(c.bold(`Installing ${source.name} skills...`));
console.log(); console.log();
try { try {
await runSkillsAdd(source.url, opts); await runSkillsAdd(source.url, safeSelection, opts);
} catch (err) { } catch (err) {
if (opts.strict) throw err instanceof Error ? err : new Error(String(err)); if (opts.strict) throw err instanceof Error ? err : new Error(String(err));
console.log(c.dim(`${source.name} skills skipped`)); console.log(c.dim(`${source.name} skills skipped`));
@@ -223,6 +257,174 @@ export async function installAllSkills(
mirrorToInstalledAgents(); 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<UpdateSkillsResult> {
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 <workflow>`): 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<UpdateSkillsResult> {
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 ──────────────────────────────────────────────────────────────────── // ── check ────────────────────────────────────────────────────────────────────
/** Print a labelled list of skills (nothing if empty), each line uniformly coloured. */ /** Print a labelled list of skills (nothing if empty), each line uniformly coloured. */
@@ -232,8 +434,9 @@ function printSkillSection(
title: string, title: string,
mark: string, mark: string,
color: (s: string) => string, color: (s: string) => string,
filter: (s: SkillDiff) => boolean = () => true,
): void { ): 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; if (!items.length) return;
console.log(); console.log();
console.log(` ${color(title)}`); 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(` ${c.bold("Location")} ${c.dim(result.location)} ${c.dim(`(${result.agent})`)}`);
console.log(); console.log();
const onDemandMissing = summary.missing - summary.coreMissing;
const parts = [c.success(`${summary.current} current`)]; const parts = [c.success(`${summary.current} current`)];
if (summary.outdated) parts.push(c.warn(`${summary.outdated} outdated`)); 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`)); if (summary.removed) parts.push(c.warn(`${summary.removed} removed upstream`));
console.log(` ${parts.join(" ")}`); console.log(` ${parts.join(" ")}`);
printSkillSection(result, "outdated", "Outdated:", "↑", c.warn); 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( printSkillSection(
result, result,
"removed", "removed",
@@ -321,17 +541,75 @@ const checkCommand = defineCommand({
// ── update ─────────────────────────────────────────────────────────────────── // ── 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({ const updateCommand = defineCommand({
meta: { meta: {
name: "update", name: "update",
description: 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 // Mirror `check`'s flags: the prune step runs the same removed-detection, so it
// must respect the same overrides. Without these, `update`'s internal // must respect the same overrides. Without these, `update`'s internal
// checkSkills() fell back to defaults — pruning the auto-detected install // checkSkills() fell back to defaults — pruning the auto-detected install
// against the default manifest even when the user pointed `check` elsewhere. // against the default manifest even when the user pointed `check` elsewhere.
args: { args: {
json: { type: "boolean", description: "Output as JSON", default: false },
dir: { dir: {
type: "string", type: "string",
description: description:
@@ -347,27 +625,44 @@ const updateCommand = defineCommand({
const dir = args.dir; const dir = args.dir;
const source = args.source; const source = args.source;
// The install re-fetches every skill to the latest AND installs ones not yet // Positional skill names (e.g. `hyperframes skills update pr-to-video`) are
// present — so "update" pulls the full set, not just what is already // the ONLY way update expands an install: each named skill is guaranteed
// installed. This is where `init` and the stale-skills nudge both lead. // present and current. This is the router's trigger-time step — the
// runSkillsAdd resolves the agent target set itself (existing project // /hyperframes router runs it after picking a workflow, before reading the
// folders → installed CLIs → a Claude-Code + `.agents` floor); we no longer // workflow's skill.
// spray to every agent via `--all`. 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 // 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 // the resolved agent dirs), so `--dir` here scopes only the *prune* detection
// below, not the install. `--source` likewise drives where the prune's // below, not the install. `--source` likewise drives where the prune's
// "latest" manifest comes from; the install always targets the canonical // "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 // strict: this is the documented recovery path for the agent/CI contract
// `hyperframes skills check || hyperframes skills update`. If the install // `hyperframes skills check || hyperframes skills update`, and the router's
// fails (no npx, `skills add` exits non-zero) it must exit non-zero too — // trigger-time guarantee. If the install fails (no npx, `skills add` exits
// otherwise the `||` chain passes while nothing actually changed. // non-zero, a named skill still absent afterwards) it must exit non-zero
// too — otherwise the `||` chain passes while nothing actually changed.
try { try {
await installAllSkills({ strict: true }); const result = await updateSkills({ requested, refreshInstalled: true, strict: true });
reportUpdate(result, requested, args.json === true);
} catch (err) { } 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; process.exitCode = 1;
return; return;
} }
@@ -413,6 +708,6 @@ export default defineCommand({
// citty runs this parent handler even when a subcommand matches; guard on // citty runs this parent handler even when a subcommand matches; guard on
// the positional so bare `hyperframes skills` installs, while // the positional so bare `hyperframes skills` installs, while
// `hyperframes skills check|update` does not also re-install. // `hyperframes skills check|update` does not also re-install.
if (!args._?.[0]) await installAllSkills(); if (!args._?.[0]) await installSkills("*");
}, },
}); });
+5 -2
View File
@@ -23,8 +23,11 @@ The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes-
> **Tailwind v4 projects** (`hyperframes init --tailwind`): see `/hyperframes-core``references/tailwind.md`. > **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` > **Skill missing or stale?** Run `npx hyperframes skills update <name>` to install/refresh
> and restart the agent session so the new skills load. > 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 ## Commands
+5 -2
View File
@@ -23,8 +23,11 @@ The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes-
> **Tailwind v4 projects** (`hyperframes init --tailwind`): see `/hyperframes-core``references/tailwind.md`. > **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` > **Skill missing or stale?** Run `npx hyperframes skills update <name>` to install/refresh
> and restart the agent session so the new skills load. > 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 ## Commands
+112 -10
View File
@@ -1,12 +1,16 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"; 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 { tmpdir } from "node:os";
import { join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { import {
hashSkillBundle, hashSkillBundle,
buildManifest, buildManifest,
checkSkills, checkSkills,
diffSkills, diffSkills,
FALLBACK_CORE_SKILLS,
isCoreSkill,
presentSkills,
skillsAttributedToSource, skillsAttributedToSource,
type SkillsManifest, type SkillsManifest,
type SkillEntry, 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", () => { describe("diffSkills", () => {
const latest: SkillsManifest = { const latest: SkillsManifest = {
source: "test", source: "test",
@@ -94,14 +132,10 @@ describe("diffSkills", () => {
changed: "outdated", changed: "outdated",
gone: "missing", 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", () => { it("flags updateAvailable for anything outdated", () => {
// 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);
const hasOutdated = diffSkills({ changed: { hash: "X", files: 1 } }, latest); const hasOutdated = diffSkills({ changed: { hash: "X", files: 1 } }, latest);
expect(hasOutdated.updateAvailable).toBe(true); expect(hasOutdated.updateAvailable).toBe(true);
@@ -128,6 +162,57 @@ describe("diffSkills", () => {
); );
expect(withExtra.updateAvailable).toBe(false); 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", () => { describe("checkSkills install detection", () => {
@@ -213,11 +298,22 @@ describe("checkSkills install detection", () => {
const home = join(root, "home"); const home = join(root, "home");
mkdirSync(project, { recursive: true }); mkdirSync(project, { recursive: true });
mkdirSync(home, { 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 }); const res = await checkSkills({ source, cwd: project, home });
expect(res.location).toBeNull(); expect(res.location).toBeNull();
expect(res.summary.missing).toBe(2); expect(res.summary.missing).toBe(2);
expect(res.summary.coreMissing).toBe(1);
expect(res.updateAvailable).toBe(true); expect(res.updateAvailable).toBe(true);
}); });
@@ -349,7 +445,13 @@ describe("checkSkills removed-upstream detection", () => {
writeGlobalLock(home, { alpha: { source: "test" }, gamma: { source: "test" } }); writeGlobalLock(home, { alpha: { source: "test" }, gamma: { source: "test" } });
const res = await checkSkills(opts); 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); expect(res.updateAvailable).toBe(true);
}); });
+80 -7
View File
@@ -72,7 +72,8 @@ export interface SkillDiff {
/** The pure manifest diff (current / outdated / missing — what `diffSkills` returns). */ /** The pure manifest diff (current / outdated / missing — what `diffSkills` returns). */
export interface SkillsDiff { export interface SkillsDiff {
updateAvailable: boolean; 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[]; 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 of the located install — so a caller prunes in the same scope it attributed from. */
scope: "project" | "global" | null; scope: "project" | "global" | null;
updateAvailable: boolean; updateAvailable: boolean;
summary: { current: number; outdated: number; missing: number; removed: number }; summary: {
current: number;
outdated: number;
missing: number;
coreMissing: number;
removed: number;
};
skills: SkillDiff[]; skills: SkillDiff[];
/** /**
* True when an install was located but the upstream skills lock was absent at * 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"; export const MANIFEST_FILE = "skills-manifest.json";
const FETCH_TIMEOUT_MS = 4000; 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 <name>`), 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 ──────────────────────────────────────────────────────────────── // ── Hashing ────────────────────────────────────────────────────────────────
function listFilesSorted(dir: string): string[] { function listFilesSorted(dir: string): string[] {
@@ -283,6 +334,20 @@ function locateInstall(
return null; 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`. */ /** Hash every manifest skill that is installed under `root`. */
function hashInstalled(root: SkillRoot, skillNames: string[]): Record<string, SkillEntry> { function hashInstalled(root: SkillRoot, skillNames: string[]): Record<string, SkillEntry> {
const out: Record<string, SkillEntry> = {}; const out: Record<string, SkillEntry> = {};
@@ -304,7 +369,7 @@ export function diffSkills(
// one "ours but removed" via the lock's source attribution, never the bare // one "ours but removed" via the lock's source attribution, never the bare
// directory name — `.../skills` is shared across sources. // directory name — `.../skills` is shared across sources.
const skills: SkillDiff[] = []; 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()) { for (const name of Object.keys(latest.skills).sort()) {
const latestEntry = latest.skills[name]!; const latestEntry = latest.skills[name]!;
@@ -316,7 +381,10 @@ export function diffSkills(
if (status === "current") summary.current++; if (status === "current") summary.current++;
else if (status === "outdated") summary.outdated++; else if (status === "outdated") summary.outdated++;
else summary.missing++; else {
summary.missing++;
if (isCoreSkill(name)) summary.coreMissing++;
}
skills.push({ skills.push({
name, name,
@@ -327,9 +395,14 @@ export function diffSkills(
} }
return { return {
// The full skill set is the goal — `init` and `skills update` both pull the // "Update available" means the install is stale, not merely partial:
// complete set, so anything outdated OR missing means an update is available. // anything installed-but-outdated, or a missing CORE skill (the entry
updateAvailable: summary.outdated > 0 || summary.missing > 0, // 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 <name>`). 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, summary,
skills, skills,
}; };
+4 -2
View File
@@ -42,13 +42,15 @@ async function refreshSkillsCache(): Promise<SkillsUpdateMeta> {
config.lastSkillsCheck = new Date().toISOString(); config.lastSkillsCheck = new Date().toISOString();
config.skillsUpdateAvailable = result.updateAvailable; config.skillsUpdateAvailable = result.updateAvailable;
config.skillsOutdatedCount = result.summary.outdated; 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); writeConfig(config);
} }
return { return {
updateAvailable: result.updateAvailable, updateAvailable: result.updateAvailable,
outdated: result.summary.outdated, outdated: result.summary.outdated,
missing: result.summary.missing, missing: result.summary.coreMissing,
}; };
} }
+13 -13
View File
@@ -2,23 +2,23 @@
"source": "heygen-com/hyperframes", "source": "heygen-com/hyperframes",
"skills": { "skills": {
"embedded-captions": { "embedded-captions": {
"hash": "d870992d206c9ddc", "hash": "36a43fc349052ab0",
"files": 144 "files": 144
}, },
"faceless-explainer": { "faceless-explainer": {
"hash": "58b1901b8ef7a84e", "hash": "027ae009f5255130",
"files": 18 "files": 18
}, },
"figma": { "figma": {
"hash": "aabcd697d0823efb", "hash": "ee1408c58b76db8e",
"files": 1 "files": 1
}, },
"general-video": { "general-video": {
"hash": "a4d2b4ed6d8a0643", "hash": "c266136f4ffa2157",
"files": 1 "files": 1
}, },
"hyperframes": { "hyperframes": {
"hash": "547fd3b432a028bb", "hash": "42ba4448d7552099",
"files": 1 "files": 1
}, },
"hyperframes-animation": { "hyperframes-animation": {
@@ -50,35 +50,35 @@
"files": 103 "files": 103
}, },
"motion-graphics": { "motion-graphics": {
"hash": "7452272d8da8934d", "hash": "2bd92504783e8a0c",
"files": 23 "files": 23
}, },
"music-to-video": { "music-to-video": {
"hash": "5c9d4b3307c40a33", "hash": "3e1e91e93a7a80d0",
"files": 132 "files": 132
}, },
"pr-to-video": { "pr-to-video": {
"hash": "30a91ac16fae4737", "hash": "088f89960ecaf0ed",
"files": 22 "files": 22
}, },
"product-launch-video": { "product-launch-video": {
"hash": "8499a01e77b41c6c", "hash": "cc8f9e4aeb87aa4b",
"files": 20 "files": 20
}, },
"remotion-to-hyperframes": { "remotion-to-hyperframes": {
"hash": "6d4b352ecd46c8fb", "hash": "aa599f027db4b994",
"files": 70 "files": 70
}, },
"slideshow": { "slideshow": {
"hash": "764746e9199213b6", "hash": "3dd62c326ccf25eb",
"files": 2 "files": 2
}, },
"talking-head-recut": { "talking-head-recut": {
"hash": "fbb95a4c6062c41a", "hash": "ff57c239045342a9",
"files": 27 "files": 27
}, },
"website-to-video": { "website-to-video": {
"hash": "76c4bae653b29e2f", "hash": "baa2f956f3b7a12d",
"files": 32 "files": 32
} }
} }
View File
+2
View File
@@ -5,6 +5,8 @@ metadata:
tags: captions, embedded-captions, occlusion, matting, talking-head, rembg-matting, whisper, ffmpeg, cinematic 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 # 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 做的"). **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 做的").
+2
View File
@@ -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." 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. > **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 # Faceless Explainer to HyperFrames
+2
View File
@@ -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. 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 # Figma → HyperFrames
Bring the user's Figma work into a composition. **Split by capability** (design spec §2): Bring the user's Figma work into a composition. **Split by capability** (design spec §2):
+2
View File
@@ -8,6 +8,8 @@ description: >
metadata: { "tags": "orchestrator, general-video, fallback, freeform, composition-authoring" } 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. > **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 # general-video — general video workflow
+15 -8
View File
@@ -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. - **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. - **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 <workflow-name>` (e.g. `--skill pr-to-video` — bare name, no leading `/`). ```bash
- **All workflows at once:** `npx skills add heygen-com/hyperframes --all` (core + every workflow, skips the picker). npx hyperframes skills update <workflow-name>
```
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 <workflow-name>`; everything at once: `npx skills add heygen-com/hyperframes --all`.
## Keeping skills current ## 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 <workflow-name>`).
If a task is behaving unexpectedly, or before a long build, confirm the installed skills are current: 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**). - **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`pulls the full set to the latest, **installing any not yet present** (same as init's install step). - **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 <name…>`) 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. The CLI also surfaces a one-line reminder when a `render` / `lint` / `validate` run detects stale skills.
+2
View File
@@ -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 # 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.** > **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.**
+2
View File
@@ -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." 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 # 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. 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.
+2
View File
@@ -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." 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. > **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 # PR to HyperFrames
+2
View File
@@ -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." 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. > **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 # Product Launch to HyperFrames
+2
View File
@@ -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.' 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 # 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.** > **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.**
+2
View File
@@ -9,6 +9,8 @@ description: >
Unclear → /hyperframes. 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 # 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. 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.
+2
View File
@@ -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. 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
Talking Head Recut takes a local video that **plays in full** and layers a sequence of Talking Head Recut takes a local video that **plays in full** and layers a sequence of
+2
View File
@@ -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." 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. > **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 # Website to HyperFrames