skillrepo 4.5.0 → 4.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/add.mjs +17 -2
- package/src/commands/get.mjs +17 -1
- package/src/commands/list.mjs +28 -9
- package/src/commands/remove.mjs +13 -0
- package/src/lib/agent-registry.mjs +15 -1
- package/src/lib/cli-config.mjs +32 -5
- package/src/lib/detect-agents.mjs +42 -5
- package/src/lib/sync.mjs +253 -3
- package/src/test/e2e/mock-server.mjs +92 -2
- package/src/test/integration/update-list-contract.integration.test.mjs +1565 -0
- package/src/test/lib/cli-config.test.mjs +57 -6
- package/src/test/lib/detect-agents.test.mjs +37 -4
- package/src/test/lib/sync.test.mjs +220 -0
|
@@ -507,14 +507,65 @@ describe("resolveFlags — positional callback", () => {
|
|
|
507
507
|
// ── effectiveVendors ───────────────────────────────────────────────────
|
|
508
508
|
|
|
509
509
|
describe("effectiveVendors", () => {
|
|
510
|
-
|
|
511
|
-
|
|
510
|
+
// Inject empty detection so the default-fallback branch fires
|
|
511
|
+
// deterministically regardless of the test runner's environment.
|
|
512
|
+
// The default callable reads `detectAgents()` from the live process —
|
|
513
|
+
// useful in production but non-deterministic across dev machines.
|
|
514
|
+
const noneDetected = { detect: () => [] };
|
|
515
|
+
const detectsClaudeCursor = { detect: () => ["claudeCode", "cursor"] };
|
|
516
|
+
|
|
517
|
+
it("returns ['claudeCode'] fallback when no vendors detected", () => {
|
|
518
|
+
assert.deepEqual(
|
|
519
|
+
effectiveVendors({ vendors: null, global: false }, noneDetected),
|
|
520
|
+
["claudeCode"],
|
|
521
|
+
);
|
|
512
522
|
});
|
|
513
523
|
|
|
514
|
-
it("returns ['claudeCode'] for bare --global with
|
|
515
|
-
// Bare --global preserves the historical
|
|
516
|
-
//
|
|
517
|
-
assert.deepEqual(
|
|
524
|
+
it("returns ['claudeCode'] fallback for bare --global with nothing detected", () => {
|
|
525
|
+
// Bare --global with nothing detected preserves the historical
|
|
526
|
+
// single-vendor default. The user can override with `--global --agent X`.
|
|
527
|
+
assert.deepEqual(
|
|
528
|
+
effectiveVendors({ vendors: null, global: true }, noneDetected),
|
|
529
|
+
["claudeCode"],
|
|
530
|
+
);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("returns all detected vendors when --agent is not passed (4.5.1+)", () => {
|
|
534
|
+
// The 4.5.1 change: no --agent defaults to every detected vendor
|
|
535
|
+
// instead of just ["claudeCode"]. Matches `list`'s drift detection
|
|
536
|
+
// and `init`'s picker.
|
|
537
|
+
assert.deepEqual(
|
|
538
|
+
effectiveVendors({ vendors: null, global: false }, detectsClaudeCursor),
|
|
539
|
+
["claudeCode", "cursor"],
|
|
540
|
+
);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("returns a 3-vendor detected list verbatim (proves no hardcoded pair)", () => {
|
|
544
|
+
// QA H1 guard: the 2-vendor test alone can't distinguish a
|
|
545
|
+
// passthrough from a hardcoded `["claudeCode", "cursor"]`. A
|
|
546
|
+
// future regression that "helpfully" filtered or sliced the
|
|
547
|
+
// detected list would pass the 2-vendor case but fail here.
|
|
548
|
+
const detectsThree = {
|
|
549
|
+
detect: () => ["claudeCode", "cursor", "windsurf"],
|
|
550
|
+
};
|
|
551
|
+
assert.deepEqual(
|
|
552
|
+
effectiveVendors({ vendors: null, global: false }, detectsThree),
|
|
553
|
+
["claudeCode", "cursor", "windsurf"],
|
|
554
|
+
);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("preserves detection order verbatim (no reordering, no dedup beyond what detect returns)", () => {
|
|
558
|
+
// Lock the contract that `effectiveVendors` is a pure passthrough
|
|
559
|
+
// of the detect callable. The order of placement writes depends
|
|
560
|
+
// on this in `placementTargetsFor` — a reorder could change which
|
|
561
|
+
// target appears first in user-visible "wrote to X" output.
|
|
562
|
+
const reverseOrder = {
|
|
563
|
+
detect: () => ["windsurf", "cursor", "claudeCode"],
|
|
564
|
+
};
|
|
565
|
+
assert.deepEqual(
|
|
566
|
+
effectiveVendors({ vendors: null, global: false }, reverseOrder),
|
|
567
|
+
["windsurf", "cursor", "claudeCode"],
|
|
568
|
+
);
|
|
518
569
|
});
|
|
519
570
|
|
|
520
571
|
it("preserves --agent under --global (does NOT discard the vendor list)", () => {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
15
15
|
import assert from "node:assert/strict";
|
|
16
|
-
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
|
16
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { tmpdir } from "node:os";
|
|
19
19
|
|
|
@@ -168,11 +168,37 @@ describe("detectAgents — home signal", () => {
|
|
|
168
168
|
beforeEach(setup);
|
|
169
169
|
afterEach(teardown);
|
|
170
170
|
|
|
171
|
-
it("detects Claude Code via ~/.claude/
|
|
171
|
+
it("detects Claude Code via ~/.claude/settings.json (file, not bare dir)", () => {
|
|
172
|
+
// Claude Code's home signal probes the settings.json file
|
|
173
|
+
// Claude Code itself creates on first run, NOT the bare `.claude/`
|
|
174
|
+
// directory. The earlier broad-directory signal false-positive-
|
|
175
|
+
// detected on Cursor-only users because SkillRepo creates
|
|
176
|
+
// `~/.claude/skillrepo/` for its own state — which forced
|
|
177
|
+
// `~/.claude/` to exist on every machine ever touched by the CLI.
|
|
178
|
+
// See PR #1574 production-readiness audit + the comment in
|
|
179
|
+
// agent-registry.mjs above the claudeCode entry.
|
|
180
|
+
const settingsPath = join(process.env.HOME, ".claude", "settings.json");
|
|
172
181
|
mkdirSync(join(process.env.HOME, ".claude"), { recursive: true });
|
|
182
|
+
writeFileSync(settingsPath, "{}", "utf-8");
|
|
173
183
|
const result = findResult(detectAgents(), "claudeCode");
|
|
174
184
|
assert.equal(result.detected, true);
|
|
175
|
-
assert.equal(result.reason, "~/.claude/");
|
|
185
|
+
assert.equal(result.reason, "~/.claude/settings.json");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("does NOT detect Claude Code on bare ~/.claude/ directory (SkillRepo state-dir poisoning guard)", () => {
|
|
189
|
+
// Regression guard for the 4.5.x false-positive bug. The CLI
|
|
190
|
+
// writes its own state to `~/.claude/skillrepo/config.json` and
|
|
191
|
+
// `~/.claude/skillrepo/.last-sync`, creating `~/.claude/` as a
|
|
192
|
+
// side effect. Without the narrowed signal, every CLI user — even
|
|
193
|
+
// those who don't use Claude Code — would be detected as a Claude
|
|
194
|
+
// Code user from their second invocation onward, producing all-
|
|
195
|
+
// MISS output in `list`. This test holds the line.
|
|
196
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), {
|
|
197
|
+
recursive: true,
|
|
198
|
+
});
|
|
199
|
+
const result = findResult(detectAgents(), "claudeCode");
|
|
200
|
+
assert.equal(result.detected, false);
|
|
201
|
+
assert.equal(result.reason, null);
|
|
176
202
|
});
|
|
177
203
|
|
|
178
204
|
it("detects Cursor via ~/.cursor/ directory", () => {
|
|
@@ -294,10 +320,17 @@ describe("detectAgents — OR semantics + priority", () => {
|
|
|
294
320
|
|
|
295
321
|
it("home signal wins over project signal when env is absent", () => {
|
|
296
322
|
mkdirSync(join(process.env.HOME, ".claude"), { recursive: true });
|
|
323
|
+
writeFileSync(
|
|
324
|
+
join(process.env.HOME, ".claude", "settings.json"),
|
|
325
|
+
"{}",
|
|
326
|
+
"utf-8",
|
|
327
|
+
);
|
|
297
328
|
mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
|
|
298
329
|
const result = findResult(detectAgents(), "claudeCode");
|
|
299
330
|
assert.equal(result.detected, true);
|
|
300
|
-
|
|
331
|
+
// settings.json is a file → reason has no trailing slash (file vs dir
|
|
332
|
+
// distinguished by statSync in formatHomeReason).
|
|
333
|
+
assert.equal(result.reason, "~/.claude/settings.json");
|
|
301
334
|
});
|
|
302
335
|
|
|
303
336
|
it("primary env signal wins over fallback env signal (Cursor: CURSOR_AGENT > CURSOR_CLI)", () => {
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
runSync,
|
|
39
39
|
readLastSync,
|
|
40
40
|
writeLastSync,
|
|
41
|
+
placementsAreComplete,
|
|
41
42
|
LAST_SYNC_SCHEMA_VERSION,
|
|
42
43
|
} from "../../lib/sync.mjs";
|
|
43
44
|
import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
@@ -780,6 +781,48 @@ describe("runSync — ETag round-trip", () => {
|
|
|
780
781
|
const after = readFileSync(join(dir, "SKILL.md"), "utf-8");
|
|
781
782
|
assert.equal(before, after);
|
|
782
783
|
});
|
|
784
|
+
|
|
785
|
+
it("forced re-fetch (placementsAreComplete=false) sets fullSync=false (prior state existed)", async () => {
|
|
786
|
+
// Locks in the semantic contract from QA Round 2 Gap 5: a forced
|
|
787
|
+
// full re-fetch (placementsAreComplete returns false because a
|
|
788
|
+
// placement was missing) does NOT bump fullSync to true. fullSync
|
|
789
|
+
// is determined by whether prior `syncedAt` existed, not by
|
|
790
|
+
// whether the server returned a delta or full payload. The
|
|
791
|
+
// distinction matters to init.mjs which interprets the
|
|
792
|
+
// `fullSync` × `counters-all-zero` combination — for an `init`
|
|
793
|
+
// that hits a placement-incomplete state, counters won't be all
|
|
794
|
+
// zero (the missing skill produces added>=1), so the consumer
|
|
795
|
+
// distinction is preserved. This test pins that behavior.
|
|
796
|
+
server.setEtag('"v1"');
|
|
797
|
+
server.setLibraryResponse({
|
|
798
|
+
skills: [makeSkill("forced")],
|
|
799
|
+
removals: [],
|
|
800
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
801
|
+
});
|
|
802
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
803
|
+
|
|
804
|
+
// Delete the placement on disk — forces re-fetch on next runSync.
|
|
805
|
+
rmSync(resolvePlacementDir("claudeProject", "forced"), {
|
|
806
|
+
recursive: true,
|
|
807
|
+
force: true,
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
const result = await runSync({
|
|
811
|
+
serverUrl,
|
|
812
|
+
apiKey: VALID_KEY,
|
|
813
|
+
vendors: ["claudeCode"],
|
|
814
|
+
});
|
|
815
|
+
// Prior syncedAt was set → fullSync stays false even though a
|
|
816
|
+
// full re-fetch happened. This preserves the init.mjs consumer
|
|
817
|
+
// semantic: fullSync=true means "no prior sync state existed",
|
|
818
|
+
// not "this sync returned a full library payload."
|
|
819
|
+
assert.equal(result.fullSync, false);
|
|
820
|
+
// The forced re-fetch DOES produce a non-zero write count —
|
|
821
|
+
// which is why the behavioral consequence of the semantic
|
|
822
|
+
// mismatch is unreachable. Documents that fact.
|
|
823
|
+
assert.equal(result.added, 1, "forced re-fetch must restore the missing skill");
|
|
824
|
+
assert.equal(result.notModified, false);
|
|
825
|
+
});
|
|
783
826
|
});
|
|
784
827
|
|
|
785
828
|
// ── runSync — argument validation ──────────────────────────────────────
|
|
@@ -1093,3 +1136,180 @@ describe("runSync — cohort placement classification", () => {
|
|
|
1093
1136
|
);
|
|
1094
1137
|
});
|
|
1095
1138
|
});
|
|
1139
|
+
|
|
1140
|
+
// ───────────────────────────────────────────────────────────────────
|
|
1141
|
+
// placementsAreComplete unit tests (PR #1575 hotfix)
|
|
1142
|
+
// ───────────────────────────────────────────────────────────────────
|
|
1143
|
+
//
|
|
1144
|
+
// Direct unit coverage for the function that decides whether the ETag
|
|
1145
|
+
// short-circuit is safe. The integration tests in
|
|
1146
|
+
// update-list-contract.integration.test.mjs exercise the function
|
|
1147
|
+
// end-to-end through runSync; this suite covers the branches:
|
|
1148
|
+
//
|
|
1149
|
+
// - empty/missing skills map (returns false — force re-fetch)
|
|
1150
|
+
// - empty/missing vendors (returns true — nothing to verify)
|
|
1151
|
+
// - placementTargetsFor throws (returns false — safe direction)
|
|
1152
|
+
// - malformed skill keys (skip without crash)
|
|
1153
|
+
// - skill present in EVERY target (returns true)
|
|
1154
|
+
// - skill missing in ONE target (returns false)
|
|
1155
|
+
// - directory exists but SKILL.md missing (returns false)
|
|
1156
|
+
|
|
1157
|
+
describe("placementsAreComplete — direct unit coverage", () => {
|
|
1158
|
+
beforeEach(() => {
|
|
1159
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-pac-"));
|
|
1160
|
+
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
1161
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
1162
|
+
originalCwd = process.cwd();
|
|
1163
|
+
originalHomeEnv = captureHome();
|
|
1164
|
+
process.chdir(join(sandbox, "project"));
|
|
1165
|
+
setSandboxHome(join(sandbox, "home"));
|
|
1166
|
+
});
|
|
1167
|
+
afterEach(() => {
|
|
1168
|
+
process.chdir(originalCwd);
|
|
1169
|
+
restoreHome(originalHomeEnv);
|
|
1170
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
function seedSkillMd(target, skillName) {
|
|
1174
|
+
const dir = resolvePlacementDir(target, skillName);
|
|
1175
|
+
mkdirSync(dir, { recursive: true });
|
|
1176
|
+
writeFileSync(
|
|
1177
|
+
join(dir, "SKILL.md"),
|
|
1178
|
+
`---\nname: ${skillName}\ndescription: x\n---\n`,
|
|
1179
|
+
"utf-8",
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
it("returns false on null skillsMap (can't trust empty baseline)", () => {
|
|
1184
|
+
assert.equal(placementsAreComplete(null, ["claudeCode"], false), false);
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
it("returns false on undefined skillsMap", () => {
|
|
1188
|
+
assert.equal(placementsAreComplete(undefined, ["claudeCode"], false), false);
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
it("returns false on empty skillsMap object (v1 → v2 recovery path)", () => {
|
|
1192
|
+
assert.equal(placementsAreComplete({}, ["claudeCode"], false), false);
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
it("returns true on empty vendors array (no placements to verify)", () => {
|
|
1196
|
+
// Defensive guard — `requireVendorTargets` in the command layer
|
|
1197
|
+
// catches this case before runSync, but if it ever reached here
|
|
1198
|
+
// we'd return true (no targets means trivially complete).
|
|
1199
|
+
const skills = {
|
|
1200
|
+
"alice/skill": {
|
|
1201
|
+
version: "1.0.0",
|
|
1202
|
+
skillMdSha256: "a".repeat(64),
|
|
1203
|
+
filesSha256: "b".repeat(64),
|
|
1204
|
+
syncedAt: "x",
|
|
1205
|
+
},
|
|
1206
|
+
};
|
|
1207
|
+
assert.equal(placementsAreComplete(skills, [], false), true);
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
it("returns true on undefined vendors (same defensive path)", () => {
|
|
1211
|
+
const skills = {
|
|
1212
|
+
"alice/skill": {
|
|
1213
|
+
version: "1.0.0",
|
|
1214
|
+
skillMdSha256: "a".repeat(64),
|
|
1215
|
+
filesSha256: "b".repeat(64),
|
|
1216
|
+
syncedAt: "x",
|
|
1217
|
+
},
|
|
1218
|
+
};
|
|
1219
|
+
assert.equal(placementsAreComplete(skills, undefined, false), true);
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
it("returns true when every (skill, target) pair has its SKILL.md on disk", () => {
|
|
1223
|
+
seedSkillMd("claudeProject", "skill-a");
|
|
1224
|
+
const skills = {
|
|
1225
|
+
"alice/skill-a": {
|
|
1226
|
+
version: "1.0.0",
|
|
1227
|
+
skillMdSha256: "a".repeat(64),
|
|
1228
|
+
filesSha256: "b".repeat(64),
|
|
1229
|
+
syncedAt: "x",
|
|
1230
|
+
},
|
|
1231
|
+
};
|
|
1232
|
+
assert.equal(placementsAreComplete(skills, ["claudeCode"], false), true);
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
it("returns false when ONE skill's SKILL.md is missing in ONE target", () => {
|
|
1236
|
+
// Two skills, claudeProject + agentsProject expected. Only one
|
|
1237
|
+
// skill landed in claudeProject; agentsProject has neither.
|
|
1238
|
+
seedSkillMd("claudeProject", "skill-a");
|
|
1239
|
+
const skills = {
|
|
1240
|
+
"alice/skill-a": {
|
|
1241
|
+
version: "1.0.0",
|
|
1242
|
+
skillMdSha256: "a".repeat(64),
|
|
1243
|
+
filesSha256: "b".repeat(64),
|
|
1244
|
+
syncedAt: "x",
|
|
1245
|
+
},
|
|
1246
|
+
};
|
|
1247
|
+
// Both claudeCode and cursor → both targets must have skill-a.
|
|
1248
|
+
// Only claudeProject does → returns false → forces re-fetch.
|
|
1249
|
+
assert.equal(
|
|
1250
|
+
placementsAreComplete(skills, ["claudeCode", "cursor"], false),
|
|
1251
|
+
false,
|
|
1252
|
+
);
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
it("returns false when directory exists but SKILL.md is missing (partial dir guard)", () => {
|
|
1256
|
+
// The HIGH finding from the code-reviewer audit: an empty
|
|
1257
|
+
// directory satisfies existsSync but lacks the load-bearing
|
|
1258
|
+
// SKILL.md. The check probes SKILL.md specifically.
|
|
1259
|
+
const dir = resolvePlacementDir("claudeProject", "partial");
|
|
1260
|
+
mkdirSync(dir, { recursive: true });
|
|
1261
|
+
// NOTE: no writeFileSync — dir exists but is empty.
|
|
1262
|
+
const skills = {
|
|
1263
|
+
"alice/partial": {
|
|
1264
|
+
version: "1.0.0",
|
|
1265
|
+
skillMdSha256: "a".repeat(64),
|
|
1266
|
+
filesSha256: "b".repeat(64),
|
|
1267
|
+
syncedAt: "x",
|
|
1268
|
+
},
|
|
1269
|
+
};
|
|
1270
|
+
assert.equal(placementsAreComplete(skills, ["claudeCode"], false), false);
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
it("returns false when placementTargetsFor throws (conservative direction)", () => {
|
|
1274
|
+
// copilot has globalTarget=null. `--global` with copilot throws
|
|
1275
|
+
// inside placementTargetsFor. placementsAreComplete catches the
|
|
1276
|
+
// throw and returns false so we drop the ETag and the
|
|
1277
|
+
// downstream write loop surfaces the same error with the
|
|
1278
|
+
// correct typed exception.
|
|
1279
|
+
const skills = {
|
|
1280
|
+
"alice/skill": {
|
|
1281
|
+
version: "1.0.0",
|
|
1282
|
+
skillMdSha256: "a".repeat(64),
|
|
1283
|
+
filesSha256: "b".repeat(64),
|
|
1284
|
+
syncedAt: "x",
|
|
1285
|
+
},
|
|
1286
|
+
};
|
|
1287
|
+
assert.equal(
|
|
1288
|
+
placementsAreComplete(skills, ["copilot"], true),
|
|
1289
|
+
false,
|
|
1290
|
+
);
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
it("skips malformed skill keys (no slash) without crashing", () => {
|
|
1294
|
+
seedSkillMd("claudeProject", "good");
|
|
1295
|
+
const skills = {
|
|
1296
|
+
"alice/good": {
|
|
1297
|
+
version: "1.0.0",
|
|
1298
|
+
skillMdSha256: "a".repeat(64),
|
|
1299
|
+
filesSha256: "b".repeat(64),
|
|
1300
|
+
syncedAt: "x",
|
|
1301
|
+
},
|
|
1302
|
+
// Malformed key — no slash. Should be silently skipped so a
|
|
1303
|
+
// corrupt entry doesn't poison the whole check.
|
|
1304
|
+
"no-slash-key": {
|
|
1305
|
+
version: "1.0.0",
|
|
1306
|
+
skillMdSha256: "a".repeat(64),
|
|
1307
|
+
filesSha256: "b".repeat(64),
|
|
1308
|
+
syncedAt: "x",
|
|
1309
|
+
},
|
|
1310
|
+
};
|
|
1311
|
+
// Returns true because the valid skill is on disk and the
|
|
1312
|
+
// malformed entry is skipped (continue) — no false negative.
|
|
1313
|
+
assert.equal(placementsAreComplete(skills, ["claudeCode"], false), true);
|
|
1314
|
+
});
|
|
1315
|
+
});
|