skillrepo 4.8.0 → 4.8.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.
Files changed (43) hide show
  1. package/bin/skillrepo.mjs +6 -3
  2. package/package.json +1 -1
  3. package/src/commands/add.mjs +2 -1
  4. package/src/commands/init.mjs +15 -5
  5. package/src/lib/http.mjs +25 -11
  6. package/src/test/commands/add.test.mjs +79 -2
  7. package/src/test/commands/get.test.mjs +131 -2
  8. package/src/test/commands/init-session-sync.test.mjs +724 -0
  9. package/src/test/commands/init.test.mjs +159 -2
  10. package/src/test/commands/list.test.mjs +573 -1
  11. package/src/test/commands/publish.test.mjs +136 -3
  12. package/src/test/commands/push.test.mjs +280 -1
  13. package/src/test/commands/remove.test.mjs +221 -2
  14. package/src/test/commands/search.test.mjs +203 -1
  15. package/src/test/commands/session-sync.test.mjs +227 -1
  16. package/src/test/commands/uninstall.test.mjs +216 -0
  17. package/src/test/commands/update.test.mjs +218 -0
  18. package/src/test/dispatcher.test.mjs +103 -2
  19. package/src/test/e2e/advertised-surface.test.mjs +207 -0
  20. package/src/test/e2e/cli-commands.test.mjs +1 -1
  21. package/src/test/e2e/uninstall-interactive.test.mjs +93 -0
  22. package/src/test/e2e/update-check-suppression.test.mjs +135 -0
  23. package/src/test/integration/update-list-contract.integration.test.mjs +66 -0
  24. package/src/test/lib/browser-open.test.mjs +43 -0
  25. package/src/test/lib/config.test.mjs +87 -0
  26. package/src/test/lib/crypto-shas.test.mjs +17 -0
  27. package/src/test/lib/file-write.test.mjs +244 -0
  28. package/src/test/lib/fs-utils.test.mjs +259 -0
  29. package/src/test/lib/global-install.test.mjs +134 -0
  30. package/src/test/lib/http-timeout.test.mjs +114 -0
  31. package/src/test/lib/http.test.mjs +616 -1
  32. package/src/test/lib/mcp-merge.test.mjs +157 -0
  33. package/src/test/lib/npm-update-check.test.mjs +180 -0
  34. package/src/test/lib/placement-walk.test.mjs +132 -0
  35. package/src/test/lib/skill-walk.test.mjs +39 -1
  36. package/src/test/lib/sync.test.mjs +139 -5
  37. package/src/test/lib/telemetry.test.mjs +34 -0
  38. package/src/test/mergers/claude-mcp.test.mjs +30 -0
  39. package/src/test/mergers/cursor-mcp.test.mjs +115 -0
  40. package/src/test/mergers/env-local.test.mjs +126 -0
  41. package/src/test/mergers/vscode-mcp.test.mjs +177 -0
  42. package/src/test/mergers/windsurf-mcp.test.mjs +144 -0
  43. package/src/test/resolve-key.test.mjs +33 -0
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `skillrepo uninstall` interactive DECLINE path.
3
+ *
4
+ * uninstall is destructive (it deletes skill placements + config from the
5
+ * project). When run without --yes/--json it prompts "Proceed? (y/N)" and
6
+ * a "no" answer MUST remove nothing. The whole in-process suite uses
7
+ * --yes/--json/--dry-run and never drives this prompt — so the abort path
8
+ * (the single most safety-critical branch in the command) had zero
9
+ * coverage. confirm() reads process.stdin via readline (no TTY required),
10
+ * so we spawn the real binary and pipe "n" to stdin.
11
+ */
12
+
13
+ import { describe, it, beforeEach, afterEach } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { spawnSync } from "node:child_process";
16
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
17
+ import { join, dirname, resolve } from "node:path";
18
+ import { tmpdir } from "node:os";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+ // This file lives at src/test/e2e/ — three levels below packages/cli/.
23
+ const CLI_BIN = resolve(__dirname, "../../../bin/skillrepo.mjs");
24
+
25
+ let sandbox;
26
+ let home;
27
+ let project;
28
+ let skillDir;
29
+
30
+ function setup() {
31
+ sandbox = mkdtempSync(join(tmpdir(), "cli-uninstall-decline-"));
32
+ home = join(sandbox, "home");
33
+ project = join(sandbox, "project");
34
+ mkdirSync(home, { recursive: true });
35
+ // Seed a SkillRepo artifact so uninstall has something to remove and
36
+ // therefore reaches the confirmation prompt (it short-circuits with
37
+ // "nothing to remove" otherwise).
38
+ skillDir = join(project, ".claude", "skills", "dummy");
39
+ mkdirSync(skillDir, { recursive: true });
40
+ writeFileSync(join(skillDir, "SKILL.md"), "---\nname: dummy\ndescription: d\n---\nbody\n");
41
+ }
42
+
43
+ function teardown() {
44
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
45
+ }
46
+
47
+ function runUninstallWithInput(input) {
48
+ return spawnSync(process.execPath, [CLI_BIN, "uninstall", "--key", "sk_live_dummy"], {
49
+ cwd: project,
50
+ encoding: "utf-8",
51
+ input,
52
+ timeout: 10_000,
53
+ env: {
54
+ ...process.env,
55
+ HOME: home,
56
+ USERPROFILE: home,
57
+ NO_COLOR: "1",
58
+ CI: "true", // silence the update-check nudge
59
+ SKILLREPO_ACCESS_KEY: "",
60
+ SKILLREPO_TIMEOUT_MS: "2000",
61
+ },
62
+ });
63
+ }
64
+
65
+ describe("uninstall — interactive decline removes nothing", () => {
66
+ beforeEach(setup);
67
+ afterEach(teardown);
68
+
69
+ it("answering 'n' at the prompt cancels and leaves all artifacts intact", () => {
70
+ const r = runUninstallWithInput("n\n");
71
+ const out = (r.stdout ?? "") + (r.stderr ?? "");
72
+ assert.match(out, /Cancelled\. Nothing was removed\./, "decline must print the cancel message");
73
+ assert.ok(
74
+ existsSync(join(skillDir, "SKILL.md")),
75
+ "the skill placement MUST survive a declined uninstall",
76
+ );
77
+ // It must NOT have printed a per-artifact removal confirmation.
78
+ assert.doesNotMatch(out, /has been removed from this project/);
79
+ });
80
+
81
+ it("answering 'y' at the prompt proceeds with removal (control — proves the prompt is live)", () => {
82
+ // Without this control, the decline test could pass simply because
83
+ // the prompt never fired. A "y" answer must actually remove the
84
+ // artifact, proving the prompt gates a real deletion.
85
+ const r = runUninstallWithInput("y\n");
86
+ const out = (r.stdout ?? "") + (r.stderr ?? "");
87
+ assert.doesNotMatch(out, /Cancelled/);
88
+ assert.ok(
89
+ !existsSync(join(skillDir, "SKILL.md")),
90
+ "accepting the prompt must remove the skill placement",
91
+ );
92
+ });
93
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Dispatcher npm update-check: nudge emission + --json suppression (#1554).
3
+ *
4
+ * The closest analog to the #1911 class of "command reports success while
5
+ * silently doing the wrong thing": the dispatcher gates the post-command
6
+ * upgrade nudge on `if (!rest.includes("--json"))`. If that guard ever
7
+ * regressed, every `--json` invocation would leak human text into a
8
+ * structured stream — invisible to the per-command tests (which call
9
+ * runX() directly and never reach the dispatcher's post-command hook) and
10
+ * to npm-update-check.test.mjs (which tests the module, not the binary).
11
+ *
12
+ * This spawns the REAL binary and arms the update-check cache so a nudge
13
+ * fires deterministically WITHOUT a network call (a cache hit resolves
14
+ * synchronously, before the dispatcher's Promise.race timer). Then it
15
+ * proves: nudge present on a normal command, fully absent under --json.
16
+ */
17
+
18
+ import { describe, it, beforeEach, afterEach } from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { execFile } from "node:child_process";
21
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
22
+ import { join, dirname, resolve } from "node:path";
23
+ import { tmpdir } from "node:os";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ import { createMockServer } from "./mock-server.mjs";
27
+
28
+ const __dirname = dirname(fileURLToPath(import.meta.url));
29
+ // This file lives at src/test/e2e/ — three levels below packages/cli/.
30
+ const CLI_BIN = resolve(__dirname, "../../../bin/skillrepo.mjs");
31
+ const PKG = JSON.parse(readFileSync(resolve(__dirname, "../../../package.json"), "utf-8"));
32
+ const CURRENT_VERSION = PKG.version;
33
+ const NUDGE = /A newer .*skillrepo.* is available/;
34
+
35
+ let sandbox;
36
+ let home;
37
+ let server;
38
+ let serverUrl;
39
+
40
+ /**
41
+ * Write a fresh positive cache entry advertising a much higher published
42
+ * version. currentCliVersion MUST equal the live version or isCacheFresh
43
+ * discards the entry and falls back to a (here, unroutable) network fetch.
44
+ */
45
+ function armCacheNudge() {
46
+ const dir = join(home, ".claude", "skillrepo");
47
+ mkdirSync(dir, { recursive: true });
48
+ writeFileSync(
49
+ join(dir, ".npm-version-check"),
50
+ JSON.stringify({
51
+ schemaVersion: 1,
52
+ checkedAt: new Date().toISOString(),
53
+ currentCliVersion: CURRENT_VERSION,
54
+ latestPublishedVersion: "999.0.0",
55
+ fetchOk: true,
56
+ }),
57
+ );
58
+ }
59
+
60
+ async function setup() {
61
+ sandbox = mkdtempSync(join(tmpdir(), "cli-update-nudge-"));
62
+ home = join(sandbox, "home");
63
+ mkdirSync(home, { recursive: true });
64
+ server = createMockServer({});
65
+ const port = await server.start();
66
+ serverUrl = `http://127.0.0.1:${port}`;
67
+ server.setEtag('"v1"');
68
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "2025-01-01T00:00:00Z" });
69
+ }
70
+
71
+ async function teardown() {
72
+ if (server) await server.stop();
73
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
74
+ server = null;
75
+ }
76
+
77
+ function runCli(args, extraEnv = {}) {
78
+ return new Promise((res) => {
79
+ execFile(
80
+ process.execPath,
81
+ [CLI_BIN, ...args],
82
+ {
83
+ encoding: "utf-8",
84
+ timeout: 10_000,
85
+ env: {
86
+ ...process.env,
87
+ HOME: home,
88
+ USERPROFILE: home,
89
+ NO_COLOR: "1",
90
+ // Force the update-check ON: CI auto-disables it, and the test
91
+ // suite itself often runs under CI=true. Clear both switches.
92
+ CI: "",
93
+ SKILLREPO_NO_UPDATE_CHECK: "",
94
+ SKILLREPO_ACCESS_KEY: "",
95
+ SKILLREPO_TIMEOUT_MS: "2000",
96
+ ...extraEnv,
97
+ },
98
+ },
99
+ (err, stdout, stderr) =>
100
+ res({ stdout: stdout ?? "", stderr: stderr ?? "", status: err ? (err.code ?? 1) : 0 }),
101
+ );
102
+ });
103
+ }
104
+
105
+ describe("dispatcher — update-check nudge + --json suppression (#1554)", () => {
106
+ beforeEach(setup);
107
+ afterEach(teardown);
108
+
109
+ it("emits the upgrade nudge on stderr after a successful non-JSON command", async () => {
110
+ // This is BOTH a real assertion and the control that proves the next
111
+ // test isn't vacuous: if the armed cache didn't produce a nudge here,
112
+ // the suppression test would pass for the wrong reason.
113
+ armCacheNudge();
114
+ const r = await runCli(["list", "--key", "sk_live_x", "--url", serverUrl]);
115
+ assert.equal(r.status, 0, `list should succeed; stderr: ${r.stderr}`);
116
+ assert.match(r.stderr, NUDGE, "armed cache must produce the upgrade nudge on a non-JSON command");
117
+ assert.match(
118
+ r.stderr,
119
+ new RegExp(`${CURRENT_VERSION.replace(/\./g, "\\.")}.*999\\.0\\.0`),
120
+ "nudge shows current → latest version",
121
+ );
122
+ });
123
+
124
+ it("suppresses the nudge entirely under --json (stdout pure JSON, stderr nudge-free)", async () => {
125
+ armCacheNudge();
126
+ const r = await runCli(["list", "--key", "sk_live_x", "--url", serverUrl, "--json"]);
127
+ assert.equal(r.status, 0, `list --json should succeed; stderr: ${r.stderr}`);
128
+ assert.doesNotMatch(r.stderr, NUDGE, "no nudge on stderr under --json");
129
+ assert.doesNotMatch(r.stdout, NUDGE, "no nudge on stdout under --json");
130
+ assert.doesNotThrow(
131
+ () => JSON.parse(r.stdout),
132
+ "stdout under --json must be pure JSON even with an armed update-check cache",
133
+ );
134
+ });
135
+ });
@@ -883,6 +883,72 @@ describe("remove → list cross-command contract", () => {
883
883
  assert.equal(state.etag, '"v1"', "remove must not clobber the library etag");
884
884
  });
885
885
 
886
+ it("remove → re-add round trip: skill lands again and list shows it current (#1911 acceptance)", async () => {
887
+ // #1911 acceptance criterion that had no test: "Remove then re-add an
888
+ // older skill → re-delivered." remove.mjs purges the .last-sync
889
+ // baseline entry specifically so a future re-add doesn't compare
890
+ // against a stale baseline and mis-report drift. Exercise the full
891
+ // round trip and prove the re-added skill is on disk AND reported
892
+ // `current` (not `edited`/`missing` from a leftover baseline).
893
+ process.env.CLAUDECODE = "1";
894
+
895
+ // 1) Seed: sync the skill so it's on disk + tracked in .last-sync.
896
+ server.setEtag('"v1"');
897
+ server.setLibraryResponse({
898
+ skills: [makeSkill("alice", "roundtrip", "1.0.0")],
899
+ removals: [],
900
+ syncedAt: "2026-01-01T00:00:00Z",
901
+ });
902
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
903
+ const dir = resolvePlacementDir("claudeProject", "roundtrip");
904
+ assert.ok(existsSync(join(dir, "SKILL.md")), "precondition: roundtrip on disk after sync");
905
+ assert.ok(readLastSync().skills["alice/roundtrip"], "precondition: tracked in .last-sync");
906
+
907
+ // 2) remove: deletes local files + purges the baseline entry.
908
+ server.setRemoveResponseForAny({
909
+ status: 200,
910
+ body: { removed: { owner: "alice", name: "roundtrip" } },
911
+ });
912
+ await runRemove(["@alice/roundtrip", "--key", VALID_KEY, "--url", serverUrl], { stdout });
913
+ assert.ok(!existsSync(join(dir, "SKILL.md")), "remove deletes the local directory");
914
+ assert.equal(
915
+ readLastSync().skills["alice/roundtrip"],
916
+ undefined,
917
+ "remove purges the .last-sync baseline entry",
918
+ );
919
+
920
+ // 3) re-add the SAME skill.
921
+ server.setAddResponseForAny({
922
+ status: 201,
923
+ body: { added: { owner: "alice", name: "roundtrip", version: "1.0.0", addedAt: "2026-02-01T00:00:00Z" } },
924
+ });
925
+ server.setSkillResponse("alice", "roundtrip", makeSkill("alice", "roundtrip", "1.0.0"));
926
+ stdout = createCaptureStream();
927
+ await runAdd(["@alice/roundtrip", "--key", VALID_KEY, "--url", serverUrl], { stdout });
928
+ assert.ok(
929
+ existsSync(join(dir, "SKILL.md")),
930
+ "re-added skill MUST land back on disk",
931
+ );
932
+
933
+ // 4) list: must report `current`, proving the re-add wrote a fresh
934
+ // baseline rather than colliding with the purged-then-stale one.
935
+ server.setEtag('"v2"');
936
+ server.setLibraryResponse({
937
+ skills: [makeSkill("alice", "roundtrip", "1.0.0")],
938
+ removals: [],
939
+ syncedAt: "2026-02-01T00:00:00Z",
940
+ });
941
+ stdout = createCaptureStream();
942
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
943
+ const [item] = JSON.parse(stdout.text());
944
+ assert.equal(item.name, "roundtrip");
945
+ assert.equal(
946
+ item.state,
947
+ "current",
948
+ "re-added skill must be current — remove must have cleared the stale baseline",
949
+ );
950
+ });
951
+
886
952
  it("removing a skill that was never in .last-sync is idempotent (no throw, no spurious write)", async () => {
887
953
  // Edge case: user runs `skillrepo remove @alice/never-synced`
888
954
  // for a skill that was never on disk and was never tracked. The
@@ -184,4 +184,47 @@ describe("openBrowser", () => {
184
184
  });
185
185
  assert.equal(calls[0].options.shell, undefined);
186
186
  });
187
+
188
+ // ── default-parameter branches (no options object) ────────────────
189
+
190
+ it("falls back to process.platform when `platform` is omitted", () => {
191
+ // Covers the `platform ?? process.platform` default. We inject a spawnFn
192
+ // so no real browser launches; the host CI platform (darwin/linux on the
193
+ // Linux+macOS runners, win32 on the Windows runner) is supported, so the
194
+ // default resolves to a real launch command and the spawn fires.
195
+ const { spawnFn, calls } = makeSpawnStub();
196
+ const result = openBrowser("https://skillrepo.dev/cli/auth", { spawnFn });
197
+ assert.equal(result, true);
198
+ assert.equal(calls.length, 1);
199
+ // Platform-agnostic: darwin → "open", linux → "xdg-open", win32 →
200
+ // "cmd.exe". Asserting a specific command would break on whichever host
201
+ // runs the suite; a non-empty resolved command proves the default fired
202
+ // (an undefined platform would make resolveLaunchCommand return false).
203
+ assert.ok(
204
+ typeof calls[0].command === "string" && calls[0].command.length > 0,
205
+ `the default host platform must resolve to a launch command, got ${calls[0].command}`,
206
+ );
207
+ });
208
+
209
+ it("falls back to the real child_process.spawn when `spawnFn` is omitted", () => {
210
+ // Covers the `spawnFn ?? nodeSpawn` default WITHOUT actually
211
+ // launching anything: an unsupported platform returns false from
212
+ // resolveLaunchCommand BEFORE the spawn call, so the default
213
+ // nodeSpawn is selected (exercising the `??`) but never invoked.
214
+ const result = openBrowser("https://skillrepo.dev/cli/auth", {
215
+ platform: "plan9",
216
+ });
217
+ assert.equal(result, false, "unsupported platform returns false before spawning");
218
+ });
219
+
220
+ it("works with no options object at all (both defaults resolve)", () => {
221
+ // The signature defaults the whole options object to `{}`. Calling
222
+ // with a rejected URL exits at the `isLaunchableUrl` guard before
223
+ // touching the defaults — but calling with a real http URL on the
224
+ // host platform exercises both `?? process.platform` and
225
+ // `?? nodeSpawn`. We can't safely launch a real browser in CI, so we
226
+ // assert the early-return path with an invalid URL instead, which is
227
+ // the safe way to call the zero-arg form deterministically.
228
+ assert.equal(openBrowser("not-a-url"), false);
229
+ });
187
230
  });
@@ -138,6 +138,36 @@ describe("readConfig", () => {
138
138
  writeFileSync(path, "[]");
139
139
  assert.equal(readConfig(), null);
140
140
  });
141
+
142
+ it("returns null when the JSON parses to a non-object primitive (e.g. `null`)", () => {
143
+ // `!parsed || typeof parsed !== "object"` — a file containing the
144
+ // literal `null` parses successfully but is falsy, so readConfig
145
+ // bails on the !parsed branch. The `[]`/`{}` cases above are both
146
+ // `typeof === "object"` and never reach this branch.
147
+ const path = globalConfigPath();
148
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
149
+ writeFileSync(path, "null");
150
+ assert.equal(readConfig(), null);
151
+ });
152
+
153
+ it("returns null when the JSON parses to a number (typeof !== object)", () => {
154
+ const path = globalConfigPath();
155
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
156
+ writeFileSync(path, "42");
157
+ assert.equal(readConfig(), null);
158
+ });
159
+
160
+ it(
161
+ "returns null when readFileSync throws (path is a directory → EISDIR)",
162
+ { skip: process.platform === "win32" },
163
+ () => {
164
+ // existsSync(path) is true (the directory exists) but readFileSync
165
+ // on a directory throws EISDIR → the read try/catch returns null.
166
+ const path = globalConfigPath();
167
+ mkdirSync(path, { recursive: true });
168
+ assert.equal(readConfig(), null);
169
+ },
170
+ );
141
171
  });
142
172
 
143
173
  // ── writeConfig ────────────────────────────────────────────────────────
@@ -275,6 +305,46 @@ describe("writeConfig", () => {
275
305
  chmodSync(grandParent, 0o755);
276
306
  }
277
307
  });
308
+
309
+ it(
310
+ "throws diskError when the temp file can't be written (tmp path is a directory)",
311
+ { skip: process.platform === "win32" },
312
+ () => {
313
+ // The parent dir exists and is writable, but `<path>.tmp` is itself
314
+ // a directory, so writeFileSync(tmpPath) throws EISDIR → the
315
+ // temp-write try/catch surfaces a diskError. This covers the
316
+ // tmp-write failure branch distinct from the parent-dir branch.
317
+ const path = globalConfigPath();
318
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
319
+ mkdirSync(`${path}.tmp`, { recursive: true });
320
+ assert.throws(
321
+ () => writeConfig({ apiKey: "sk_live_abc", serverUrl: "https://example.com" }),
322
+ (err) => err instanceof CliError && err.exitCode === EXIT_DISK,
323
+ );
324
+ },
325
+ );
326
+
327
+ it(
328
+ "throws diskError when the rename fails and still cleans up the temp file",
329
+ { skip: process.platform === "win32" },
330
+ () => {
331
+ // Destination `path` is a NON-EMPTY directory: the temp write +
332
+ // chmod succeed, but renameSync(tmp, path) throws (can't replace a
333
+ // non-empty dir with a file). This exercises the rename try/catch
334
+ // AND the best-effort tmp cleanup (the .tmp file must be removed so
335
+ // a live key isn't left on disk).
336
+ const path = globalConfigPath();
337
+ mkdirSync(path, { recursive: true });
338
+ // Make the destination directory non-empty so rename can't clobber it.
339
+ writeFileSync(join(path, "occupant"), "x");
340
+ assert.throws(
341
+ () => writeConfig({ apiKey: "sk_live_abc", serverUrl: "https://example.com" }),
342
+ (err) => err instanceof CliError && err.exitCode === EXIT_DISK,
343
+ );
344
+ // The temp file must have been cleaned up (no live key left behind).
345
+ assert.ok(!existsSync(`${path}.tmp`), "stale .tmp must be unlinked on rename failure");
346
+ },
347
+ );
278
348
  });
279
349
 
280
350
  // ── clearConfig ────────────────────────────────────────────────────────
@@ -293,4 +363,21 @@ describe("clearConfig", () => {
293
363
  assert.equal(clearConfig(), true);
294
364
  assert.ok(!existsSync(globalConfigPath()));
295
365
  });
366
+
367
+ it(
368
+ "throws diskError when the path can't be unlinked (path is a non-empty directory)",
369
+ { skip: process.platform === "win32" },
370
+ () => {
371
+ // existsSync(path) is true so clearConfig attempts unlinkSync, but
372
+ // unlinking a non-empty directory throws (EISDIR/ENOTEMPTY/EPERM) →
373
+ // the catch surfaces a diskError rather than silently swallowing.
374
+ const path = globalConfigPath();
375
+ mkdirSync(path, { recursive: true });
376
+ writeFileSync(join(path, "occupant"), "x");
377
+ assert.throws(
378
+ () => clearConfig(),
379
+ (err) => err instanceof CliError && err.exitCode === EXIT_DISK,
380
+ );
381
+ },
382
+ );
296
383
  });
@@ -156,6 +156,23 @@ describe("computeSkillShas", () => {
156
156
  assert.equal(result.filesSha256, sha256Utf8(expectedProjection));
157
157
  });
158
158
 
159
+ it("handles equal paths in the sort comparator (returns 0 arm)", () => {
160
+ // The internal byte-order comparator's equal-path arm
161
+ // (`a.path === b.path → 0`) is only reached when two entries share
162
+ // a path. computeSkillShas does not dedupe (that's validateSkill's
163
+ // job upstream), so identical paths are valid input here and must
164
+ // not crash. With two identical entries the projection is the same
165
+ // line twice, joined by "\n".
166
+ const result = computeSkillShas([
167
+ { path: "dup.md", content: "one\n" },
168
+ { path: "dup.md", content: "one\n" },
169
+ ]);
170
+ const shaDup = sha256Utf8("one\n");
171
+ const expectedProjection = `dup.md|${shaDup}\ndup.md|${shaDup}`;
172
+ assert.equal(result.filesSha256, sha256Utf8(expectedProjection));
173
+ assert.equal(result.skillMdSha256, null);
174
+ });
175
+
159
176
  it("throws TypeError on non-array input", () => {
160
177
  assert.throws(() => computeSkillShas(null), TypeError);
161
178
  assert.throws(() => computeSkillShas(undefined), TypeError);