mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 12:00:26 +00:00
`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.
779 lines
30 KiB
TypeScript
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);
|
|
});
|
|
});
|