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
+38 -39
View File
@@ -577,48 +577,46 @@ async function scaffoldProject(
}
/**
* Ensure the AI coding skills are present and current. Checks the installed
* skills against the latest published on GitHub and only (re)installs when
* something is outdated or missing — so re-running `init` on an up-to-date
* machine is a no-op. Best-effort: if the version check can't reach GitHub, it
* installs anyway. The install itself (`installAllSkills`) installs the full set
* once GLOBALLY (~/.claude/skills + ~/.agents/skills) and mirrors it into every
* other installed agent, so it is project-independent — the check is global-first
* to match.
* Keep the AI coding skills present and current — TARGETED, not the full
* set. Guarantees the core set (the `/hyperframes` entry router + shared
* domain skills) and refreshes any skill already installed; the end-user
* workflow skills are NOT pulled here — they install on demand when their
* workflow is triggered (`hyperframes skills update <name>`, which the router
* runs before entering a workflow). Re-running `init` on an up-to-date machine
* is a no-op, and `init` never expands a deliberate partial install.
* Best-effort: offline, it degrades to a presence check and never breaks init.
* The install itself lands once GLOBALLY (~/.claude/skills + ~/.agents/skills)
* and mirrors into every other installed agent, so it is project-independent —
* the check is global-first to match.
*/
async function ensureSkillsCurrent(destDir: string): Promise<void> {
const { installAllSkills } = await import("./skills.js");
const { checkSkills } = await import("../utils/skillsManifest.js");
async function keepSkillsCurrent(destDir: string): Promise<void> {
const { updateSkills } = await import("./skills.js");
console.log();
console.log(c.bold("Checking AI coding skills against GitHub..."));
let needsInstall = true;
// Wrap defensively (non-strict already swallows most failures): a
// skills-install failure can never break `init` itself — it warns and
// proceeds, since --skip-skills no longer escapes this path.
try {
const result = await checkSkills({ cwd: destDir });
needsInstall = result.updateAvailable;
} catch {
// Couldn't reach GitHub (offline, rate-limited) — install anyway.
}
if (needsInstall) {
// installAllSkills installs the full set once globally and mirrors it into
// every installed agent's global dir — project-independent, so a freshly
// scaffolded project doesn't need any agent folders yet.
//
// Best-effort: installAllSkills (non-strict here) already swallows its own
// failures, but now that --skip-skills no longer escapes this path every
// init runs it — including offline ones, where checkSkills throws and we
// fall through to "install anyway". Wrap defensively so a skills-install
// failure can never break `init` itself; it only warns and proceeds.
try {
await installAllSkills({ cwd: destDir });
} catch (err) {
const result = await updateSkills({ refreshInstalled: true, cwd: destDir });
if (result.presenceOnly) {
// Freshness never got checked (GitHub unreachable) — don't claim
// "up to date"; the engine already reported what it could verify or
// blind-install. Point at the recovery command instead.
console.log(
c.dim(`AI coding skills install skipped: ${err instanceof Error ? err.message : err}`),
c.dim("Skills freshness unverified — run `npx hyperframes skills update` when online."),
);
} else if (result.installed.length === 0) {
console.log(c.success("AI coding skills are already up to date."));
} else {
console.log(
c.dim("Workflow skills not installed here are added on demand, when first used."),
);
}
} else {
console.log(c.success("AI coding skills are already up to date."));
} catch (err) {
console.log(
c.dim(`AI coding skills install skipped: ${err instanceof Error ? err.message : err}`),
);
}
}
@@ -871,7 +869,7 @@ export default defineCommand({
}
if (!skipSkills) {
await ensureSkillsCurrent(destDir);
await keepSkillsCurrent(destDir);
}
console.log();
@@ -1087,11 +1085,12 @@ export default defineCommand({
const files = readdirSync(destDir);
clack.note(files.map((f) => c.accent(f)).join("\n"), c.success(`Created ${name}/`));
// Check skills against GitHub and (re)install only if outdated or missing —
// init is the one place the full set is pulled. The --skip-skills flag is
// temporarily neutered (see above); CI/tests opt out via HYPERFRAMES_SKIP_SKILLS=1.
// Check skills against GitHub and refresh only what's stale — the core set
// plus anything already installed; workflow skills install on demand. The
// --skip-skills flag is temporarily neutered (see above); CI/tests opt out
// via HYPERFRAMES_SKIP_SKILLS=1.
if (!skipSkills) {
await ensureSkillsCurrent(destDir);
await keepSkillsCurrent(destDir);
}
// Auto-launch studio preview
+343 -49
View File
@@ -67,15 +67,38 @@ vi.mock("../telemetry/events.js", () => ({
trackSkillsInstallSkipped: (...args: unknown[]) => trackSkillsInstallSkipped(...args),
}));
// `skills update` calls checkSkills() to find skills removed upstream, then
// prunes them. Mock it so these tests don't touch the real FS / network; the
// default returns nothing removed, and the prune test overrides per-call.
vi.mock("../utils/skillsManifest.js", () => ({
checkSkills: vi.fn(async () => ({ skills: [] })),
// installAllSkills resolves the HyperFrames skill names (lock-attributed) to
// scope the mirror; pin it so these arg-shape tests don't read a real lock.
hyperframesSkillNames: vi.fn(() => ["hyperframes"]),
}));
// A realistic check result for the targeted paths (`update`, with or without names):
// one core skill outdated, one core missing, one on-demand workflow installed
// and current, one on-demand workflow not installed. `update` must refresh the
// two stale core skills, leave `pr-to-video` alone (on demand), and keep
// `embedded-captions` (installed + current) untouched.
const DEFAULT_CHECK = {
location: "/home/user/.claude/skills",
agent: "claude-code",
scope: "global",
updateAvailable: true,
summary: { current: 1, outdated: 1, missing: 2, coreMissing: 1, removed: 0 },
skills: [
{ name: "hyperframes", status: "outdated" },
{ name: "hyperframes-core", status: "missing" },
{ name: "embedded-captions", status: "current" },
{ name: "pr-to-video", status: "missing" },
],
lockMissing: false,
};
// Mock only the impure exports; keep the real isCoreSkill (pure classifier).
// presentSkills echoes its input so the post-install presence verification
// passes without touching the real filesystem.
vi.mock("../utils/skillsManifest.js", async (importOriginal) => {
const actual = await importOriginal<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 real $HOME. Stub it so these arg-shape tests never create symlinks in the
@@ -84,10 +107,9 @@ vi.mock("../utils/skillsMirror.js", () => ({
mirrorGlobalSkills: vi.fn(() => ({ source: null, mirrored: [] })),
}));
// The global install command this CLI runs (after `skills add <url>`).
const GLOBAL_ARGS = [
"--skill",
"*",
// The global install command this CLI runs (after `skills add <url>` and the
// per-name `--skill` selection).
const GLOBAL_ARGS_TAIL = [
"--global",
"--agent",
"claude-code",
@@ -104,24 +126,55 @@ function setPlatform(platform: NodeJS.Platform): void {
});
}
/** Invoke the `skills update` subcommand from a freshly-imported module. */
async function runSkillsUpdate(args: Record<string, unknown> = {}): Promise<void> {
/** Invoke a `skills <name>` subcommand from a freshly-imported module. */
async function runSkillsSub(
name: "update",
args: Record<string, unknown> = {},
positionals: string[] = [],
): Promise<void> {
const { default: skillsCmd } = await import("./skills.js");
const subs = skillsCmd.subCommands as unknown as Record<string, typeof skillsCmd>;
expect(subs.update).toBeDefined();
await subs.update!.run?.({ args, rawArgs: [], cmd: subs.update } as never);
expect(subs[name]).toBeDefined();
await subs[name]!.run?.({
args: { _: positionals, ...args },
rawArgs: positionals,
cmd: subs[name],
} as never);
}
const runSkillsUpdate = (args: Record<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", () => {
let prevExitCode: typeof process.exitCode;
beforeEach(() => {
beforeEach(async () => {
state.execCalls = [];
state.spawnCalls = [];
state.spawnExitCode = 0;
state.gitMissing = false;
trackSkillsInstallSkipped.mockClear();
vi.resetModules();
// vi.resetModules re-imports skills.js but the manifest mock's vi.fn
// instances persist — restore their default behavior for each test.
const { checkSkills, presentSkills } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockReset();
vi.mocked(checkSkills).mockImplementation(async () => DEFAULT_CHECK as never);
vi.mocked(presentSkills).mockReset();
vi.mocked(presentSkills).mockImplementation((names: readonly string[]) => [...names]);
// Each test asserts on process.exitCode; isolate it from the runner's own.
prevExitCode = process.exitCode;
process.exitCode = 0;
@@ -154,13 +207,27 @@ describe("hyperframes skills", () => {
"linux",
"npx",
["--version"],
["skills", "add", "https://github.com/heygen-com/hyperframes", ...GLOBAL_ARGS],
[
"skills",
"add",
"https://github.com/heygen-com/hyperframes",
"--skill",
"*",
...GLOBAL_ARGS_TAIL,
],
],
[
"darwin",
"npx",
["--version"],
["skills", "add", "https://github.com/heygen-com/hyperframes", ...GLOBAL_ARGS],
[
"skills",
"add",
"https://github.com/heygen-com/hyperframes",
"--skill",
"*",
...GLOBAL_ARGS_TAIL,
],
],
[
"win32",
@@ -174,11 +241,13 @@ describe("hyperframes skills", () => {
"skills",
"add",
"https://github.com/heygen-com/hyperframes",
...GLOBAL_ARGS,
"--skill",
"*",
...GLOBAL_ARGS_TAIL,
],
],
] as const)(
"uses %s-compatible npx command for preflight and skills install",
"uses %s-compatible npx command for preflight and the full install",
async (platform, expectedCommand, expectedPreflightArgs, expectedInstallArgs) => {
setPlatform(platform);
@@ -202,16 +271,23 @@ describe("hyperframes skills", () => {
expect(process.exitCode).toBe(1);
});
it("skills update exits zero on a successful install", async () => {
it("skills update refreshes only the stale core + installed skills — never the full set", async () => {
setPlatform("linux");
await runSkillsUpdate();
expect(process.exitCode).toBe(0);
const args = state.spawnCalls[0]?.args ?? [];
// pulls the full set straight from GitHub, globally, as a faithful clone
// straight from GitHub, globally, as a faithful clone
expect(args).toContain("https://github.com/heygen-com/hyperframes");
expect(args).toContain("--global");
expect(args).toContain("--copy");
expect(args).toContain("--full-depth");
// targeted per-name selection: the stale core skills only
expect(skillFlagValues(args).sort()).toEqual(["hyperframes", "hyperframes-core"]);
// never the full-set wildcard, and never a missing on-demand workflow
expect(skillFlagValues(args)).not.toContain("*");
expect(skillFlagValues(args)).not.toContain("pr-to-video");
// installed-and-current skills are not re-fetched
expect(skillFlagValues(args)).not.toContain("embedded-captions");
// never the `--all` (= `--agent '*'`) spray
expect(args).not.toContain("--all");
// `--agent` must be followed by a concrete key, never the `'*'` wildcard
@@ -219,15 +295,54 @@ describe("hyperframes skills", () => {
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.
it("skills update prunes skills removed upstream, in the attributed scope", async () => {
setPlatform("linux");
const { checkSkills } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockResolvedValueOnce({
scope: "global",
skills: [{ name: "graphic-overlays", status: "removed" }],
} as never);
// First call feeds the targeted install; the second is the prune detection.
vi.mocked(checkSkills)
.mockResolvedValueOnce(DEFAULT_CHECK as never)
.mockResolvedValueOnce({
scope: "global",
skills: [{ name: "graphic-overlays", status: "removed" }],
} as never);
await runSkillsUpdate();
@@ -247,10 +362,12 @@ describe("hyperframes skills", () => {
it("skills update prunes in project scope without -g", async () => {
setPlatform("linux");
const { checkSkills } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockResolvedValueOnce({
scope: "project",
skills: [{ name: "graphic-overlays", status: "removed" }],
} as never);
vi.mocked(checkSkills)
.mockResolvedValueOnce(DEFAULT_CHECK as never)
.mockResolvedValueOnce({
scope: "project",
skills: [{ name: "graphic-overlays", status: "removed" }],
} as never);
await runSkillsUpdate();
@@ -272,11 +389,13 @@ describe("hyperframes skills", () => {
it("skills update plumbs --source/--dir to its prune detection (parity with check)", async () => {
setPlatform("linux");
const { checkSkills } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockResolvedValueOnce({ scope: "project", skills: [] } as never);
await runSkillsUpdate({ source: "owner/repo", dir: "/custom/skills" });
expect(checkSkills).toHaveBeenCalledWith({ source: "owner/repo", dir: "/custom/skills" });
// The last checkSkills call is the prune's — the update engine's own check
// (first call) intentionally uses default detection, matching where the
// install actually lands.
expect(checkSkills).toHaveBeenLastCalledWith({ source: "owner/repo", dir: "/custom/skills" });
});
// Skill names come from lock-file JSON keys; a flag-like / shell-special name
@@ -284,13 +403,15 @@ describe("hyperframes skills", () => {
it("skills update never passes a non-slug skill name to remove", async () => {
setPlatform("linux");
const { checkSkills } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockResolvedValueOnce({
scope: "global",
skills: [
{ name: "graphic-overlays", status: "removed" },
{ name: "--config=evil.js", status: "removed" },
],
} as never);
vi.mocked(checkSkills)
.mockResolvedValueOnce(DEFAULT_CHECK as never)
.mockResolvedValueOnce({
scope: "global",
skills: [
{ name: "graphic-overlays", status: "removed" },
{ name: "--config=evil.js", status: "removed" },
],
} as never);
await runSkillsUpdate();
@@ -308,13 +429,15 @@ describe("hyperframes skills", () => {
it("skills update spawns no remove when every removed name is rejected", async () => {
setPlatform("linux");
const { checkSkills } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockResolvedValueOnce({
scope: "global",
skills: [
{ name: "--config=evil.js", status: "removed" },
{ name: "../escape", status: "removed" },
],
} as never);
vi.mocked(checkSkills)
.mockResolvedValueOnce(DEFAULT_CHECK as never)
.mockResolvedValueOnce({
scope: "global",
skills: [
{ name: "--config=evil.js", status: "removed" },
{ name: "../escape", status: "removed" },
],
} as never);
await runSkillsUpdate();
@@ -368,3 +491,174 @@ describe("hyperframes skills", () => {
expect(process.exitCode).toBe(1);
});
});
// The router contract: `/hyperframes` picks a workflow, then runs
// `hyperframes skills update <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 {
checkSkills,
FALLBACK_CORE_SKILLS,
hyperframesSkillNames,
isCoreSkill,
presentSkills,
SKILLS_CLI_LOCK_PATHS_VERIFIED_AT,
type SkillDiff,
type SkillsCheckResult,
@@ -19,7 +22,8 @@ export const examples: Example[] = [
["Install all HyperFrames skills", "hyperframes skills"],
["Check whether installed skills are up to date", "hyperframes skills check"],
["Check, machine-readable (for agents / CI)", "hyperframes skills check --json"],
["Update all skills to the latest (installs any missing)", "hyperframes skills update"],
["Update the core set + everything already installed", "hyperframes skills update"],
["Also install one workflow (on-demand install)", "hyperframes skills update pr-to-video"],
];
function hasNpx(): boolean {
@@ -52,7 +56,7 @@ function spawnNpx(args: string[], opts: { cwd?: string } = {}): Promise<void> {
const child = spawn(npx.command, npx.args, {
stdio: "inherit",
// We install with --full-depth (a full `git clone` of the repo, the only
// path that bypasses the laggy skills.sh blob — see GLOBAL_INSTALL_ARGS),
// path that bypasses the laggy skills.sh blob — see GLOBAL_INSTALL_ARGS_TAIL),
// which is heavier than the blob fetch, so allow more headroom.
timeout: 300_000,
cwd: opts.cwd,
@@ -94,9 +98,7 @@ function spawnNpx(args: string[], opts: { cwd?: string } = {}): Promise<void> {
// skills.sh registry blob, which lags GitHub main by hours, so a fresh install
// would read as several skills "outdated" (verified: blob → ~9 outdated;
// --full-depth → all current).
const GLOBAL_INSTALL_ARGS = [
"--skill",
"*",
const GLOBAL_INSTALL_ARGS_TAIL = [
"--global",
"--agent",
"claude-code",
@@ -106,11 +108,25 @@ const GLOBAL_INSTALL_ARGS = [
"--yes",
];
/** All skills, or an explicit list of skill names to install. */
type SkillSelection = "*" | readonly string[];
// The upstream CLI takes one `--skill` flag per name (`--skill a --skill b`),
// with `'*'` meaning "all skills" — see vercel-labs/skills `add --skill`.
function skillSelectionArgs(selection: SkillSelection): string[] {
const names = selection === "*" ? ["*"] : selection;
return names.flatMap((name) => ["--skill", name]);
}
function runSkillsAdd(
source: string,
opts: { cwd?: string; extraArgs?: string[] } = {},
selection: SkillSelection,
opts: { cwd?: string } = {},
): Promise<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
@@ -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
// 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
// resolves "latest" straight from GitHub too, so install and check agree.
const SOURCES = [{ name: "HyperFrames", url: "https://github.com/heygen-com/hyperframes" }];
@@ -203,9 +219,27 @@ function skillsToolingReady(strict: boolean): boolean {
return true;
}
export async function installAllSkills(
opts: { cwd?: string; extraArgs?: string[]; strict?: boolean } = {},
// Skill names can originate from a fetched manifest or agent argv, and each is
// 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> {
const safeSelection = sanitizeSelection(selection);
if (safeSelection === null) return;
if (!skillsToolingReady(opts.strict ?? false)) return;
for (const source of SOURCES) {
@@ -213,7 +247,7 @@ export async function installAllSkills(
console.log(c.bold(`Installing ${source.name} skills...`));
console.log();
try {
await runSkillsAdd(source.url, opts);
await runSkillsAdd(source.url, safeSelection, opts);
} catch (err) {
if (opts.strict) throw err instanceof Error ? err : new Error(String(err));
console.log(c.dim(`${source.name} skills skipped`));
@@ -223,6 +257,174 @@ export async function installAllSkills(
mirrorToInstalledAgents();
}
// ── targeted install engine ───────────────────────────────────────────────────────────────────
/** What an `updateSkills` run guaranteed, and what it had to do to get there. */
export interface UpdateSkillsResult {
/** Every skill this run guaranteed: requested + core (+ installed, when refreshing). */
targets: string[];
/** Targets that were (re)installed by this run. */
installed: string[];
/** Targets that were already current — nothing fetched for them. */
current: string[];
/**
* Requested names the latest manifest doesn't ship (typo, or renamed
* upstream). Only ever non-empty on a non-strict run: a strict run throws on
* unknown names before returning, so strict callers never observe this — a
* future caller reading `unknown` should not expect it under `strict: true`.
*/
unknown: string[];
/** True when freshness couldn't be checked (offline) and only presence was verified. */
presenceOnly: boolean;
}
/**
* The targeted install engine behind `init` and `skills update [names...]` —
* the replacement for "anything stale ⇒ re-pull the full skill set". It
* guarantees a small, explicit set is installed and current:
*
* - the requested names (a workflow being routed to, e.g. `pr-to-video`),
* - the core set (entry router + shared domain skills — see skillsManifest),
* - with `refreshInstalled`, whatever is already installed (refreshed, so an
* update never *expands* a deliberate partial install).
*
* Only targets that are actually missing or outdated are passed to
* `skills add` (one spawn, one `--skill` flag per name); when everything is
* current the call is a fast no-op with no install. When the manifest is
* unreachable, freshness is unknowable and the run degrades to the presence
* half of the guarantee — see updateSkillsOffline.
*/
export async function updateSkills(
opts: {
requested?: readonly string[];
refreshInstalled?: boolean;
strict?: boolean;
cwd?: string;
} = {},
): Promise<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 ────────────────────────────────────────────────────────────────────
/** Print a labelled list of skills (nothing if empty), each line uniformly coloured. */
@@ -232,8 +434,9 @@ function printSkillSection(
title: string,
mark: string,
color: (s: string) => string,
filter: (s: SkillDiff) => boolean = () => true,
): void {
const items = result.skills.filter((s) => s.status === status);
const items = result.skills.filter((s) => s.status === status && filter(s));
if (!items.length) return;
console.log();
console.log(` ${color(title)}`);
@@ -256,14 +459,31 @@ function renderCheck(result: SkillsCheckResult): void {
console.log(` ${c.bold("Location")} ${c.dim(result.location)} ${c.dim(`(${result.agent})`)}`);
console.log();
const onDemandMissing = summary.missing - summary.coreMissing;
const parts = [c.success(`${summary.current} current`)];
if (summary.outdated) parts.push(c.warn(`${summary.outdated} outdated`));
if (summary.missing) parts.push(c.dim(`${summary.missing} not installed`));
if (summary.coreMissing) parts.push(c.warn(`${summary.coreMissing} core not installed`));
if (onDemandMissing) parts.push(c.dim(`${onDemandMissing} available on demand`));
if (summary.removed) parts.push(c.warn(`${summary.removed} removed upstream`));
console.log(` ${parts.join(" ")}`);
printSkillSection(result, "outdated", "Outdated:", "↑", c.warn);
printSkillSection(result, "missing", "Not installed:", "◦", c.dim);
printSkillSection(
result,
"missing",
"Core not installed (skills update installs these):",
"◦",
c.warn,
(s) => isCoreSkill(s.name),
);
printSkillSection(
result,
"missing",
"Available on demand (installed when their workflow first runs):",
"◦",
c.dim,
(s) => !isCoreSkill(s.name),
);
printSkillSection(
result,
"removed",
@@ -321,17 +541,75 @@ const checkCommand = defineCommand({
// ── update ───────────────────────────────────────────────────────────────────
/**
* Positional skill names from argv, split into plain-slug names and rejected
* tokens — each name is spread into a spawn as a `--skill` value, so
* flag-like tokens are refused up front (the caller reports and exits).
*/
function requestedNamesFrom(positionals: readonly unknown[]): {
requested: string[];
rejected: string[];
} {
const names = positionals.map(String).filter((n) => n.length > 0);
return {
requested: names.filter((n) => PLAIN_SKILL_NAME.test(n)),
rejected: names.filter((n) => !PLAIN_SKILL_NAME.test(n)),
};
}
/** Result line(s) for `skills update` — JSON for agents, one calm line for humans. */
function reportUpdate(
result: UpdateSkillsResult,
requested: readonly string[],
json: boolean,
): void {
if (json) {
console.log(JSON.stringify(withMeta(result), null, 2));
return;
}
if (result.installed.length > 0) {
console.log(
c.success(
`Installed/updated ${result.installed.length} skill(s): ${result.installed.join(", ")}`,
),
);
} else if (result.presenceOnly) {
// Freshness was never checked (offline degrade) — "up to date" would be a
// claim we can't back. Presence is all that was verified.
console.log(c.warn("Freshness unknown (GitHub unreachable) — verified presence only."));
} else {
console.log(c.success("Installed skills are already up to date."));
}
// The named skills are the caller's actual question ("is my workflow ready?")
// — answer it explicitly, whatever the install had to do.
if (requested.length) console.log(c.success(`◇ Ready: ${requested.join(", ")}`));
}
/**
* Failure line for `skills update`. In --json mode the failure must land on
* stdout as JSON (an agent piping to a parser gets structure, not clack
* prose); the human path keeps the clack error.
*/
function reportUpdateFailure(message: string, json: boolean): void {
if (json) {
console.log(JSON.stringify(withMeta({ error: message }), null, 2));
return;
}
clack.log.error(c.error(message));
}
const updateCommand = defineCommand({
meta: {
name: "update",
description:
"Update all HyperFrames skills to the latest — installs any not yet present, removes any no longer published",
"Update the core set plus every installed HyperFrames skill to the latest, and remove any no longer published. Pass skill names to also install those (how workflow skills install on demand) — without names it never expands a partial install",
},
// Mirror `check`'s flags: the prune step runs the same removed-detection, so it
// must respect the same overrides. Without these, `update`'s internal
// checkSkills() fell back to defaults — pruning the auto-detected install
// against the default manifest even when the user pointed `check` elsewhere.
args: {
json: { type: "boolean", description: "Output as JSON", default: false },
dir: {
type: "string",
description:
@@ -347,27 +625,44 @@ const updateCommand = defineCommand({
const dir = args.dir;
const source = args.source;
// The install re-fetches every skill to the latest AND installs ones not yet
// present — so "update" pulls the full set, not just what is already
// installed. This is where `init` and the stale-skills nudge both lead.
// runSkillsAdd resolves the agent target set itself (existing project
// folders → installed CLIs → a Claude-Code + `.agents` floor); we no longer
// spray to every agent via `--all`.
// Positional skill names (e.g. `hyperframes skills update pr-to-video`) are
// the ONLY way update expands an install: each named skill is guaranteed
// present and current. This is the router's trigger-time step — the
// /hyperframes router runs it after picking a workflow, before reading the
// workflow's skill.
const { requested, rejected } = requestedNamesFrom(args._ ?? []);
if (rejected.length) {
reportUpdateFailure(`Invalid skill name(s): ${rejected.join(", ")}`, args.json === true);
process.exitCode = 1;
return;
}
// Targeted, not full-set: refresh the core set (entry router + shared
// domain skills) plus whatever is already installed, plus anything named
// above. Without names a deliberate partial install stays partial
// (refreshed, but never expanded) — the end-user workflow skills install
// on demand, when their workflow is
// triggered. This is where `init` and the stale-skills nudge both lead;
// pulling the complete skill set here is exactly what users complained
// about. Explicit full set: `hyperframes skills` or `npx skills add
// heygen-com/hyperframes --all`.
//
// Note: the upstream `skills add` CLI has no `--dir` flag (it installs into
// the resolved agent dirs), so `--dir` here scopes only the *prune* detection
// below, not the install. `--source` likewise drives where the prune's
// "latest" manifest comes from; the install always targets the canonical
// HyperFrames repo so `update` reliably pulls the published set.
// HyperFrames repo so `update` reliably refreshes the published skills.
//
// strict: this is the documented recovery path for the agent/CI contract
// `hyperframes skills check || hyperframes skills update`. If the install
// fails (no npx, `skills add` exits non-zero) it must exit non-zero too —
// otherwise the `||` chain passes while nothing actually changed.
// `hyperframes skills check || hyperframes skills update`, and the router's
// trigger-time guarantee. If the install fails (no npx, `skills add` exits
// non-zero, a named skill still absent afterwards) it must exit non-zero
// too — otherwise the `||` chain passes while nothing actually changed.
try {
await installAllSkills({ strict: true });
const result = await updateSkills({ requested, refreshInstalled: true, strict: true });
reportUpdate(result, requested, args.json === true);
} catch (err) {
clack.log.error(c.error(`Update failed: ${(err as Error).message}`));
reportUpdateFailure(`Update failed: ${(err as Error).message}`, args.json === true);
process.exitCode = 1;
return;
}
@@ -413,6 +708,6 @@ export default defineCommand({
// citty runs this parent handler even when a subcommand matches; guard on
// the positional so bare `hyperframes skills` installs, while
// `hyperframes skills check|update` does not also re-install.
if (!args._?.[0]) await installAllSkills();
if (!args._?.[0]) await installSkills("*");
},
});
+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`.
> **Skills not available or need updating?** Run `npx skills add heygen-com/hyperframes`
> and restart the agent session so the new skills load.
> **Skill missing or stale?** Run `npx hyperframes skills update <name>` to install/refresh
> the specific skill you need (the `/hyperframes` router does this automatically before
> entering a workflow), or bare `npx hyperframes skills update` to refresh the core set plus
> everything already installed — neither pulls the full set. Restart the agent session so
> newly installed skills load.
## Commands
+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`.
> **Skills not available or need updating?** Run `npx skills add heygen-com/hyperframes`
> and restart the agent session so the new skills load.
> **Skill missing or stale?** Run `npx hyperframes skills update <name>` to install/refresh
> the specific skill you need (the `/hyperframes` router does this automatically before
> entering a workflow), or bare `npx hyperframes skills update` to refresh the core set plus
> everything already installed — neither pulls the full set. Restart the agent session so
> newly installed skills load.
## Commands
+112 -10
View File
@@ -1,12 +1,16 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
hashSkillBundle,
buildManifest,
checkSkills,
diffSkills,
FALLBACK_CORE_SKILLS,
isCoreSkill,
presentSkills,
skillsAttributedToSource,
type SkillsManifest,
type SkillEntry,
@@ -70,6 +74,40 @@ describe("buildManifest", () => {
});
});
describe("isCoreSkill", () => {
it("classifies the entry router, hyperframes-* domain skills, and media-use as core", () => {
expect(isCoreSkill("hyperframes")).toBe(true);
expect(isCoreSkill("hyperframes-core")).toBe(true);
expect(isCoreSkill("hyperframes-animation")).toBe(true);
expect(isCoreSkill("media-use")).toBe(true);
// End-user workflows and optional integrations install on demand.
expect(isCoreSkill("pr-to-video")).toBe(false);
expect(isCoreSkill("embedded-captions")).toBe(false);
expect(isCoreSkill("figma")).toBe(false);
});
});
describe("FALLBACK_CORE_SKILLS pin", () => {
// The fallback list exists because isCoreSkill is a pattern and the offline
// path can't enumerate a pattern. This pins the list to the repo's actual
// skills/ tree so it can't drift silently when core membership changes.
it("matches the core skills present in the repo's skills/ tree exactly", () => {
const skillsRoot = join(
dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"..",
"..",
"skills",
);
const onDisk = readdirSync(skillsRoot).filter((n) =>
existsSync(join(skillsRoot, n, "SKILL.md")),
);
const coreOnDisk = onDisk.filter((n) => isCoreSkill(n)).sort();
expect([...FALLBACK_CORE_SKILLS].sort()).toEqual(coreOnDisk);
});
});
describe("diffSkills", () => {
const latest: SkillsManifest = {
source: "test",
@@ -94,14 +132,10 @@ describe("diffSkills", () => {
changed: "outdated",
gone: "missing",
});
expect(diff.summary).toEqual({ current: 1, outdated: 1, missing: 1 });
expect(diff.summary).toEqual({ current: 1, outdated: 1, missing: 1, coreMissing: 0 });
});
it("flags updateAvailable when a skill is outdated OR missing", () => {
// The full set is the goal, so missing skills now count too.
const missingOnly = diffSkills({ keep: { hash: "h1", files: 1 } }, latest);
expect(missingOnly.updateAvailable).toBe(true);
it("flags updateAvailable for anything outdated", () => {
const hasOutdated = diffSkills({ changed: { hash: "X", files: 1 } }, latest);
expect(hasOutdated.updateAvailable).toBe(true);
@@ -128,6 +162,57 @@ describe("diffSkills", () => {
);
expect(withExtra.updateAvailable).toBe(false);
});
it("a missing on-demand skill is NOT an update — a missing core skill is", () => {
// The old semantics ("full set is the goal") made any missing skill flip
// updateAvailable, which re-pulled all skills onto deliberate partial
// installs. On-demand skills now install when their workflow triggers.
const withCore: SkillsManifest = {
source: "test",
skills: {
hyperframes: { hash: "e1", files: 1 }, // core: entry router
"pr-to-video": { hash: "w1", files: 1 }, // on-demand workflow
},
};
// Core current, workflow missing → partial install is fine, no update.
const workflowMissing = diffSkills({ hyperframes: { hash: "e1", files: 1 } }, withCore);
expect(workflowMissing.updateAvailable).toBe(false);
expect(workflowMissing.summary).toEqual({
current: 1,
outdated: 0,
missing: 1,
coreMissing: 0,
});
// Core itself missing → every workflow needs it, so that IS an update.
const coreMissing = diffSkills({ "pr-to-video": { hash: "w1", files: 1 } }, withCore);
expect(coreMissing.updateAvailable).toBe(true);
expect(coreMissing.summary).toEqual({ current: 1, outdated: 0, missing: 1, coreMissing: 1 });
});
});
describe("presentSkills", () => {
it("returns only the names present in the located install", () => {
const home = join(root, "home");
const project = join(root, "project");
mkdirSync(project, { recursive: true });
const skillsDir = join(home, ".claude/skills");
mkdirSync(join(skillsDir, "hyperframes"), { recursive: true });
writeFileSync(join(skillsDir, "hyperframes", "SKILL.md"), "# hyperframes");
expect(presentSkills(["hyperframes", "pr-to-video"], { cwd: project, home })).toEqual([
"hyperframes",
]);
});
it("returns [] when no install exists at all", () => {
const home = join(root, "home");
const project = join(root, "project");
mkdirSync(home, { recursive: true });
mkdirSync(project, { recursive: true });
expect(presentSkills(["hyperframes"], { cwd: project, home })).toEqual([]);
});
});
describe("checkSkills install detection", () => {
@@ -213,11 +298,22 @@ describe("checkSkills install detection", () => {
const home = join(root, "home");
mkdirSync(project, { recursive: true });
mkdirSync(home, { recursive: true });
const source = writeManifest(root);
// A manifest with a core skill: a truly fresh machine is missing the core
// set, and THAT (not the missing on-demand skills) makes the update
// available.
const source = join(root, "manifest-core.json");
writeFileSync(
source,
JSON.stringify({
source: "test",
skills: { hyperframes: { hash: "x", files: 1 }, alpha: { hash: "y", files: 1 } },
}),
);
const res = await checkSkills({ source, cwd: project, home });
expect(res.location).toBeNull();
expect(res.summary.missing).toBe(2);
expect(res.summary.coreMissing).toBe(1);
expect(res.updateAvailable).toBe(true);
});
@@ -349,7 +445,13 @@ describe("checkSkills removed-upstream detection", () => {
writeGlobalLock(home, { alpha: { source: "test" }, gamma: { source: "test" } });
const res = await checkSkills(opts);
expect(res.summary).toEqual({ current: 1, outdated: 0, missing: 0, removed: 1 });
expect(res.summary).toEqual({
current: 1,
outdated: 0,
missing: 0,
coreMissing: 0,
removed: 1,
});
expect(res.updateAvailable).toBe(true);
});
+80 -7
View File
@@ -72,7 +72,8 @@ export interface SkillDiff {
/** The pure manifest diff (current / outdated / missing — what `diffSkills` returns). */
export interface SkillsDiff {
updateAvailable: boolean;
summary: { current: number; outdated: number; missing: number };
/** `coreMissing` ⊆ `missing`: the missing skills that are core (see "Skill tiers"). */
summary: { current: number; outdated: number; missing: number; coreMissing: number };
skills: SkillDiff[];
}
@@ -84,7 +85,13 @@ export interface SkillsCheckResult {
/** Scope of the located install — so a caller prunes in the same scope it attributed from. */
scope: "project" | "global" | null;
updateAvailable: boolean;
summary: { current: number; outdated: number; missing: number; removed: number };
summary: {
current: number;
outdated: number;
missing: number;
coreMissing: number;
removed: number;
};
skills: SkillDiff[];
/**
* True when an install was located but the upstream skills lock was absent at
@@ -100,6 +107,50 @@ const DEFAULT_REPO_SLUG = "heygen-com/hyperframes";
export const MANIFEST_FILE = "skills-manifest.json";
const FETCH_TIMEOUT_MS = 4000;
// ── Skill tiers ──────────────────────────────────────────────────────────────
//
// Two tiers decide what installs eagerly vs on demand:
//
// core — the `/hyperframes` entry router plus the shared domain skills
// (`hyperframes-*`, `media-use`) that every creation workflow
// references structurally (sibling `../hyperframes-animation/…`
// paths, "call /media-use" preambles). These must be present and
// current for ANY workflow to run, so `init` / `skills update`
// keep them fresh.
// on-demand — everything else: the end-user workflow skills (pr-to-video,
// embedded-captions, …) and optional integrations (figma). They
// install lazily, when their workflow is actually triggered
// (`hyperframes skills update <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 ────────────────────────────────────────────────────────────────
function listFilesSorted(dir: string): string[] {
@@ -283,6 +334,20 @@ function locateInstall(
return null;
}
/**
* Names from `skillNames` that are present (their SKILL.md exists) in the
* located install. Local-only no manifest fetch so callers can verify the
* presence half of an install guarantee even when GitHub is unreachable.
*/
export function presentSkills(
skillNames: readonly string[],
opts: { dir?: string; cwd?: string; home?: string } = {},
): string[] {
const root = locateInstall([...skillNames], opts);
if (!root) return [];
return skillNames.filter((name) => existsSync(join(root.dir, name, "SKILL.md")));
}
/** Hash every manifest skill that is installed under `root`. */
function hashInstalled(root: SkillRoot, skillNames: string[]): Record<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
// directory name — `.../skills` is shared across sources.
const skills: SkillDiff[] = [];
const summary = { current: 0, outdated: 0, missing: 0 };
const summary = { current: 0, outdated: 0, missing: 0, coreMissing: 0 };
for (const name of Object.keys(latest.skills).sort()) {
const latestEntry = latest.skills[name]!;
@@ -316,7 +381,10 @@ export function diffSkills(
if (status === "current") summary.current++;
else if (status === "outdated") summary.outdated++;
else summary.missing++;
else {
summary.missing++;
if (isCoreSkill(name)) summary.coreMissing++;
}
skills.push({
name,
@@ -327,9 +395,14 @@ export function diffSkills(
}
return {
// The full skill set is the goal — `init` and `skills update` both pull the
// complete set, so anything outdated OR missing means an update is available.
updateAvailable: summary.outdated > 0 || summary.missing > 0,
// "Update available" means the install is stale, not merely partial:
// anything installed-but-outdated, or a missing CORE skill (the entry
// router + shared domain skills every workflow needs). A missing
// on-demand skill is NOT an update — it installs when its workflow is
// triggered (`hyperframes skills update <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,
skills,
};
+4 -2
View File
@@ -42,13 +42,15 @@ async function refreshSkillsCache(): Promise<SkillsUpdateMeta> {
config.lastSkillsCheck = new Date().toISOString();
config.skillsUpdateAvailable = result.updateAvailable;
config.skillsOutdatedCount = result.summary.outdated;
config.skillsMissingCount = result.summary.missing;
// Core-missing only: skills that install on demand (workflows not yet
// triggered on this machine) are not "missing" worth nagging about.
config.skillsMissingCount = result.summary.coreMissing;
writeConfig(config);
}
return {
updateAvailable: result.updateAvailable,
outdated: result.summary.outdated,
missing: result.summary.missing,
missing: result.summary.coreMissing,
};
}