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
@@ -27,7 +27,10 @@ import { join } from "node:path";
27
27
  import { tmpdir } from "node:os";
28
28
 
29
29
  import { runSessionSync } from "../../commands/session-sync.mjs";
30
- import { buildHookCommand } from "../../lib/mergers/session-hook.mjs";
30
+ import {
31
+ buildHookCommand,
32
+ resolveSkillrepoBinary,
33
+ } from "../../lib/mergers/session-hook.mjs";
31
34
  import { SESSION_HOOK_FINGERPRINT } from "../../lib/artifact-registry.mjs";
32
35
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
33
36
  import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
@@ -146,6 +149,43 @@ describe("session-sync enable", () => {
146
149
  assert.ok(hasHook, "SkillRepo hook must be present");
147
150
  });
148
151
 
152
+ it("reports 'updated' when an existing SkillRepo hook has a stale command", async () => {
153
+ // INTENT: a prior SkillRepo hook pointing at a DIFFERENT binary path
154
+ // (e.g. from an older install location) must be upgraded in place —
155
+ // not duplicated, not left stale. This is the 'updated' branch of
156
+ // printEnableResult, which no test exercised (only installed/unchanged
157
+ // were covered).
158
+ ASSERT_HOME_ISOLATED();
159
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
160
+ const settingsPath = join(process.cwd(), ".claude", "settings.local.json");
161
+ const staleCommand = buildHookCommand("/old/location/skillrepo");
162
+ assert.ok(
163
+ staleCommand.includes(SESSION_HOOK_FINGERPRINT),
164
+ "precondition: seeded command is a SkillRepo hook",
165
+ );
166
+ writeFileSync(
167
+ settingsPath,
168
+ JSON.stringify(
169
+ { hooks: { SessionStart: [{ hooks: [{ type: "command", command: staleCommand }] }] } },
170
+ null,
171
+ 2,
172
+ ),
173
+ );
174
+
175
+ await runSessionSync(["enable"], { stdout, stderr });
176
+
177
+ assert.match(stdout.text(), /updated/i, "a stale SkillRepo hook must report 'updated'");
178
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
179
+ const skillrepoHooks = parsed.hooks.SessionStart.flatMap((g) => g.hooks)
180
+ .map((h) => h.command)
181
+ .filter((c) => c.includes(SESSION_HOOK_FINGERPRINT));
182
+ assert.equal(skillrepoHooks.length, 1, "must not duplicate the SkillRepo hook");
183
+ assert.ok(
184
+ !skillrepoHooks.includes(staleCommand),
185
+ "the stale command must be replaced with the current one",
186
+ );
187
+ });
188
+
149
189
  it("is idempotent — second enable returns 'unchanged'", async () => {
150
190
  // INTENT: users re-running `session-sync enable` must get a clear
151
191
  // "nothing to do" response, not a duplicate hook.
@@ -219,16 +259,103 @@ describe("session-sync disable", () => {
219
259
  assert.match(stdout.text(), /removed/i);
220
260
  });
221
261
 
262
+ it("--global removes the hook from the user-wide settings file", async () => {
263
+ // Symmetry with `enable --global`: disabling with --global must strip
264
+ // the hook from ~/.claude/settings.local.json (not the project file).
265
+ // enable --global was tested; disable --global was the gap.
266
+ ASSERT_HOME_ISOLATED();
267
+ await runSessionSync(["enable", "--global"], { stdout, stderr });
268
+ const globalSettings = join(process.env.HOME, ".claude", "settings.local.json");
269
+ assert.ok(existsSync(globalSettings), "precondition: global hook installed");
270
+ stdout.clear();
271
+
272
+ await runSessionSync(["disable", "--global"], { stdout, stderr });
273
+
274
+ const parsed = JSON.parse(readFileSync(globalSettings, "utf-8"));
275
+ const hasHook = (parsed.hooks?.SessionStart ?? [])
276
+ .flatMap((g) => g?.hooks ?? [])
277
+ .some((h) => h?.command?.includes(SESSION_HOOK_FINGERPRINT));
278
+ assert.ok(!hasHook, "SkillRepo hook must be gone from the global file after disable --global");
279
+ assert.match(stdout.text(), /removed/i);
280
+ });
281
+
222
282
  it("reports cleanly when disable runs on a file without the hook", async () => {
223
283
  // INTENT: a user running `session-sync disable` when the hook
224
284
  // isn't installed must get a clear "nothing to do" response, not
225
285
  // an error. This is how automation scripts confirm the final
226
286
  // state is "disabled" regardless of prior state.
287
+ //
288
+ // NOTE: there is NO settings file here, so the remover returns
289
+ // `skipped` (no error) and printDisableResult takes the
290
+ // "no settings file" branch (lines 148-151). The "unchanged"
291
+ // branch (lines 132-136) is covered by the next test, which
292
+ // seeds a file that EXISTS but has no SkillRepo hook.
227
293
  ASSERT_HOME_ISOLATED();
228
294
  await runSessionSync(["disable"], { stdout, stderr });
229
295
  assert.match(stdout.text(), /not enabled|not installed|nothing to do/i);
230
296
  });
231
297
 
298
+ it("reports 'was not installed — nothing to do' when the file exists but has no SkillRepo hook", async () => {
299
+ // INTENT: distinct from the no-file case above. The settings file
300
+ // EXISTS and is valid JSON, but carries only a user-authored hook —
301
+ // no SkillRepo entry. The remover returns `unchanged` (not
302
+ // `skipped`), and printDisableResult must take the "was not
303
+ // installed" branch (lines 132-136), NOT the "no settings file"
304
+ // fallback. This is the branch the no-file test does NOT hit.
305
+ ASSERT_HOME_ISOLATED();
306
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
307
+ writeFileSync(
308
+ join(process.cwd(), ".claude", "settings.local.json"),
309
+ JSON.stringify(
310
+ {
311
+ hooks: {
312
+ SessionStart: [
313
+ { hooks: [{ type: "command", command: "echo hi" }] },
314
+ ],
315
+ },
316
+ },
317
+ null,
318
+ 2,
319
+ ),
320
+ );
321
+
322
+ await runSessionSync(["disable"], { stdout, stderr });
323
+
324
+ const out = stdout.text();
325
+ assert.match(
326
+ out,
327
+ /was not installed|nothing to do/i,
328
+ "an existing file with no SkillRepo hook must report 'was not installed — nothing to do'",
329
+ );
330
+ // It must NOT claim the file is absent — that's the other branch.
331
+ assert.doesNotMatch(
332
+ out,
333
+ /No settings file/i,
334
+ "must not take the 'no settings file' branch when the file exists",
335
+ );
336
+ });
337
+
338
+ it("--json emits the 'unchanged' action when the file exists without a SkillRepo hook", async () => {
339
+ // Locks the structured-output counterpart of the human branch
340
+ // above: the remover's `unchanged` action surfaces verbatim in
341
+ // --json mode.
342
+ ASSERT_HOME_ISOLATED();
343
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
344
+ writeFileSync(
345
+ join(process.cwd(), ".claude", "settings.local.json"),
346
+ JSON.stringify(
347
+ { hooks: { SessionStart: [{ hooks: [{ type: "command", command: "echo hi" }] }] } },
348
+ null,
349
+ 2,
350
+ ),
351
+ );
352
+
353
+ await runSessionSync(["disable", "--json"], { stdout, stderr });
354
+
355
+ const json = JSON.parse(stdout.text());
356
+ assert.equal(json.action, "unchanged");
357
+ });
358
+
232
359
  it("preserves user-authored hooks in the same settings file", async () => {
233
360
  // INTENT: disable must only strip SkillRepo's entry — everything
234
361
  // the user added must survive.
@@ -350,3 +477,102 @@ describe("session-sync — flag ordering tolerance", () => {
350
477
  assert.equal(json.action, "installed");
351
478
  });
352
479
  });
480
+
481
+ // Skipped on Windows: this scrubs process.env.PATH to a single empty dir,
482
+ // which is unsafe on the Windows runner (an empty PATH can stall process
483
+ // machinery the test harness relies on). The "skipped" branch is platform-
484
+ // agnostic and covered on the Linux/macOS CI run.
485
+ describe("session-sync enable — binary unresolvable (skipped)", { skip: process.platform === "win32" }, () => {
486
+ // This block deliberately does NOT install the PATH shim. The
487
+ // standard `setup` installs a `skillrepo` shim so mergeSessionHook
488
+ // can resolve a binary path; here we need the OPPOSITE — an
489
+ // environment where `skillrepo` is UNresolvable so mergeSessionHook
490
+ // returns `{ action: "skipped", reason: ... }` and printEnableResult
491
+ // takes the "Could not enable session sync" branch (lines 123-124),
492
+ // the only enable branch no other test exercised.
493
+ let localSandbox;
494
+ let localOriginalCwd;
495
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
496
+ let localHomeEnv;
497
+ let savedPath;
498
+ let localStdout;
499
+ let localStderr;
500
+
501
+ beforeEach(() => {
502
+ localSandbox = mkdtempSync(join(tmpdir(), "cli-cmd-session-sync-noshim-"));
503
+ mkdirSync(join(localSandbox, "project"), { recursive: true });
504
+ mkdirSync(join(localSandbox, "home"), { recursive: true });
505
+ // An empty bin dir is the ENTIRE PATH — guarantees `skillrepo` is
506
+ // not resolvable from anywhere (no global install can leak in).
507
+ mkdirSync(join(localSandbox, "emptybin"), { recursive: true });
508
+ localOriginalCwd = process.cwd();
509
+ localHomeEnv = captureHome();
510
+ process.chdir(join(localSandbox, "project"));
511
+ setSandboxHome(join(localSandbox, "home"));
512
+
513
+ // Replace PATH with a single empty directory. resolveBinaryOnPath
514
+ // scans PATH dirs for a `skillrepo` file and finds none → returns
515
+ // null → mergeSessionHook short-circuits to "skipped". Save the
516
+ // real PATH so teardown restores it (other suites depend on it).
517
+ savedPath = process.env.PATH;
518
+ process.env.PATH = join(localSandbox, "emptybin");
519
+
520
+ assertHomeIsolated(tmpdir(), "session-sync skipped test");
521
+
522
+ localStdout = createCaptureStream();
523
+ localStderr = createCaptureStream();
524
+ });
525
+
526
+ afterEach(() => {
527
+ process.chdir(localOriginalCwd);
528
+ if (savedPath === undefined) {
529
+ delete process.env.PATH;
530
+ } else {
531
+ process.env.PATH = savedPath;
532
+ }
533
+ restoreHome(localHomeEnv);
534
+ if (localSandbox) rmSync(localSandbox, { recursive: true, force: true });
535
+ });
536
+
537
+ it("reports 'Could not enable session sync' when no skillrepo binary is on PATH", async () => {
538
+ // Precondition: prove the binary genuinely can't be resolved, so
539
+ // the test isn't silently passing for the wrong reason (e.g. a
540
+ // global install leaking onto PATH).
541
+ assert.equal(
542
+ resolveSkillrepoBinary(),
543
+ null,
544
+ "precondition: skillrepo must be unresolvable on the scrubbed PATH",
545
+ );
546
+
547
+ await runSessionSync(["enable"], { stdout: localStdout, stderr: localStderr });
548
+
549
+ const out = localStdout.text();
550
+ assert.match(
551
+ out,
552
+ /Could not enable session sync/i,
553
+ "the skipped branch must surface the human 'Could not enable' message",
554
+ );
555
+ // The skipped reason points the user at how to fix it.
556
+ assert.match(out, /skillrepo/i, "the reason text must be present");
557
+ // No settings file should have been written.
558
+ assert.ok(
559
+ !existsSync(join(process.cwd(), ".claude", "settings.local.json")),
560
+ "a skipped enable must not write a settings file",
561
+ );
562
+ });
563
+
564
+ it("--json emits the skipped action + reason when the binary is unresolvable", async () => {
565
+ // Structured-output counterpart: automation needs to detect the
566
+ // skipped state programmatically.
567
+ assert.equal(resolveSkillrepoBinary(), null, "precondition: unresolvable");
568
+
569
+ await runSessionSync(["enable", "--json"], {
570
+ stdout: localStdout,
571
+ stderr: localStderr,
572
+ });
573
+
574
+ const json = JSON.parse(localStdout.text());
575
+ assert.equal(json.action, "skipped");
576
+ assert.ok(json.reason, "reason field must be present in JSON output");
577
+ });
578
+ });
@@ -31,6 +31,8 @@ import {
31
31
  readFileSync,
32
32
  writeFileSync,
33
33
  existsSync,
34
+ symlinkSync,
35
+ chmodSync,
34
36
  } from "node:fs";
35
37
  import { join } from "node:path";
36
38
  import { tmpdir } from "node:os";
@@ -771,4 +773,218 @@ describe("runUninstall — error handling", () => {
771
773
  const giSurvivors = readFileSync(join(projectDir, ".gitignore"), "utf-8");
772
774
  assert.doesNotMatch(giSurvivors, /SkillRepo/);
773
775
  });
776
+
777
+ // ── Basename safety-net (removeDirectoryArtifact 145-151) ─────────
778
+ //
779
+ // The inline directory remover resolves `.claude/skills` with
780
+ // `realpathSync` BEFORE rmSync and refuses to recurse if the
781
+ // resolved basename is neither "skills" nor "skillrepo". The prior
782
+ // structural test only greps the source for the assertion text; the
783
+ // two tests below actually EXERCISE the guard at runtime by pointing
784
+ // `.claude/skills` at a symlink whose real target is a directory with
785
+ // a dangerous basename ("important-data"). This is the exact attack
786
+ // the architect's round-1 review flagged: a `.claude/skills` symlink
787
+ // pointing at `/home/user/important-data/` whose string-basename is
788
+ // "skills" but whose rmSync target is the user's real data.
789
+ //
790
+ // POSIX-only: symlink semantics differ on Windows and the guard's
791
+ // realpath-then-basename logic is exercised identically by these
792
+ // tests on POSIX, so we skip on win32 rather than fight junction
793
+ // semantics. The guard's source is the same on both platforms.
794
+ it(
795
+ "dry-run + json: basename guard surfaces a refusal error for a symlinked skills dir",
796
+ { skip: process.platform === "win32" ? "POSIX-only symlink test" : false },
797
+ async () => {
798
+ ASSERT_HOME_ISOLATED();
799
+ // `.claude/skills` → <project>/important-data (target EXISTS, so
800
+ // existsSync passes and realpathSync resolves — the only way to
801
+ // reach the basename branch, since a dangling symlink would
802
+ // short-circuit at the earlier existsSync check).
803
+ const dangerous = join(projectDir, "important-data");
804
+ mkdirSync(dangerous, { recursive: true });
805
+ writeFileSync(join(dangerous, "keepme.txt"), "user data");
806
+ mkdirSync(join(projectDir, ".claude"), { recursive: true });
807
+ symlinkSync(dangerous, join(projectDir, ".claude", "skills"));
808
+
809
+ await runUninstall(["--dry-run", "--json"], { stdout, stderr });
810
+
811
+ const json = JSON.parse(stdout.text());
812
+ assert.equal(json.action, "dry-run");
813
+ const dirError = json.errors.find((e) => e.id === "skills-dir-project");
814
+ assert.ok(
815
+ dirError,
816
+ "the symlinked skills dir must appear in errors[] during scan",
817
+ );
818
+ assert.match(dirError.error, /Refusing to recursively remove/);
819
+ assert.match(dirError.error, /important-data/);
820
+ // Nothing was touched — dry run.
821
+ assert.ok(existsSync(join(dangerous, "keepme.txt")));
822
+ },
823
+ );
824
+
825
+ it(
826
+ "execute: basename guard refuses rmSync, reports the error, and leaves the target intact (covers stderr error summary + EXIT_DISK)",
827
+ { skip: process.platform === "win32" ? "POSIX-only symlink test" : false },
828
+ async () => {
829
+ ASSERT_HOME_ISOLATED();
830
+ const dangerous = join(projectDir, "important-data");
831
+ mkdirSync(dangerous, { recursive: true });
832
+ writeFileSync(join(dangerous, "keepme.txt"), "user data");
833
+ mkdirSync(join(projectDir, ".claude"), { recursive: true });
834
+ symlinkSync(dangerous, join(projectDir, ".claude", "skills"));
835
+
836
+ // Non-JSON so the human-readable stderr error summary path
837
+ // (the "⚠ Some artifacts could not be removed" block) runs.
838
+ let thrown;
839
+ try {
840
+ await runUninstall(["--yes"], { stdout, stderr });
841
+ } catch (err) {
842
+ thrown = err;
843
+ }
844
+
845
+ // Aggregated failures propagate as EXIT_DISK.
846
+ assert.ok(thrown, "a refused removal must throw");
847
+ assert.equal(thrown.exitCode, 3, "must carry EXIT_DISK");
848
+
849
+ // The stderr summary block rendered the refusal.
850
+ assert.match(stderr.text(), /Some artifacts could not be removed/);
851
+ assert.match(stderr.text(), /Refusing to recursively remove/);
852
+ assert.match(
853
+ stderr.text(),
854
+ /re-run `skillrepo uninstall`/,
855
+ "the remediation hint must render",
856
+ );
857
+
858
+ // CRITICAL: the symlink target survived. The guard did its job.
859
+ assert.ok(
860
+ existsSync(join(dangerous, "keepme.txt")),
861
+ "the symlink target must NOT be removed by rmSync",
862
+ );
863
+ },
864
+ );
865
+
866
+ // ── Thrown-remover catch (execute phase 311-321) ─────────────────
867
+ //
868
+ // The corrupt-JSON test above covers a remover that RETURNS an error.
869
+ // This one covers a remover that THROWS during the execute phase — a
870
+ // different branch (the `catch (err)` that wraps the result). We make
871
+ // the .gitignore remover throw by making the project directory
872
+ // read-only AFTER seeding: the scan phase only reads (succeeds), but
873
+ // the execute phase's atomic write (temp-file + rename) fails with
874
+ // EACCES, which `writeFileAtomic` re-throws.
875
+ //
876
+ // POSIX-only: chmod 0555 has no equivalent enforcement on Windows.
877
+ it(
878
+ "continues and aggregates when a remover throws mid-execute (read-only project dir)",
879
+ { skip: process.platform === "win32" ? "POSIX-only chmod test" : false },
880
+ async () => {
881
+ ASSERT_HOME_ISOLATED();
882
+ // Seed ONLY a .gitignore section so exactly one artifact is in the
883
+ // scanned set — keeps the assertion focused on the throw path.
884
+ seedInstalledProject({
885
+ mcpSkillrepo: false,
886
+ envLocalHasKey: false,
887
+ gitignoreHasSection: true,
888
+ skillsDir: false,
889
+ });
890
+
891
+ chmodSync(projectDir, 0o555);
892
+ let thrown;
893
+ try {
894
+ await runUninstall(["--yes", "--json"], { stdout, stderr });
895
+ } catch (err) {
896
+ thrown = err;
897
+ } finally {
898
+ // Restore write permission so teardown's rmSync can clean up.
899
+ chmodSync(projectDir, 0o755);
900
+ }
901
+
902
+ assert.ok(thrown, "a throwing remover must still produce EXIT_DISK");
903
+ assert.equal(thrown.exitCode, 3);
904
+
905
+ const json = JSON.parse(stdout.text());
906
+ assert.equal(json.action, "partially-uninstalled");
907
+ const giError = json.errors.find((e) => e.id === "gitignore-entries");
908
+ assert.ok(giError, ".gitignore write failure must be aggregated");
909
+ assert.match(giError.error, /EACCES|permission denied|Cannot write/i);
910
+ },
911
+ );
912
+
913
+ // ── Color wrappers (GREEN/YELLOW/BOLD under a TTY stdout) ─────────
914
+ //
915
+ // The three ANSI color helpers only run when stdout is a TTY and
916
+ // NO_COLOR is unset (`makeColors`). The capture stream is not a TTY
917
+ // by default, so these wrappers are never exercised — they show as
918
+ // uncovered functions. We force `isTTY = true` on the capture stream
919
+ // and seed a state with BOTH a successful removal (green ✓ + bold
920
+ // header) AND a refused artifact (yellow ⚠) so all three wrappers
921
+ // fire in a single run. We assert the ANSI escape leaked into the
922
+ // captured (non-real) stream — proof the colored branch ran.
923
+ //
924
+ // POSIX-only: relies on the symlink basename guard to produce the
925
+ // yellow error branch.
926
+ it(
927
+ "emits ANSI color codes when stdout is a TTY (covers GREEN/YELLOW/BOLD)",
928
+ { skip: process.platform === "win32" ? "POSIX-only symlink test" : false },
929
+ async () => {
930
+ ASSERT_HOME_ISOLATED();
931
+ // makeColors gates on NO_COLOR too — neutralize it for this test
932
+ // and restore afterward so we don't leak env state across tests.
933
+ const savedNoColor = process.env.NO_COLOR;
934
+ delete process.env.NO_COLOR;
935
+ stdout.isTTY = true;
936
+ stderr.isTTY = true;
937
+
938
+ // A removable artifact (green ✓) ...
939
+ seedInstalledProject({
940
+ mcpSkillrepo: false,
941
+ envLocalHasKey: false,
942
+ gitignoreHasSection: true,
943
+ skillsDir: false,
944
+ });
945
+ // ... plus a symlinked skills dir that the basename guard refuses
946
+ // (yellow ⚠).
947
+ const dangerous = join(projectDir, "important-data");
948
+ mkdirSync(dangerous, { recursive: true });
949
+ mkdirSync(join(projectDir, ".claude"), { recursive: true });
950
+ symlinkSync(dangerous, join(projectDir, ".claude", "skills"));
951
+
952
+ let thrown;
953
+ try {
954
+ await runUninstall(["--yes"], { stdout, stderr });
955
+ } catch (err) {
956
+ thrown = err;
957
+ } finally {
958
+ if (savedNoColor === undefined) delete process.env.NO_COLOR;
959
+ else process.env.NO_COLOR = savedNoColor;
960
+ }
961
+
962
+ assert.ok(thrown, "the refused artifact still drives EXIT_DISK");
963
+ // ANSI escape (\x1b[) present → the colored branch ran. bold is
964
+ // in the preview header on stdout, green on the removed line,
965
+ // yellow on the stderr warning.
966
+ assert.match(stdout.text(), /\x1b\[/, "stdout must carry ANSI codes under TTY");
967
+ assert.match(stderr.text(), /\x1b\[33m/, "the yellow warning wrapper must run");
968
+ },
969
+ );
970
+
971
+ // ── Unknown-flag rejection (parseUninstallFlags acceptPositional) ──
972
+ //
973
+ // An unrecognized arg falls through `parseUninstallFlags`'s
974
+ // acceptPositional callback (which returns `false`), so resolveFlags
975
+ // raises a validationError. Covers the `return false` tail of the
976
+ // callback.
977
+ it("rejects an unknown flag with a validation error", async () => {
978
+ ASSERT_HOME_ISOLATED();
979
+ seedInstalledProject();
980
+
981
+ await assert.rejects(
982
+ () => runUninstall(["--bogus"], { stdout, stderr }),
983
+ (err) => {
984
+ assert.equal(err.exitCode, 5, "unknown flag must be a validation error");
985
+ assert.match(err.message, /Unknown argument/);
986
+ return true;
987
+ },
988
+ );
989
+ });
774
990
  });