Skip to content

Commit d36a0ac

Browse files
fix(skills): report the engines with no skills primitive instead of dropping them (#166)
prd/0003 R8 requires `/skill install` to report engines without a skills primitive as skipped, and the PRD's own UX example prints a `skipped — no skills primitive` line for them. planSkillInstall maps SKILL_ENGINES, so codex, opencode, privacycode and aider never enter the plan, never reach runSkillInstall, and never reach the summary. The `{ skip: "no skills primitive" }` branch in skillInstallAction is unreachable through the command. Plan over every engine, ordered SKILL_ENGINES first then the remainder, derived from ENGINES exactly as the /skill list matrix already derives it. runSkillInstall checks skip before installed, so a skipped engine reports its reason whether or not it is installed. Co-authored-by: clawedassistant26 <307253840+clawedassistant26@users.noreply.github.com>
1 parent d892458 commit d36a0ac

2 files changed

Lines changed: 148 additions & 2 deletions

File tree

src/skills.mjs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,17 @@ export function skillInstallAction(key, spec) {
4646
}
4747
}
4848

49-
/** Plan the fan-out: one entry per skills engine with its action or skip reason. */
49+
/**
50+
* Plan the fan-out: one entry per engine with its action or skip reason.
51+
* Every engine, not just SKILL_ENGINES — prd/0003 R8 requires the engines with
52+
* no skills primitive to be *reported* as skipped, and an engine missing from
53+
* the plan is missing from the summary. Derived from ENGINES the same way the
54+
* /skill list matrix derives it, so an engine added later cannot quietly fall
55+
* out of the fan-out while still showing up in the matrix.
56+
*/
5057
export function planSkillInstall(spec, { installedSet } = {}) {
51-
return SKILL_ENGINES.map((key) => {
58+
const rest = Object.keys(ENGINES).filter((key) => !SKILL_ENGINES.includes(key));
59+
return [...SKILL_ENGINES, ...rest].map((key) => {
5260
const bin = ENGINES[key].bin;
5361
const installed = installedSet ? installedSet.has(key) : isInstalled(bin);
5462
return { key, bin, installed, ...skillInstallAction(key, spec) };

test/skill-install-fanout.test.mjs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// prd/0003 R8: `/skill install` MUST report the engines that have no skills
2+
// primitive as skipped. The plan is what the summary prints, so an engine the
3+
// plan omits is an engine the user never hears about.
4+
import assert from "node:assert/strict";
5+
import path from "node:path";
6+
import test from "node:test";
7+
8+
import { ENGINES } from "../src/engines.mjs";
9+
import {
10+
SKILL_ENGINES, claudeSkillsDir, planSkillInstall, runSkillInstall,
11+
} from "../src/skills.mjs";
12+
13+
const SPEC = { source: "https://github.com/acme/some-skill", name: "some-skill" };
14+
const NO_PRIMITIVE = Object.keys(ENGINES).filter((key) => !SKILL_ENGINES.includes(key));
15+
const byKey = (results) => Object.fromEntries(results.map((r) => [r.key, r]));
16+
17+
// --- the bug -----------------------------------------------------------------
18+
19+
test("the plan covers every engine, not just the ones with a primitive", () => {
20+
const keys = planSkillInstall(SPEC, { installedSet: new Set() }).map((p) => p.key);
21+
assert.deepEqual([...keys].sort(), Object.keys(ENGINES).sort());
22+
});
23+
24+
test("every engine without a primitive carries the skip reason", () => {
25+
const plan = byKey(planSkillInstall(SPEC, { installedSet: new Set() }));
26+
for (const key of NO_PRIMITIVE) {
27+
assert.equal(plan[key]?.skip, "no skills primitive", `${key} has no skip reason`);
28+
}
29+
});
30+
31+
test("the fan-out reports them as skipped, so the summary can print a line", async () => {
32+
const plan = planSkillInstall(SPEC, { installedSet: new Set(["claude", "gemini"]) });
33+
const results = byKey(await runSkillInstall(plan, { run: async () => ({ ok: true, code: 0 }) }));
34+
for (const key of NO_PRIMITIVE) {
35+
assert.equal(results[key]?.status, "skipped", `${key} missing from the summary`);
36+
assert.equal(results[key]?.reason, "no skills primitive");
37+
}
38+
});
39+
40+
test("skipped beats not-installed: an absent engine still gets its reason", async () => {
41+
// Nothing installed at all — the user must still learn *why* codex got
42+
// nothing, rather than being told to go and install it.
43+
const plan = planSkillInstall(SPEC, { installedSet: new Set() });
44+
const results = byKey(await runSkillInstall(plan, { run: async () => ({ ok: true, code: 0 }) }));
45+
for (const key of NO_PRIMITIVE) {
46+
assert.equal(results[key]?.status, "skipped", `${key} reported as ${results[key]?.status}`);
47+
}
48+
});
49+
50+
test("an installed engine without a primitive is skipped, never spawned", async () => {
51+
const spawned = [];
52+
const plan = planSkillInstall(SPEC, { installedSet: new Set(Object.keys(ENGINES)) });
53+
const results = byKey(await runSkillInstall(plan, {
54+
run: async (cmd, args) => { spawned.push([cmd, ...args]); return { ok: true, code: 0 }; },
55+
}));
56+
57+
for (const key of NO_PRIMITIVE) assert.equal(results[key]?.status, "skipped");
58+
assert.equal(spawned.length, SKILL_ENGINES.length);
59+
for (const [cmd] of spawned) assert.ok(["git", "gemini"].includes(cmd), `spawned ${cmd}`);
60+
});
61+
62+
test("privacycode — an engine added after SKILL_ENGINES was written — is reported", () => {
63+
// The exact failure the /skill list matrix comment warns about: a hardcoded
64+
// list silently drops any engine added later.
65+
const plan = byKey(planSkillInstall(SPEC, { installedSet: new Set() }));
66+
assert.equal(plan.privacycode?.skip, "no skills primitive");
67+
});
68+
69+
test("the fan-out and the /skill list matrix name the same engines", async () => {
70+
const results = await runSkillInstall(
71+
planSkillInstall(SPEC, { installedSet: new Set() }),
72+
{ run: async () => ({ ok: true, code: 0 }) },
73+
);
74+
assert.deepEqual(results.map((r) => r.key).sort(), Object.keys(ENGINES).sort());
75+
});
76+
77+
test("every planned engine carries a real bin, so the summary can name it", () => {
78+
for (const item of planSkillInstall(SPEC, { installedSet: new Set() })) {
79+
assert.equal(item.bin, ENGINES[item.key].bin);
80+
}
81+
});
82+
83+
// --- controls: the fix must not buy green by over-reporting -------------------
84+
85+
test("the engines with a primitive still come first, in SKILL_ENGINES order", () => {
86+
const keys = planSkillInstall(SPEC, { installedSet: new Set() }).map((p) => p.key);
87+
assert.deepEqual(keys.slice(0, SKILL_ENGINES.length), SKILL_ENGINES);
88+
});
89+
90+
test("SKILL_ENGINES is unchanged: no engine gained a primitive", () => {
91+
assert.deepEqual(SKILL_ENGINES, ["claude", "gemini"]);
92+
});
93+
94+
test("claude still clones the source into its skills dir, byte for byte", () => {
95+
const claude = byKey(planSkillInstall(SPEC, { installedSet: new Set(["claude"]) })).claude;
96+
assert.equal(claude.skip, undefined);
97+
assert.equal(claude.cmd, "git");
98+
assert.deepEqual(claude.args, [
99+
"clone", "--depth", "1", SPEC.source, path.join(claudeSkillsDir(), "some-skill"),
100+
]);
101+
});
102+
103+
test("gemini still installs natively, byte for byte", () => {
104+
const gemini = byKey(planSkillInstall(SPEC, { installedSet: new Set(["gemini"]) })).gemini;
105+
assert.equal(gemini.skip, undefined);
106+
assert.equal(gemini.cmd, "gemini");
107+
assert.deepEqual(gemini.args, ["skills", "install", SPEC.source, "--scope", "user"]);
108+
});
109+
110+
test("installed / not-installed still decides the outcome for a real target", async () => {
111+
const plan = planSkillInstall(SPEC, { installedSet: new Set(["gemini"]) });
112+
const results = byKey(await runSkillInstall(plan, { run: async () => ({ ok: true, code: 0 }) }));
113+
assert.equal(results.gemini.status, "installed");
114+
assert.equal(results.claude.status, "not-installed");
115+
});
116+
117+
test("a real install killed by a signal still reports failed", async () => {
118+
const plan = planSkillInstall(SPEC, { installedSet: new Set(["gemini"]) });
119+
const results = byKey(await runSkillInstall(plan, {
120+
run: async () => ({ ok: true, code: null, signal: "SIGKILL" }),
121+
}));
122+
assert.equal(results.gemini.status, "failed");
123+
assert.equal(results.gemini.signal, "SIGKILL");
124+
});
125+
126+
test("a failing exit code is still a failure, not a skip", async () => {
127+
const plan = planSkillInstall(SPEC, { installedSet: new Set(["gemini"]) });
128+
const results = byKey(await runSkillInstall(plan, {
129+
run: async () => ({ ok: true, code: 128, signal: null }),
130+
}));
131+
assert.equal(results.gemini.status, "failed");
132+
assert.equal(results.gemini.code, 128);
133+
});
134+
135+
test("no engine is planned twice", () => {
136+
const keys = planSkillInstall(SPEC, { installedSet: new Set() }).map((p) => p.key);
137+
assert.equal(new Set(keys).size, keys.length);
138+
});

0 commit comments

Comments
 (0)