mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
Add a `core-skills` entry to .claude-plugin/marketplace.json declaring the core skill set (the /hyperframes router, the hyperframes-* domain skills, and media-use). The upstream vercel-labs/skills CLI reads that entry's `skills` array for its interactive picker: the core set renders under a "Core Skills" group and everything else falls into "Other", so a human running `npx skills add heygen-com/hyperframes --full-depth` can tell the always-needed core set apart from the on-demand creation workflows — mirroring the core/on-demand tiers `hyperframes skills update` already enforces (isCoreSkill in skillsManifest.ts). The array deliberately lives on a separate marketplace entry, NOT on plugin.json or the `hyperframes` entry: Claude Code treats a manifest `skills` array as that plugin's skill allowlist (verified against claude CLI), so attaching it to the full plugin would narrow it from all skills to the core 8. As a side effect the new entry is itself a coherent Claude Code plugin — `core-skills@hyperframes` installs just the core set — while `hyperframes@hyperframes` keeps auto-discovering everything. A new pin test keeps the marketplace list in lockstep with isCoreSkill and the skills/ tree (alongside the existing FALLBACK_CORE_SKILLS pin), and asserts the full plugin carries no allowlist. Agent installs are unaffected — the upstream CLI detects agent environments and installs non-interactively, and the hyperframes CLI always passes explicit --skill flags. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
804 lines
32 KiB
TypeScript
804 lines
32 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
hashSkillBundle,
|
|
buildManifest,
|
|
checkSkills,
|
|
diffSkills,
|
|
FALLBACK_CORE_SKILLS,
|
|
isCoreSkill,
|
|
MANIFEST_FILE,
|
|
presentSkills,
|
|
pruneOrphanedLockEntries,
|
|
skillsAttributedToSource,
|
|
type SkillsManifest,
|
|
type SkillEntry,
|
|
} from "./skillsManifest.js";
|
|
|
|
// The retired-skill regression tests below drive `checkSkills`'s real
|
|
// `canonical: true` network path (see resolveLatestManifest) instead of an
|
|
// explicit local `source` — that's the whole point (it must NOT read a stale
|
|
// local repo manifest). Stub the two network boundaries it can reach so those
|
|
// tests stay fast and offline: `git ls-remote` (remoteHeadSha) always "fails"
|
|
// so it falls back to the branch URL, and `fetch` is stubbed per-test. `vi.mock`
|
|
// is hoisted above these imports regardless of source position. No existing
|
|
// test in this file omits `source`, so nothing else touches this mock.
|
|
vi.mock("node:child_process", () => ({
|
|
execFile: vi.fn(
|
|
(
|
|
_cmd: string,
|
|
_args: readonly string[],
|
|
_opts: unknown,
|
|
callback: (err: Error | null) => void,
|
|
) => callback(new Error("no git in tests")),
|
|
),
|
|
}));
|
|
|
|
let root: string;
|
|
|
|
beforeEach(() => {
|
|
root = mkdtempSync(join(tmpdir(), "skills-manifest-"));
|
|
});
|
|
afterEach(() => {
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
function writeSkill(name: string, files: Record<string, string>): string {
|
|
const dir = join(root, name);
|
|
for (const [rel, content] of Object.entries(files)) {
|
|
const p = join(dir, rel);
|
|
mkdirSync(join(p, ".."), { recursive: true });
|
|
writeFileSync(p, content);
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
describe("hashSkillBundle", () => {
|
|
it("is deterministic for identical content", () => {
|
|
const a = writeSkill("a", { "SKILL.md": "hello", "references/x.md": "x" });
|
|
const b = writeSkill("b", { "SKILL.md": "hello", "references/x.md": "x" });
|
|
expect(hashSkillBundle(a).hash).toBe(hashSkillBundle(b).hash);
|
|
});
|
|
|
|
it("changes when any file's content changes", () => {
|
|
const dir = writeSkill("a", { "SKILL.md": "hello", "references/x.md": "x" });
|
|
const before = hashSkillBundle(dir).hash;
|
|
writeFileSync(join(dir, "references/x.md"), "CHANGED");
|
|
expect(hashSkillBundle(dir).hash).not.toBe(before);
|
|
});
|
|
|
|
it("counts every file in the bundle, not just SKILL.md", () => {
|
|
const dir = writeSkill("a", {
|
|
"SKILL.md": "hello",
|
|
"references/x.md": "x",
|
|
"scripts/y.mjs": "export const y = 1;",
|
|
});
|
|
expect(hashSkillBundle(dir).files).toBe(3);
|
|
});
|
|
|
|
it("normalises CRLF so a Windows checkout is not flagged as different", () => {
|
|
const lf = writeSkill("lf", { "SKILL.md": "line1\nline2\n" });
|
|
const crlf = writeSkill("crlf", { "SKILL.md": "line1\r\nline2\r\n" });
|
|
expect(hashSkillBundle(lf).hash).toBe(hashSkillBundle(crlf).hash);
|
|
});
|
|
});
|
|
|
|
describe("buildManifest", () => {
|
|
it("includes only directories that contain a SKILL.md", () => {
|
|
writeSkill("real", { "SKILL.md": "x" });
|
|
writeSkill("not-a-skill", { "README.md": "x" });
|
|
const m = buildManifest(root, { source: "test" });
|
|
expect(Object.keys(m.skills)).toEqual(["real"]);
|
|
});
|
|
});
|
|
|
|
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(".claude-plugin/marketplace.json core-skills pin", () => {
|
|
// The marketplace `core-skills` entry's `skills` array drives two surfaces:
|
|
// the upstream `skills add` picker groups the listed skills under
|
|
// "Core Skills" (everything unlisted falls into "Other"), and Claude Code
|
|
// treats the array as that plugin's skill allowlist. That makes it a third
|
|
// enumeration of core membership — pin it to isCoreSkill and the skills/
|
|
// tree so neither surface can silently drift from the tiers `init` /
|
|
// `skills update` actually enforce. It lives on a separate marketplace
|
|
// entry (not plugin.json, and not the `hyperframes` entry) precisely so
|
|
// the full `hyperframes` plugin keeps auto-discovering all skills.
|
|
it("lists exactly the core skills present in the repo's skills/ tree", () => {
|
|
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..");
|
|
const marketplace = JSON.parse(
|
|
readFileSync(join(repoRoot, ".claude-plugin", "marketplace.json"), "utf-8"),
|
|
) as { plugins?: { name?: string; skills?: string[] }[] };
|
|
const entry = marketplace.plugins?.find((p) => p.name === "core-skills");
|
|
if (!entry?.skills) {
|
|
throw new Error("marketplace.json is missing the core-skills entry's `skills` array");
|
|
}
|
|
const declared = entry.skills.map((p) => p.replace(/^\.\/skills\//, "")).sort();
|
|
|
|
const skillsRoot = join(repoRoot, "skills");
|
|
const coreOnDisk = readdirSync(skillsRoot)
|
|
.filter((n) => existsSync(join(skillsRoot, n, "SKILL.md")))
|
|
.filter((n) => isCoreSkill(n))
|
|
.sort();
|
|
|
|
expect(declared).toEqual(coreOnDisk);
|
|
// Upstream resolves each entry relative to the repo root — the "./skills/"
|
|
// prefix is load-bearing (see vercel-labs/skills plugin-manifest.ts).
|
|
for (const p of entry.skills) {
|
|
expect(p.startsWith("./skills/")).toBe(true);
|
|
}
|
|
// The full plugin must NOT carry a skills allowlist: Claude Code would
|
|
// narrow it to the listed subset instead of auto-discovering all skills.
|
|
const full = marketplace.plugins?.find((p) => p.name === "hyperframes");
|
|
expect(full).toBeDefined();
|
|
expect(full?.skills).toBeUndefined();
|
|
// Same for plugin.json (the direct-install manifest for the full plugin) —
|
|
// and upstream lets plugin.json groupings override marketplace ones, so a
|
|
// skills array here would also rename the picker group back to
|
|
// "Hyperframes".
|
|
const plugin = JSON.parse(
|
|
readFileSync(join(repoRoot, ".claude-plugin", "plugin.json"), "utf-8"),
|
|
) as { skills?: string[] };
|
|
expect(plugin.skills).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("diffSkills", () => {
|
|
const latest: SkillsManifest = {
|
|
source: "test",
|
|
skills: {
|
|
keep: { hash: "h1", files: 1 },
|
|
changed: { hash: "h2", files: 1 },
|
|
gone: { hash: "h3", files: 1 },
|
|
},
|
|
};
|
|
|
|
it("classifies current / outdated / missing and ignores skills not in the manifest", () => {
|
|
const installed: Record<string, SkillEntry> = {
|
|
keep: { hash: "h1", files: 1 }, // current
|
|
changed: { hash: "DIFFERENT", files: 1 }, // outdated
|
|
// gone: not installed → missing
|
|
extra: { hash: "hx", files: 1 }, // not in the manifest → ignored
|
|
};
|
|
const diff = diffSkills(installed, latest);
|
|
const byName = Object.fromEntries(diff.skills.map((s) => [s.name, s.status]));
|
|
expect(byName).toEqual({
|
|
keep: "current",
|
|
changed: "outdated",
|
|
gone: "missing",
|
|
});
|
|
expect(diff.summary).toEqual({ current: 1, outdated: 1, missing: 1, coreMissing: 0 });
|
|
});
|
|
|
|
it("flags updateAvailable for anything outdated", () => {
|
|
const hasOutdated = diffSkills({ changed: { hash: "X", files: 1 } }, latest);
|
|
expect(hasOutdated.updateAvailable).toBe(true);
|
|
|
|
// Everything present and current → no update.
|
|
const allCurrent = diffSkills(
|
|
{
|
|
keep: { hash: "h1", files: 1 },
|
|
changed: { hash: "h2", files: 1 },
|
|
gone: { hash: "h3", files: 1 },
|
|
},
|
|
latest,
|
|
);
|
|
expect(allCurrent.updateAvailable).toBe(false);
|
|
|
|
// A skill installed but not in the manifest is ignored — doesn't trigger one.
|
|
const withExtra = diffSkills(
|
|
{
|
|
keep: { hash: "h1", files: 1 },
|
|
changed: { hash: "h2", files: 1 },
|
|
gone: { hash: "h3", files: 1 },
|
|
extra: { hash: "hx", files: 1 },
|
|
},
|
|
latest,
|
|
);
|
|
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", () => {
|
|
// A spread of agent-host conventions across the upstream `skills` universe,
|
|
// including the XDG-nested OpenCode layout. Detection is structural
|
|
// (auto-discovered), so this list is illustrative, not exhaustive.
|
|
const CASES: ReadonlyArray<[string, string]> = [
|
|
[".claude/skills", "claude-code"],
|
|
[".agents/skills", "agents"],
|
|
[".codex/skills", "codex"],
|
|
[".cursor/skills", "cursor"],
|
|
[".config/opencode/skills", "opencode"],
|
|
[".factory/skills", "factory"],
|
|
[".slate/skills", "slate"],
|
|
[".kiro/skills", "kiro"],
|
|
[".hermes/skills", "hermes"],
|
|
[".gbrain/skills", "gbrain"],
|
|
[".openclaw/skills", "openclaw"],
|
|
];
|
|
|
|
function writeManifest(dir: string): string {
|
|
const p = join(dir, "manifest.json");
|
|
writeFileSync(
|
|
p,
|
|
JSON.stringify({
|
|
source: "test",
|
|
skills: { alpha: { hash: "x", files: 1 }, beta: { hash: "y", files: 1 } },
|
|
}),
|
|
);
|
|
return p;
|
|
}
|
|
|
|
function installSkill(skillsDir: string, name: string): void {
|
|
mkdirSync(join(skillsDir, name), { recursive: true });
|
|
writeFileSync(join(skillsDir, name, "SKILL.md"), `# ${name}`);
|
|
}
|
|
|
|
it.each(CASES)("locates skills under %s in the project scope", async (rel, agent) => {
|
|
const project = join(root, "project");
|
|
const home = join(root, "home");
|
|
mkdirSync(project, { recursive: true });
|
|
mkdirSync(home, { recursive: true });
|
|
const source = writeManifest(root);
|
|
installSkill(join(project, rel), "alpha");
|
|
|
|
const res = await checkSkills({ source, cwd: project, home });
|
|
expect(res.location).toBe(join(project, rel));
|
|
expect(res.agent).toBe(agent);
|
|
});
|
|
|
|
it.each(CASES)("locates skills under %s in the global scope", async (rel, agent) => {
|
|
const project = join(root, "project");
|
|
const home = join(root, "home");
|
|
mkdirSync(project, { recursive: true });
|
|
mkdirSync(home, { recursive: true });
|
|
const source = writeManifest(root);
|
|
installSkill(join(home, rel), "alpha");
|
|
|
|
const res = await checkSkills({ source, cwd: project, home });
|
|
expect(res.location).toBe(join(home, rel));
|
|
expect(res.agent).toBe(agent);
|
|
});
|
|
|
|
it("prefers global scope over project (matches how agents load skills)", async () => {
|
|
const project = join(root, "project");
|
|
const home = join(root, "home");
|
|
mkdirSync(project, { recursive: true });
|
|
mkdirSync(home, { recursive: true });
|
|
const source = writeManifest(root);
|
|
installSkill(join(home, ".claude/skills"), "alpha"); // global — what the agent actually loads
|
|
installSkill(join(project, ".hermes/skills"), "alpha"); // project — overridden by the global copy
|
|
|
|
// Claude Code (and most agents) give the personal/global scope priority over
|
|
// the project scope, and HyperFrames installs globally — so check reports on
|
|
// the global copy the agent will really use, not a stale project copy.
|
|
const res = await checkSkills({ source, cwd: project, home });
|
|
expect(res.location).toBe(join(home, ".claude/skills"));
|
|
expect(res.agent).toBe("claude-code");
|
|
});
|
|
|
|
it("reports no location and an available update when nothing is installed", async () => {
|
|
const project = join(root, "project");
|
|
const home = join(root, "home");
|
|
mkdirSync(project, { recursive: true });
|
|
mkdirSync(home, { recursive: true });
|
|
// 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);
|
|
});
|
|
|
|
it("honors the --dir override and infers the agent from the path", async () => {
|
|
const dir = join(root, "home", ".kiro/skills");
|
|
installSkill(dir, "alpha");
|
|
const source = writeManifest(root);
|
|
|
|
const res = await checkSkills({ source, dir });
|
|
expect(res.location).toBe(dir);
|
|
expect(res.agent).toBe("kiro");
|
|
});
|
|
|
|
it("auto-discovers an unknown/new agent host (no closed list)", async () => {
|
|
const project = join(root, "project");
|
|
const home = join(root, "home");
|
|
mkdirSync(project, { recursive: true });
|
|
mkdirSync(home, { recursive: true });
|
|
const source = writeManifest(root);
|
|
// A host this CLI has never heard of — structural discovery still finds it.
|
|
installSkill(join(home, ".some-future-agent/skills"), "alpha");
|
|
|
|
const res = await checkSkills({ source, cwd: project, home });
|
|
expect(res.location).toBe(join(home, ".some-future-agent/skills"));
|
|
expect(res.agent).toBe("some-future-agent");
|
|
});
|
|
|
|
it("prefers claude-code when multiple hosts in the same scope have skills", async () => {
|
|
const project = join(root, "project");
|
|
const home = join(root, "home");
|
|
mkdirSync(project, { recursive: true });
|
|
mkdirSync(home, { recursive: true });
|
|
const source = writeManifest(root);
|
|
installSkill(join(home, ".factory/skills"), "alpha");
|
|
installSkill(join(home, ".claude/skills"), "alpha");
|
|
|
|
const res = await checkSkills({ source, cwd: project, home });
|
|
expect(res.location).toBe(join(home, ".claude/skills"));
|
|
expect(res.agent).toBe("claude-code");
|
|
});
|
|
});
|
|
|
|
describe("skillsAttributedToSource", () => {
|
|
it("matches by slug or git clone URL and ignores other sources", () => {
|
|
const lock = {
|
|
skills: {
|
|
a: { source: "heygen-com/hyperframes" },
|
|
b: { sourceUrl: "https://github.com/heygen-com/hyperframes.git" },
|
|
c: { source: "https://github.com/heygen-com/hyperframes" },
|
|
d: { source: "greensock/gsap-skills" },
|
|
},
|
|
};
|
|
expect(skillsAttributedToSource(lock, "heygen-com/hyperframes").sort()).toEqual([
|
|
"a",
|
|
"b",
|
|
"c",
|
|
]);
|
|
});
|
|
|
|
it("returns [] for a null/empty lock or empty source", () => {
|
|
expect(skillsAttributedToSource(null, "x")).toEqual([]);
|
|
expect(skillsAttributedToSource({}, "x")).toEqual([]);
|
|
expect(skillsAttributedToSource({ skills: { a: { source: "x" } } }, "")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("checkSkills removed-upstream detection", () => {
|
|
// The global lock path honours XDG_STATE_HOME; clear it so the fixture under
|
|
// <home>/.agents/.skill-lock.json is the one that's read.
|
|
let xdg: string | undefined;
|
|
beforeEach(() => {
|
|
xdg = process.env.XDG_STATE_HOME;
|
|
delete process.env.XDG_STATE_HOME;
|
|
});
|
|
afterEach(() => {
|
|
if (xdg === undefined) delete process.env.XDG_STATE_HOME;
|
|
else process.env.XDG_STATE_HOME = xdg;
|
|
});
|
|
|
|
// Install one or more `<skillsDir>/<name>/SKILL.md` bundles.
|
|
function installSkills(skillsDir: string, names: string[]): void {
|
|
for (const name of names) {
|
|
mkdirSync(join(skillsDir, name), { recursive: true });
|
|
writeFileSync(join(skillsDir, name, "SKILL.md"), `# ${name}`);
|
|
}
|
|
}
|
|
|
|
// Shared fixture: a project + home with two skills installed globally
|
|
// (alpha + gamma), plus a manifest path. Tests then write the lock they need.
|
|
function setup(manifest: SkillsManifest): {
|
|
home: string;
|
|
opts: { source: string; cwd: string; home: string };
|
|
} {
|
|
const project = join(root, "project");
|
|
const home = join(root, "home");
|
|
mkdirSync(project, { recursive: true });
|
|
installSkills(join(home, ".agents/skills"), ["alpha", "gamma"]);
|
|
const manifestPath = join(root, "manifest.json");
|
|
writeFileSync(manifestPath, JSON.stringify(manifest));
|
|
return { home, opts: { source: manifestPath, cwd: project, home } };
|
|
}
|
|
function writeGlobalLock(home: string, skills: Record<string, { source: string }>): void {
|
|
writeFileSync(join(home, ".agents/.skill-lock.json"), JSON.stringify({ version: 3, skills }));
|
|
}
|
|
|
|
it("flags a lock-attributed skill the manifest dropped, ignoring other sources", async () => {
|
|
const { home, opts } = setup({ source: "test", skills: { alpha: { hash: "x", files: 1 } } });
|
|
writeGlobalLock(home, {
|
|
alpha: { source: "test" }, // still ours and in the manifest
|
|
gamma: { source: "test" }, // ours, dropped from manifest → removed
|
|
delta: { source: "someone/else" }, // a different source → never ours
|
|
});
|
|
|
|
const res = await checkSkills(opts);
|
|
const byName = Object.fromEntries(res.skills.map((s) => [s.name, s.status]));
|
|
expect(byName.gamma).toBe("removed");
|
|
expect(byName.delta).toBeUndefined();
|
|
expect(res.summary.removed).toBe(1);
|
|
});
|
|
|
|
it("a removed skill alone makes an update available (no outdated/missing)", async () => {
|
|
// Manifest lists only alpha, with its REAL hash → "current" (not outdated).
|
|
const { home, opts } = setup({ source: "test", skills: {} });
|
|
const manifest: SkillsManifest = {
|
|
source: "test",
|
|
skills: { alpha: hashSkillBundle(join(home, ".agents/skills/alpha")) },
|
|
};
|
|
writeFileSync(opts.source, JSON.stringify(manifest));
|
|
writeGlobalLock(home, { alpha: { source: "test" }, gamma: { source: "test" } });
|
|
|
|
const res = await checkSkills(opts);
|
|
expect(res.summary).toEqual({
|
|
current: 1,
|
|
outdated: 0,
|
|
missing: 0,
|
|
coreMissing: 0,
|
|
removed: 1,
|
|
});
|
|
expect(res.updateAvailable).toBe(true);
|
|
});
|
|
|
|
it("reports no removed skills when there is no lock to attribute from", async () => {
|
|
const { opts } = setup({ source: "test", skills: { alpha: { hash: "x", files: 1 } } });
|
|
// gamma is an orphan on disk, but with no lock we can't attribute it to us.
|
|
const res = await checkSkills(opts);
|
|
expect(res.summary.removed).toBe(0);
|
|
expect(res.skills.some((s) => s.status === "removed")).toBe(false);
|
|
// No install was located via auto-detect (skills live under <home>/.agents/skills
|
|
// which IS discovered) — so lockMissing reflects the genuinely-absent lock.
|
|
expect(res.lockMissing).toBe(true);
|
|
});
|
|
|
|
// A `--dir` pointing at a global-scoped install (under $HOME) must resolve the
|
|
// GLOBAL lock (<home>/.agents/.skill-lock.json), not the project lock
|
|
// (<cwd>/skills-lock.json). Before this fix, locateInstall hardcoded scope
|
|
// "project" for every --dir, so the global lock was never read and
|
|
// removed-detection silently found nothing for --dir installs.
|
|
it("--dir under $HOME resolves the global lock so removed-detection works", async () => {
|
|
const { home, opts } = setup({ source: "test", skills: { alpha: { hash: "x", files: 1 } } });
|
|
writeGlobalLock(home, {
|
|
alpha: { source: "test" }, // ours and still in the manifest
|
|
gamma: { source: "test" }, // ours, dropped from manifest → removed
|
|
});
|
|
|
|
const dir = join(home, ".agents/skills");
|
|
const res = await checkSkills({ source: opts.source, dir, cwd: opts.cwd, home });
|
|
expect(res.scope).toBe("global");
|
|
const byName = Object.fromEntries(res.skills.map((s) => [s.name, s.status]));
|
|
expect(byName.gamma).toBe("removed");
|
|
expect(res.summary.removed).toBe(1);
|
|
expect(res.lockMissing).toBe(false);
|
|
});
|
|
|
|
// Regression (Magi REQUEST_CHANGES at 88daa820): the common project-local case
|
|
// is *also* under $HOME — a project checkout at `<home>/work/proj` with skills
|
|
// at `<cwd>/.claude/skills`. A HOME-first heuristic misclassified this as
|
|
// global, so checkSkills read the global lock and `skills update` would prune
|
|
// with `-g` against a project install. CWD-containment must win: `--dir` under
|
|
// `cwd` resolves to PROJECT even when `cwd` itself is nested under $HOME.
|
|
it("--dir under a cwd that is itself nested under $HOME stays project-scoped", async () => {
|
|
const home = join(root, "home");
|
|
const project = join(home, "work", "proj"); // cwd nested INSIDE home
|
|
const skillsDir = join(project, ".claude/skills");
|
|
installSkills(skillsDir, ["alpha", "gamma"]);
|
|
const manifestPath = join(root, "m3.json");
|
|
writeFileSync(
|
|
manifestPath,
|
|
JSON.stringify({ source: "test", skills: { alpha: { hash: "x", files: 1 } } }),
|
|
);
|
|
// Project lock at <cwd>/skills-lock.json — only read if scope is "project".
|
|
// No global lock exists under $HOME, so a wrong-scope ("global") read would
|
|
// find no lock → zero removed (gamma would NOT be flagged). The project lock
|
|
// being read (gamma → removed) is the proof CWD-containment won.
|
|
writeFileSync(
|
|
join(project, "skills-lock.json"),
|
|
JSON.stringify({
|
|
version: 1,
|
|
skills: { alpha: { source: "test" }, gamma: { source: "test" } },
|
|
}),
|
|
);
|
|
|
|
const res = await checkSkills({ source: manifestPath, dir: skillsDir, cwd: project, home });
|
|
expect(res.scope).toBe("project");
|
|
const byName = Object.fromEntries(res.skills.map((s) => [s.name, s.status]));
|
|
expect(byName.gamma).toBe("removed");
|
|
expect(res.summary.removed).toBe(1);
|
|
});
|
|
|
|
// The complementary case: a `--dir` under the project tree (not $HOME) stays
|
|
// project-scoped and reads <cwd>/skills-lock.json.
|
|
it("--dir outside $HOME stays project-scoped and reads the project lock", async () => {
|
|
const project = join(root, "proj2");
|
|
const skillsDir = join(project, ".claude/skills");
|
|
installSkills(skillsDir, ["alpha", "gamma"]);
|
|
const manifestPath = join(root, "m2.json");
|
|
writeFileSync(
|
|
manifestPath,
|
|
JSON.stringify({ source: "test", skills: { alpha: { hash: "x", files: 1 } } }),
|
|
);
|
|
// Project lock lives at <cwd>/skills-lock.json — point cwd at the project.
|
|
writeFileSync(
|
|
join(project, "skills-lock.json"),
|
|
JSON.stringify({
|
|
version: 1,
|
|
skills: { alpha: { source: "test" }, gamma: { source: "test" } },
|
|
}),
|
|
);
|
|
const home = join(root, "home2");
|
|
mkdirSync(home, { recursive: true });
|
|
|
|
const res = await checkSkills({ source: manifestPath, dir: skillsDir, cwd: project, home });
|
|
expect(res.scope).toBe("project");
|
|
const byName = Object.fromEntries(res.skills.map((s) => [s.name, s.status]));
|
|
expect(byName.gamma).toBe("removed");
|
|
expect(res.summary.removed).toBe(1);
|
|
});
|
|
});
|
|
|
|
// Regression coverage for "variant 1" of the retired-skill bug: `updateSkills`
|
|
// (see commands/skills.ts) resolves its own targeted-install check with
|
|
// `canonical: true` specifically so it never trusts a stale local
|
|
// `skills-manifest.json` — this is the mechanism that makes that safe.
|
|
describe("checkSkills canonical bypass of the in-repo manifest shortcut", () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function stubFetchedManifest(manifest: SkillsManifest): void {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => ({ ok: true, json: async () => manifest }) as unknown as Response),
|
|
);
|
|
}
|
|
|
|
it("without canonical, a stale in-repo manifest wins (documented dev/CI shortcut)", async () => {
|
|
const project = join(root, "project");
|
|
const home = join(root, "home");
|
|
mkdirSync(project, { recursive: true });
|
|
mkdirSync(home, { recursive: true });
|
|
// A checked-out repo's own manifest, stale: it still lists a skill that
|
|
// has since been retired from the canonical published repo.
|
|
writeFileSync(
|
|
join(project, MANIFEST_FILE),
|
|
JSON.stringify({
|
|
source: "heygen-com/hyperframes",
|
|
skills: { "retired-skill": { hash: "x", files: 1 } },
|
|
}),
|
|
);
|
|
|
|
const res = await checkSkills({ cwd: project, home });
|
|
expect(res.skills.map((s) => s.name)).toContain("retired-skill");
|
|
});
|
|
|
|
it("with canonical:true, the same stale in-repo manifest is ignored — the fetched manifest wins", async () => {
|
|
const project = join(root, "project");
|
|
const home = join(root, "home");
|
|
mkdirSync(project, { recursive: true });
|
|
mkdirSync(home, { recursive: true });
|
|
writeFileSync(
|
|
join(project, MANIFEST_FILE),
|
|
JSON.stringify({
|
|
source: "heygen-com/hyperframes",
|
|
skills: { "retired-skill": { hash: "x", files: 1 }, kept: { hash: "y", files: 1 } },
|
|
}),
|
|
);
|
|
// The canonical (fetched) manifest no longer ships `retired-skill`.
|
|
stubFetchedManifest({
|
|
source: "heygen-com/hyperframes",
|
|
skills: { kept: { hash: "y", files: 1 } },
|
|
});
|
|
|
|
const res = await checkSkills({ cwd: project, home, canonical: true });
|
|
expect(res.skills.map((s) => s.name)).not.toContain("retired-skill");
|
|
expect(res.skills.map((s) => s.name)).toContain("kept");
|
|
});
|
|
|
|
it("canonical:true still honors an explicit local `source` override", async () => {
|
|
const project = join(root, "project");
|
|
const home = join(root, "home");
|
|
mkdirSync(project, { recursive: true });
|
|
mkdirSync(home, { recursive: true });
|
|
writeFileSync(
|
|
join(project, MANIFEST_FILE),
|
|
JSON.stringify({ source: "test", skills: { "from-repo-shortcut": { hash: "x", files: 1 } } }),
|
|
);
|
|
const explicitSource = join(root, "explicit-manifest.json");
|
|
writeFileSync(
|
|
explicitSource,
|
|
JSON.stringify({
|
|
source: "test",
|
|
skills: { "from-explicit-source": { hash: "y", files: 1 } },
|
|
}),
|
|
);
|
|
|
|
// An explicit `source` is a deliberate caller choice — canonical must not
|
|
// override it, only the silent in-repo shortcut.
|
|
const res = await checkSkills({ source: explicitSource, cwd: project, home, canonical: true });
|
|
expect(res.skills.map((s) => s.name)).toEqual(["from-explicit-source"]);
|
|
});
|
|
});
|
|
|
|
describe("pruneOrphanedLockEntries", () => {
|
|
function writeLock(path: string, skills: Record<string, { source: string }>): void {
|
|
writeFileSync(path, JSON.stringify({ version: 1, skills, dismissed: [] }));
|
|
}
|
|
|
|
it("removes only the given names, leaving other entries and lock fields intact", () => {
|
|
const home = join(root, "home");
|
|
mkdirSync(join(home, ".agents"), { recursive: true });
|
|
const lockPath = join(home, ".agents", ".skill-lock.json");
|
|
writeLock(lockPath, {
|
|
a: { source: "heygen-com/hyperframes" },
|
|
b: { source: "heygen-com/hyperframes" },
|
|
c: { source: "heygen-com/hyperframes" },
|
|
});
|
|
|
|
const pruned = pruneOrphanedLockEntries(["a", "b"], "global", { home });
|
|
|
|
expect(pruned.sort()).toEqual(["a", "b"]);
|
|
const rewritten = JSON.parse(readFileSync(lockPath, "utf8"));
|
|
expect(Object.keys(rewritten.skills)).toEqual(["c"]);
|
|
expect(rewritten.version).toBe(1); // other lock fields survive the rewrite
|
|
});
|
|
|
|
it("is idempotent — a second call with the same names finds nothing left and no-ops", () => {
|
|
const home = join(root, "home");
|
|
mkdirSync(join(home, ".agents"), { recursive: true });
|
|
const lockPath = join(home, ".agents", ".skill-lock.json");
|
|
writeLock(lockPath, { a: { source: "heygen-com/hyperframes" } });
|
|
|
|
const first = pruneOrphanedLockEntries(["a"], "global", { home });
|
|
expect(first).toEqual(["a"]);
|
|
|
|
const before = readFileSync(lockPath, "utf8");
|
|
const second = pruneOrphanedLockEntries(["a"], "global", { home });
|
|
expect(second).toEqual([]);
|
|
// No entries left to touch → the file is never rewritten a second time.
|
|
expect(readFileSync(lockPath, "utf8")).toBe(before);
|
|
});
|
|
|
|
it("writes atomically with no trailing newline, no leftover temp file, and preserves the file mode", () => {
|
|
const home = join(root, "home-atomic");
|
|
mkdirSync(join(home, ".agents"), { recursive: true });
|
|
const lockPath = join(home, ".agents", ".skill-lock.json");
|
|
writeLock(lockPath, {
|
|
a: { source: "heygen-com/hyperframes" },
|
|
b: { source: "heygen-com/hyperframes" },
|
|
});
|
|
chmodSync(lockPath, 0o640);
|
|
|
|
const pruned = pruneOrphanedLockEntries(["a"], "global", { home });
|
|
|
|
expect(pruned).toEqual(["a"]);
|
|
const raw = readFileSync(lockPath, "utf8");
|
|
expect(raw.endsWith("\n")).toBe(false);
|
|
expect(JSON.parse(raw).skills).toEqual({ b: { source: "heygen-com/hyperframes" } });
|
|
// No `.tmp` sibling left behind by the temp-file + rename.
|
|
expect(readdirSync(join(home, ".agents"))).toEqual([".skill-lock.json"]);
|
|
// Original permissions survive the rewrite (POSIX only — Windows's fs
|
|
// layer reports 0o666 regardless of the mode we set, so the bits aren't
|
|
// meaningful there).
|
|
if (process.platform !== "win32") {
|
|
expect(statSync(lockPath).mode & 0o777).toBe(0o640);
|
|
}
|
|
});
|
|
|
|
it("no-ops without throwing when the lock file doesn't exist", () => {
|
|
const home = join(root, "home-without-lock");
|
|
mkdirSync(home, { recursive: true });
|
|
expect(pruneOrphanedLockEntries(["a"], "global", { home })).toEqual([]);
|
|
});
|
|
|
|
it("resolves the project lock at <cwd>/skills-lock.json for scope: project", () => {
|
|
const project = join(root, "project");
|
|
mkdirSync(project, { recursive: true });
|
|
writeLock(join(project, "skills-lock.json"), {
|
|
a: { source: "heygen-com/hyperframes" },
|
|
b: { source: "heygen-com/hyperframes" },
|
|
});
|
|
|
|
const pruned = pruneOrphanedLockEntries(["a"], "project", { cwd: project });
|
|
|
|
expect(pruned).toEqual(["a"]);
|
|
const rewritten = JSON.parse(readFileSync(join(project, "skills-lock.json"), "utf8"));
|
|
expect(Object.keys(rewritten.skills)).toEqual(["b"]);
|
|
});
|
|
});
|