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.
@@ -507,14 +507,65 @@ describe("resolveFlags — positional callback", () => {
507
507
  // ── effectiveVendors ───────────────────────────────────────────────────
508
508
 
509
509
  describe("effectiveVendors", () => {
510
- it("returns ['claudeCode'] by default (no flags)", () => {
511
- assert.deepEqual(effectiveVendors({ vendors: null, global: false }), ["claudeCode"]);
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 no --agent", () => {
515
- // Bare --global preserves the historical default of writing to
516
- // ~/.claude/skills/. The user can override with `--global --agent X`.
517
- assert.deepEqual(effectiveVendors({ vendors: null, global: true }), ["claudeCode"]);
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/ directory", () => {
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
- assert.equal(result.reason, "~/.claude/");
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
+ });