// 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; env: NodeJS.ProcessEnv | undefined; }; type ExecCall = { command: string; args: ReadonlyArray; }; 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) => { 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, 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(); 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 ` 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 ` subcommand from a freshly-imported module. */ async function runSkillsSub( name: "update", args: Record = {}, positionals: string[] = [], ): Promise { const { default: skillsCmd } = await import("./skills.js"); const subs = skillsCmd.subCommands as unknown as Record; expect(subs[name]).toBeDefined(); await subs[name]!.run?.({ args: { _: positionals, ...args }, rawArgs: positionals, cmd: subs[name], } as never); } const runSkillsUpdate = (args: Record = {}): Promise => runSkillsSub("update", args); const runSkillsUpdateWith = ( positionals: string[], args: Record = {}, ): Promise => runSkillsSub("update", args, positionals); /** The `--skill` values of a spawned `skills add` call. */ function skillFlagValues(args: ReadonlyArray): string[] { const values: string[] = []; for (let i = 0; i < args.length; i++) { if (args[i] === "--skill") values.push(args[i + 1] ?? ""); } return values; } describe("hyperframes skills", () => { let prevExitCode: typeof process.exitCode; beforeEach(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 ` so the workflow's skill (and the core // set it depends on) is guaranteed present and current before the agent reads // it. Positional names are the ONLY way update expands an install. describe("hyperframes skills update ", () => { let prevExitCode: typeof process.exitCode; beforeEach(async () => { state.execCalls = []; state.spawnCalls = []; state.spawnExitCode = 0; state.gitMissing = false; vi.resetModules(); const { checkSkills, presentSkills } = await import("../utils/skillsManifest.js"); vi.mocked(checkSkills).mockReset(); vi.mocked(checkSkills).mockImplementation(async () => DEFAULT_CHECK as never); vi.mocked(presentSkills).mockReset(); vi.mocked(presentSkills).mockImplementation((names: readonly string[]) => [...names]); prevExitCode = process.exitCode; process.exitCode = 0; }); afterEach(() => { setPlatform(originalPlatform); vi.restoreAllMocks(); process.exitCode = prevExitCode; }); it("installs the requested workflow plus the stale core set — nothing else", async () => { setPlatform("linux"); await runSkillsUpdateWith(["pr-to-video"]); expect(process.exitCode).toBe(0); const args = state.spawnCalls[0]?.args ?? []; expect(args).toContain("add"); // requested workflow (missing) + the stale core skills; embedded-captions // (installed + current) and the full-set wildcard must not appear. expect(skillFlagValues(args).sort()).toEqual([ "hyperframes", "hyperframes-core", "pr-to-video", ]); }); it("is a fast no-op (no install spawn) when everything is already current", async () => { setPlatform("linux"); const { checkSkills } = await import("../utils/skillsManifest.js"); vi.mocked(checkSkills).mockResolvedValue({ ...DEFAULT_CHECK, updateAvailable: false, skills: [ { name: "hyperframes", status: "current" }, { name: "hyperframes-core", status: "current" }, { name: "pr-to-video", status: "current" }, ], } as never); await runSkillsUpdateWith(["pr-to-video"]); expect(state.spawnCalls).toHaveLength(0); expect(process.exitCode).toBe(0); }); it("fails loudly on a skill name the manifest doesn't ship", async () => { setPlatform("linux"); await runSkillsUpdateWith(["graphic-overlays"]); // renamed upstream → unknown expect(state.spawnCalls).toHaveLength(0); expect(process.exitCode).toBe(1); }); it("rejects flag-like skill names before any spawn", async () => { setPlatform("linux"); await runSkillsUpdateWith(["--config=evil.js"]); expect(state.spawnCalls).toHaveLength(0); expect(process.exitCode).toBe(1); }); it("offline with the skill already on disk: proceeds without installing", async () => { setPlatform("linux"); const { checkSkills } = await import("../utils/skillsManifest.js"); vi.mocked(checkSkills).mockRejectedValue(new Error("offline")); await runSkillsUpdateWith(["pr-to-video"]); expect(state.spawnCalls).toHaveLength(0); expect(process.exitCode).toBe(0); }); it("offline with the skill absent: blind-installs it plus the fallback core set", async () => { setPlatform("linux"); const { checkSkills, presentSkills, FALLBACK_CORE_SKILLS } = await import("../utils/skillsManifest.js"); vi.mocked(checkSkills).mockRejectedValue(new Error("offline")); // Absent before the install, present after it (the blind install worked). vi.mocked(presentSkills) .mockImplementationOnce(() => []) .mockImplementation((names: readonly string[]) => [...names]); await runSkillsUpdateWith(["pr-to-video"]); // The offline guarantee must still cover the core tier the workflow // depends on, not silently shrink to just the named skill. const args = state.spawnCalls[0]?.args ?? []; expect(skillFlagValues(args).sort()).toEqual(["pr-to-video", ...FALLBACK_CORE_SKILLS].sort()); expect(process.exitCode).toBe(0); }); // The `check || update` CI contract: offline, a bare update can't verify // freshness — exiting 0 would let the chain pass while everything stays // stale. It must fail loudly instead. it("bare update offline exits non-zero instead of claiming success", async () => { setPlatform("linux"); const { checkSkills } = await import("../utils/skillsManifest.js"); vi.mocked(checkSkills).mockRejectedValue(new Error("offline")); await runSkillsUpdate(); expect(state.spawnCalls.some((s) => s.args.includes("add"))).toBe(false); expect(process.exitCode).toBe(1); }); it("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); }); });