Files
hyperframes/packages/cli/src/commands/skills.test.ts
T
Miguel Ángel b1f1c0571e fix(cli): make skills update converge on a skill retired upstream (#2176)
`hyperframes skills update` failed hard or looped forever once a skill was
retired/renamed upstream while still installed locally (hyperframes-media folded
into media-use; hyperframes-captions/compose/tts consolidated earlier). Two
paths dead-ended:

- Install: target selection could trust a stale local skills-manifest.json
  (findRepoManifest) while `skills add` always installs from the canonical repo.
  isCoreSkill matches the `hyperframes-` prefix, so a retired skill was forced
  into the target set, `skills add` silently declined it (exit 0), and strict
  verifyInstalled threw "Skill(s) still missing after install".
- Prune: upstream `skills remove` scans on-disk directories, so a lock entry
  retired before it ever shipped a bundle has nothing to match — a silent
  exit-0 no-op that never clears the lock, so detectRemoved re-flags it on
  every run.

The stale-skills nudge compounded it: it fired even from `skills update` itself
(pointing users back at the failing command) and its count ignored the removed
bucket.

Resolve update targets against the canonical manifest (checkSkills({ canonical:
true })) so a retired skill is never targeted. Add pruneOrphanedLockEntries to
clear the orphaned lock entries the upstream remover can't (idempotent, so a
second run is a clean no-op). Exclude `skills` from the update-nudge gate and
thread the removed count through the nudge total.
2026-07-10 19:57:55 -04:00

779 lines
30 KiB
TypeScript

// ESM forbids `vi.spyOn` on live module exports, so we mock
// `node:child_process` at the loader level and inspect the spawned
// child's env.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
type SpawnCall = {
command: string;
args: ReadonlyArray<string>;
env: NodeJS.ProcessEnv | undefined;
};
type ExecCall = {
command: string;
args: ReadonlyArray<string>;
};
const originalPlatform = process.platform;
const state: {
execCalls: ExecCall[];
spawnCalls: SpawnCall[];
spawnExitCode: number;
gitMissing: boolean;
} = {
execCalls: [],
spawnCalls: [],
spawnExitCode: 0,
gitMissing: false,
};
vi.mock("node:child_process", () => ({
// `skillsManifest.ts` does `promisify(execFile)` at module load. These tests
// never invoke it (no skills-check path runs here), so a bare stub is enough
// to satisfy the named import — we deliberately don't spread the real module.
execFile: vi.fn(),
execFileSync: vi.fn((command: string, args: ReadonlyArray<string>) => {
state.execCalls.push({ command, args });
// Simulate `git` absent from PATH: execFileSync throws ENOENT like the OS would.
if (state.gitMissing && command === "git") {
throw Object.assign(new Error("spawn git ENOENT"), { code: "ENOENT" });
}
return Buffer.from("11.0.0");
}),
spawn: vi.fn(
(command: string, args: ReadonlyArray<string>, opts?: { env?: NodeJS.ProcessEnv }) => {
state.spawnCalls.push({ command, args, env: opts?.env });
const fake = new EventEmitter();
setImmediate(() => fake.emit("close", state.spawnExitCode, null));
return fake;
},
),
}));
vi.mock("@clack/prompts", () => ({
log: {
error: vi.fn(),
warn: vi.fn(),
},
}));
// Capture the prerequisite-skip telemetry event without touching the real
// PostHog client. trackSkillsInstallSkipped already gates on the telemetry
// opt-out inside trackEvent, so the command calls it unconditionally.
const trackSkillsInstallSkipped = vi.fn();
vi.mock("../telemetry/events.js", () => ({
trackSkillsInstallSkipped: (...args: unknown[]) => trackSkillsInstallSkipped(...args),
}));
// 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]),
// Default: nothing left to prune after `runSkillsRemove`. The real
// (unmocked) fs-level behavior is covered in skillsManifest.test.ts;
// here we only assert the wiring — what update passes in, and that it
// isn't reached when there's nothing removed.
pruneOrphanedLockEntries: vi.fn(() => []),
};
});
// 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
// dev machine's agent dirs — the mirror has its own isolated-HOME unit tests.
vi.mock("../utils/skillsMirror.js", () => ({
mirrorGlobalSkills: vi.fn(() => ({ source: null, mirrored: [] })),
}));
// 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",
"universal",
"--copy",
"--full-depth",
"--yes",
] as const;
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, "platform", {
value: platform,
configurable: true,
});
}
/** 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[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(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;
});
afterEach(() => {
setPlatform(originalPlatform);
vi.restoreAllMocks();
process.exitCode = prevExitCode;
});
it("sets clone-safe env on the spawned skills CLI child (GH #316 + LFS skip)", async () => {
setPlatform("linux");
const { default: skillsCmd } = await import("./skills.js");
await skillsCmd.run?.({ args: {}, rawArgs: [], cmd: skillsCmd } as never);
const first = state.spawnCalls[0];
expect(first).toBeDefined();
expect(first!.command).toBe("npx");
expect(first!.args).toContain("skills");
expect(first!.args).toContain("add");
expect(first!.env?.GIT_CLONE_PROTECTION_ACTIVE).toBe("0");
// --full-depth clones the repo; skip LFS so we don't drag in unrelated blobs.
expect(first!.env?.GIT_LFS_SKIP_SMUDGE).toBe("1");
});
it.each([
[
"linux",
"npx",
["--version"],
[
"skills",
"add",
"https://github.com/heygen-com/hyperframes",
"--skill",
"*",
...GLOBAL_ARGS_TAIL,
],
],
[
"darwin",
"npx",
["--version"],
[
"skills",
"add",
"https://github.com/heygen-com/hyperframes",
"--skill",
"*",
...GLOBAL_ARGS_TAIL,
],
],
[
"win32",
"cmd.exe",
["/d", "/s", "/c", "npx.cmd", "--version"],
[
"/d",
"/s",
"/c",
"npx.cmd",
"skills",
"add",
"https://github.com/heygen-com/hyperframes",
"--skill",
"*",
...GLOBAL_ARGS_TAIL,
],
],
] as const)(
"uses %s-compatible npx command for preflight and the full install",
async (platform, expectedCommand, expectedPreflightArgs, expectedInstallArgs) => {
setPlatform(platform);
const { default: skillsCmd } = await import("./skills.js");
await skillsCmd.run?.({ args: {}, rawArgs: [], cmd: skillsCmd } as never);
expect(state.execCalls[0]?.command).toBe(expectedCommand);
expect(state.execCalls[0]?.args).toEqual(expectedPreflightArgs);
expect(state.spawnCalls[0]?.command).toBe(expectedCommand);
expect(state.spawnCalls[0]?.args).toEqual(expectedInstallArgs);
},
);
// The `skills check || skills update` recovery contract requires update to
// fail loudly — a swallowed install failure would let the `||` chain pass
// while nothing changed.
it("skills update exits non-zero when the install fails", async () => {
setPlatform("linux");
state.spawnExitCode = 1; // simulate `skills add` exiting non-zero
await runSkillsUpdate();
expect(process.exitCode).toBe(1);
});
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 ?? [];
// 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
const agentValue = args[args.indexOf("--agent") + 1];
expect(agentValue).not.toBe("*");
});
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");
// First call feeds the targeted install; the second is the prune detection.
vi.mocked(checkSkills)
.mockResolvedValueOnce(DEFAULT_CHECK as never)
.mockResolvedValueOnce({
scope: "global",
skills: [{ name: "graphic-overlays", status: "removed" }],
} as never);
await runSkillsUpdate();
// install first, then a `skills remove` for the dropped skill
expect(state.spawnCalls[0]?.args).toContain("add");
const removeCall = state.spawnCalls.find((s) => s.args.includes("remove"));
expect(removeCall, "expected a `skills remove` spawn").toBeDefined();
expect(removeCall!.args).toContain("graphic-overlays");
expect(removeCall!.args).toContain("--yes");
expect(removeCall!.args).toContain("-g"); // attributed from the global lock → remove globally
expect(process.exitCode).toBe(0);
});
// The scope the skill was attributed from drives the remove scope: a
// project-scoped removal must NOT pass -g (which would target a different,
// possibly user-owned, global skill of the same name).
it("skills update prunes in project scope without -g", async () => {
setPlatform("linux");
const { checkSkills } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills)
.mockResolvedValueOnce(DEFAULT_CHECK as never)
.mockResolvedValueOnce({
scope: "project",
skills: [{ name: "graphic-overlays", status: "removed" }],
} as never);
await runSkillsUpdate();
const removeCall = state.spawnCalls.find((s) => s.args.includes("remove"));
expect(removeCall, "expected a `skills remove` spawn").toBeDefined();
expect(removeCall!.args).toContain("graphic-overlays");
expect(removeCall!.args).not.toContain("-g");
});
it("skills update does not prune when nothing was removed upstream", async () => {
setPlatform("linux");
await runSkillsUpdate();
expect(state.spawnCalls.some((s) => s.args.includes("remove"))).toBe(false);
});
// Retired-skill regression (variant 1): the update engine's OWN targeted-
// install check must resolve the canonical (published) manifest, never a
// stale local `skills-manifest.json` a checkout might still have lying
// around — see resolveLatestManifest's in-repo shortcut. Without this, a
// skill retired upstream but still listed locally gets forced into
// `targets` (isCoreSkill pattern-matches `hyperframes-*`), `skills add`
// silently declines to install something that doesn't exist canonically,
// and the old code strict-threw on a "failure" that was never real.
it("checks freshness against the canonical manifest, never a possibly-stale local one", async () => {
setPlatform("linux");
const { checkSkills } = await import("../utils/skillsManifest.js");
await runSkillsUpdate();
// The update engine's own check (first call) must ask for canonical;
// the prune's check (last call, tested separately) intentionally doesn't.
expect(checkSkills).toHaveBeenNthCalledWith(1, expect.objectContaining({ canonical: true }));
});
// Retired-skill regression (variant 2): `skills remove` is a silent no-op
// for a lock entry with no on-disk bundle (upstream scans disk, not the
// lock, to decide what's "installed" — see pruneOrphanedLockEntries's
// doc comment). `skills update` must self-heal that lock entry itself so
// `check || update` actually converges instead of re-flagging it forever.
it("self-heals an orphaned lock entry after `skills remove` no-ops on it", async () => {
setPlatform("linux");
const { checkSkills, pruneOrphanedLockEntries } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills)
.mockResolvedValueOnce(DEFAULT_CHECK as never)
.mockResolvedValueOnce({
scope: "global",
skills: [{ name: "hyperframes-captions", status: "removed" }],
} as never);
vi.mocked(pruneOrphanedLockEntries).mockReturnValueOnce(["hyperframes-captions"]);
await runSkillsUpdate();
expect(pruneOrphanedLockEntries).toHaveBeenCalledWith(["hyperframes-captions"], "global");
expect(process.exitCode).toBe(0);
});
// The idempotent-second-run contract at the command level: once nothing is
// left attributed as removed (the fs-level idempotency of the prune itself
// is covered directly in skillsManifest.test.ts), a second `skills update`
// must be a clean no-op — no `skills remove` spawn, no prune call finding
// anything, still exit 0.
it("running update twice in a row converges — the second run prunes nothing", async () => {
setPlatform("linux");
const { checkSkills, pruneOrphanedLockEntries } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills)
.mockResolvedValueOnce(DEFAULT_CHECK as never)
.mockResolvedValueOnce({
scope: "global",
skills: [{ name: "hyperframes-captions", status: "removed" }],
} as never);
vi.mocked(pruneOrphanedLockEntries).mockReturnValueOnce(["hyperframes-captions"]);
await runSkillsUpdate();
expect(process.exitCode).toBe(0);
expect(state.spawnCalls.some((s) => s.args.includes("remove"))).toBe(true);
// Second run: nothing attributed as removed anymore (the lock entry was
// pruned above), so there's nothing left to reconcile.
state.spawnCalls = [];
vi.mocked(checkSkills)
.mockResolvedValueOnce(DEFAULT_CHECK as never)
.mockResolvedValueOnce({ scope: "global", skills: [] } as never);
await runSkillsUpdate();
expect(process.exitCode).toBe(0);
expect(state.spawnCalls.some((s) => s.args.includes("remove"))).toBe(false);
// Nothing to prune this time — pruneOrphanedLockEntries isn't even reached.
expect(pruneOrphanedLockEntries).toHaveBeenCalledTimes(1);
});
// `update`'s prune runs the same removed-detection as `check`, so its
// --source/--dir must reach the internal checkSkills() — otherwise the prune
// reconciles against defaults even when the user pointed elsewhere.
it("skills update plumbs --source/--dir to its prune detection (parity with check)", async () => {
setPlatform("linux");
const { checkSkills } = await import("../utils/skillsManifest.js");
await runSkillsUpdate({ 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
// must never reach the spawn (esp. the Windows cmd.exe path).
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(DEFAULT_CHECK as never)
.mockResolvedValueOnce({
scope: "global",
skills: [
{ name: "graphic-overlays", status: "removed" },
{ name: "--config=evil.js", status: "removed" },
],
} as never);
await runSkillsUpdate();
const removeCall = state.spawnCalls.find((s) => s.args.includes("remove"));
expect(removeCall, "expected a `skills remove` spawn for the valid name").toBeDefined();
expect(removeCall!.args).toContain("graphic-overlays");
expect(removeCall!.args).not.toContain("--config=evil.js");
});
// The early-return guard in runSkillsRemove: when EVERY candidate name is
// rejected as non-slug, no `skills remove` is spawned at all (the prior test
// only covers a mix of valid + invalid). A spawn here would run `skills remove
// --yes` with no names — which the upstream CLI treats as "remove nothing" at
// best, or prompts interactively at worst — so we must not reach it.
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(DEFAULT_CHECK as never)
.mockResolvedValueOnce({
scope: "global",
skills: [
{ name: "--config=evil.js", status: "removed" },
{ name: "../escape", status: "removed" },
],
} as never);
await runSkillsUpdate();
expect(state.spawnCalls.some((s) => s.args.includes("remove"))).toBe(false);
// The install still ran and the update still succeeded — a cleanup no-op
// doesn't fail the update.
expect(state.spawnCalls[0]?.args).toContain("add");
expect(process.exitCode).toBe(0);
});
// When git is missing the upstream `skills add` would clone-abort with a noisy
// `spawn git ENOENT` block. Detect it first and never spawn the install, so a
// best-effort caller (init) skips cleanly and a fresh boot without git still
// scaffolds the project.
it("bare `skills` skips the install (no spawn) when git is unavailable", async () => {
setPlatform("linux");
state.gitMissing = true;
const { default: skillsCmd } = await import("./skills.js");
await skillsCmd.run?.({ args: {}, rawArgs: [], cmd: skillsCmd } as never);
expect(state.spawnCalls).toHaveLength(0);
expect(process.exitCode).toBe(0);
// Diagnostic instrumentation: the skip records why, so rare boxes hitting
// this (fresh Windows without git) are visible instead of silently no-op.
expect(trackSkillsInstallSkipped).toHaveBeenCalledWith({ reason: "git_missing" });
});
// The happy path must never emit the prerequisite-skip event — it's a
// skip-only diagnostic, not a per-install signal.
it("bare `skills` does not emit the skip event when prerequisites are present", async () => {
setPlatform("linux");
const { default: skillsCmd } = await import("./skills.js");
await skillsCmd.run?.({ args: {}, rawArgs: [], cmd: skillsCmd } as never);
expect(state.spawnCalls.length).toBeGreaterThan(0);
expect(trackSkillsInstallSkipped).not.toHaveBeenCalled();
});
// The strict recovery path (`skills check || skills update`) must fail loudly
// when git is missing, not silently no-op, else the `||` chain passes while
// nothing got installed.
it("skills update exits non-zero when git is unavailable", async () => {
setPlatform("linux");
state.gitMissing = true;
await runSkillsUpdate();
expect(state.spawnCalls).toHaveLength(0);
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("a malformed canonical manifest warns distinctly, then still degrades to presence mode", async () => {
setPlatform("linux");
const clack = await import("@clack/prompts");
vi.mocked(clack.log.warn).mockClear();
const { checkSkills } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockRejectedValue(
new Error("Malformed skills manifest from https://raw.githubusercontent.com/…"),
);
await runSkillsUpdateWith(["pr-to-video"]);
const warnedMalformed = vi
.mocked(clack.log.warn)
.mock.calls.some((args) => String(args[0]).includes("malformed"));
expect(warnedMalformed).toBe(true);
// Still degrades rather than failing the whole command.
expect(process.exitCode).toBe(0);
});
it("a genuine offline error degrades silently — no malformed-manifest warning", async () => {
setPlatform("linux");
const clack = await import("@clack/prompts");
vi.mocked(clack.log.warn).mockClear();
const { checkSkills } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockRejectedValue(new Error("fetch failed"));
await runSkillsUpdateWith(["pr-to-video"]);
const warnedMalformed = vi
.mocked(clack.log.warn)
.mock.calls.some((args) => String(args[0]).includes("malformed"));
expect(warnedMalformed).toBe(false);
});
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);
});
});