skillrepo 3.0.0 → 3.1.0

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 (32) hide show
  1. package/README.md +72 -6
  2. package/bin/skillrepo.mjs +14 -0
  3. package/package.json +1 -1
  4. package/src/commands/init.mjs +132 -14
  5. package/src/commands/remove.mjs +8 -13
  6. package/src/commands/session-sync.mjs +152 -0
  7. package/src/commands/uninstall.mjs +484 -0
  8. package/src/commands/update.mjs +125 -8
  9. package/src/lib/artifact-registry.mjs +265 -0
  10. package/src/lib/fs-utils.mjs +83 -1
  11. package/src/lib/mergers/session-hook.mjs +298 -0
  12. package/src/lib/paths.mjs +21 -0
  13. package/src/lib/removers/claude-mcp.mjs +67 -0
  14. package/src/lib/removers/cursor-mcp.mjs +60 -0
  15. package/src/lib/removers/env-local.mjs +55 -0
  16. package/src/lib/removers/gitignore.mjs +108 -0
  17. package/src/lib/removers/settings.mjs +183 -0
  18. package/src/lib/removers/vscode-mcp.mjs +87 -0
  19. package/src/lib/removers/windsurf-mcp.mjs +65 -0
  20. package/src/test/commands/init.test.mjs +211 -0
  21. package/src/test/commands/session-sync.test.mjs +350 -0
  22. package/src/test/commands/uninstall.test.mjs +768 -0
  23. package/src/test/commands/update.test.mjs +158 -0
  24. package/src/test/lib/artifact-registry.test.mjs +268 -0
  25. package/src/test/mergers/session-hook.test.mjs +745 -0
  26. package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
  27. package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
  28. package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
  29. package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
  30. package/src/test/mergers/uninstall-settings.test.mjs +285 -0
  31. package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
  32. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
@@ -484,3 +484,214 @@ describe("runInit — stale-key handling", () => {
484
484
  );
485
485
  });
486
486
  });
487
+
488
+ // ── Session-sync step 6 (#884) ────────────────────────────────────────
489
+ //
490
+ // INTENT-based coverage of the new step 6 added in v3.1.0. Tests use
491
+ // the PATH-shim trick from session-sync.test.mjs to make
492
+ // `which skillrepo` resolve deterministically: a fake `skillrepo`
493
+ // executable is dropped into `$HOME/bin` and prepended to PATH.
494
+ // Without this, the behavior of these tests would depend on whether
495
+ // a global install exists on the developer's machine.
496
+ //
497
+ // Lower-level installer correctness (hook shape, idempotency,
498
+ // round-trip with remover) is covered in session-hook.test.mjs. These
499
+ // init tests verify the ORCHESTRATION: --yes path, --no-session-sync
500
+ // opt-out, --json output shape, and the non-fatal disk-error path.
501
+
502
+ import { chmodSync as _chmodSync } from "node:fs";
503
+ import { SESSION_HOOK_FINGERPRINT as _FINGERPRINT } from "../../lib/artifact-registry.mjs";
504
+
505
+ async function setupWithShim() {
506
+ await setup();
507
+ // Drop a deterministic `skillrepo` shim into HOME/bin so
508
+ // `which skillrepo` resolves to it rather than a possibly-missing
509
+ // global install.
510
+ const binDir = join(process.env.HOME, "bin");
511
+ mkdirSync(binDir, { recursive: true });
512
+ const shim = join(binDir, "skillrepo");
513
+ writeFileSync(shim, "#!/bin/sh\nexit 0\n");
514
+ _chmodSync(shim, 0o755);
515
+ process.env.PATH = `${binDir}:${process.env.PATH}`;
516
+ }
517
+
518
+ describe("runInit — session sync (#884)", () => {
519
+ beforeEach(setupWithShim);
520
+ afterEach(teardown);
521
+
522
+ it("--yes installs the hook at step 6 by default", async () => {
523
+ // INTENT: the architect-designed default for --yes mode is
524
+ // "install the hook." CI/onboarding scripts passing --yes should
525
+ // get a fully-configured project including session sync.
526
+ await runInit(
527
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
528
+ { stdout, stderr },
529
+ );
530
+
531
+ const settingsPath = join(process.cwd(), ".claude", "settings.local.json");
532
+ assert.ok(existsSync(settingsPath), "settings.local.json must exist");
533
+
534
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
535
+ const hasHook = parsed.hooks.SessionStart.flatMap((g) => g.hooks).some(
536
+ (h) => h.command.includes(_FINGERPRINT),
537
+ );
538
+ assert.ok(hasHook, "SkillRepo SessionStart hook must be installed");
539
+ assert.match(stdout.text(), /SessionStart hook installed/);
540
+ });
541
+
542
+ it("--no-session-sync skips the hook install even with --yes", async () => {
543
+ // INTENT: the only way CI scripts that bootstrap a project
544
+ // without starting Claude Code sessions can opt out. Must work
545
+ // alongside --yes (otherwise --yes would force hook install).
546
+ await runInit(
547
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--no-session-sync"],
548
+ { stdout, stderr },
549
+ );
550
+
551
+ const settingsPath = join(process.cwd(), ".claude", "settings.local.json");
552
+ assert.ok(
553
+ !existsSync(settingsPath),
554
+ "settings.local.json must NOT be written under --no-session-sync",
555
+ );
556
+ assert.match(stdout.text(), /Session sync skipped \(--no-session-sync\)/);
557
+ });
558
+
559
+ it("re-running init is idempotent — exactly one hook entry", async () => {
560
+ // INTENT: users re-run init for many reasons (switching keys,
561
+ // updating the config). A duplicate hook would fire sync twice
562
+ // per session — waste at best, race at worst.
563
+ await runInit(
564
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
565
+ { stdout, stderr },
566
+ );
567
+ stdout.clear();
568
+ await runInit(
569
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
570
+ { stdout, stderr },
571
+ );
572
+
573
+ const parsed = JSON.parse(
574
+ readFileSync(
575
+ join(process.cwd(), ".claude", "settings.local.json"),
576
+ "utf-8",
577
+ ),
578
+ );
579
+ const skillrepoHooks = parsed.hooks.SessionStart.flatMap(
580
+ (g) => g.hooks,
581
+ ).filter((h) => h.command.includes(_FINGERPRINT));
582
+ assert.equal(skillrepoHooks.length, 1, "exactly one SkillRepo hook");
583
+ });
584
+
585
+ it("--json includes a sessionSync block with action + path", async () => {
586
+ // INTENT: automation scripts need to know whether session sync
587
+ // was installed, opted out, or failed. The JSON contract is the
588
+ // machine-readable channel for that.
589
+ await runInit(
590
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
591
+ { stdout, stderr },
592
+ );
593
+ const json = JSON.parse(stdout.text());
594
+ assert.ok(json.sessionSync, "sessionSync must be in --json output");
595
+ assert.equal(json.sessionSync.action, "installed");
596
+ assert.equal(json.sessionSync.path, ".claude/settings.local.json");
597
+ });
598
+
599
+ it("--json with --no-session-sync reports action: 'opted-out'", async () => {
600
+ await runInit(
601
+ [
602
+ "--key",
603
+ VALID_KEY,
604
+ "--url",
605
+ serverUrl,
606
+ "--yes",
607
+ "--no-session-sync",
608
+ "--json",
609
+ ],
610
+ { stdout, stderr },
611
+ );
612
+ const json = JSON.parse(stdout.text());
613
+ assert.equal(json.sessionSync.action, "opted-out");
614
+ assert.equal(json.sessionSync.path, null);
615
+ });
616
+
617
+ it("skips session sync entirely when only non-Claude-Code IDEs are targeted (cross-PR review fix)", async () => {
618
+ // Cross-PR review flagged: before this guard, a user running
619
+ // `skillrepo init --ide cursor` would get a Claude Code-specific
620
+ // SessionStart hook written to `.claude/settings.local.json`.
621
+ // Cursor never reads that file, so the hook was silent useless
622
+ // state that `skillrepo uninstall` later had to clean up.
623
+ //
624
+ // The guard in init.mjs step 6 now skips the install when
625
+ // `claudeCode` is not in the resolved vendors list AND
626
+ // `--global` is not passed. This test proves the skip fires.
627
+ //
628
+ // Use --ide cursor to force vendors = ["cursor"]. Bypass the
629
+ // .claude/ auto-detection by creating .cursor/ instead.
630
+ mkdirSync(join(process.cwd(), ".cursor"), { recursive: true });
631
+ rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
632
+
633
+ await runInit(
634
+ [
635
+ "--key",
636
+ VALID_KEY,
637
+ "--url",
638
+ serverUrl,
639
+ "--yes",
640
+ "--ide",
641
+ "cursor",
642
+ "--json",
643
+ ],
644
+ { stdout, stderr },
645
+ );
646
+
647
+ const json = JSON.parse(stdout.text());
648
+ assert.equal(
649
+ json.sessionSync.action,
650
+ "not-applicable",
651
+ "session sync must report 'not-applicable' for non-Claude-Code targets",
652
+ );
653
+ assert.equal(json.sessionSync.path, null);
654
+ // Critical: the settings.local.json file must NOT have been
655
+ // written. A Cursor user should never see this Claude-specific
656
+ // file materialize from `skillrepo init`.
657
+ assert.ok(
658
+ !existsSync(join(process.cwd(), ".claude", "settings.local.json")),
659
+ ".claude/settings.local.json must NOT be written for Cursor-only init",
660
+ );
661
+ });
662
+
663
+ it("still installs session sync under --global even without claudeCode in vendors", async () => {
664
+ // INTENT: `--global` writes to `~/.claude/settings.local.json`,
665
+ // which IS Claude Code's user-wide settings path. A user who
666
+ // runs `skillrepo init --global` (even without `--ide claude`)
667
+ // is implicitly targeting Claude Code. The guard must allow
668
+ // this path so `--global` users still get auto-sync.
669
+ //
670
+ // Note: the setup() helper already creates `.claude/` in the
671
+ // project, which would normally push vendors to include
672
+ // claudeCode. Force vendors = ["cursor"] via --ide to exercise
673
+ // the "--global overrides vendors" branch.
674
+ await runInit(
675
+ [
676
+ "--key",
677
+ VALID_KEY,
678
+ "--url",
679
+ serverUrl,
680
+ "--yes",
681
+ "--global",
682
+ "--ide",
683
+ "cursor",
684
+ "--json",
685
+ ],
686
+ { stdout, stderr },
687
+ );
688
+
689
+ const json = JSON.parse(stdout.text());
690
+ assert.equal(
691
+ json.sessionSync.action,
692
+ "installed",
693
+ "--global must install the hook even when vendors doesn't include claudeCode",
694
+ );
695
+ assert.equal(json.sessionSync.path, "~/.claude/settings.local.json");
696
+ });
697
+ });
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Unit tests for src/commands/session-sync.mjs (#884).
3
+ *
4
+ * INTENT-based coverage of the `skillrepo session-sync enable|disable`
5
+ * command surface. Lower-level installer correctness (fingerprint,
6
+ * atomic writes, round-trip with #885 remover) is covered in
7
+ * `session-hook.test.mjs` — these tests verify the COMMAND wrapper's
8
+ * behavior: subcommand parsing, flag handling, JSON output, error
9
+ * propagation.
10
+ *
11
+ * HOME isolation enforced in every test. --global paths write to
12
+ * `~/.claude/settings.local.json` and this guard is the only thing
13
+ * preventing a misconfigured test from nuking real user state.
14
+ */
15
+
16
+ import { describe, it, beforeEach, afterEach } from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import {
19
+ mkdtempSync,
20
+ mkdirSync,
21
+ rmSync,
22
+ readFileSync,
23
+ writeFileSync,
24
+ existsSync,
25
+ chmodSync,
26
+ } from "node:fs";
27
+ import { join } from "node:path";
28
+ import { tmpdir } from "node:os";
29
+
30
+ import { runSessionSync } from "../../commands/session-sync.mjs";
31
+ import { buildHookCommand } from "../../lib/mergers/session-hook.mjs";
32
+ import { SESSION_HOOK_FINGERPRINT } from "../../lib/artifact-registry.mjs";
33
+ import { createCaptureStream } from "../helpers/capture-stream.mjs";
34
+ import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
35
+
36
+ let sandbox;
37
+ let originalCwd;
38
+ let originalHome;
39
+ let stdout;
40
+ let stderr;
41
+
42
+ function ASSERT_HOME_ISOLATED() {
43
+ assert.ok(
44
+ process.env.HOME && process.env.HOME.startsWith(tmpdir()),
45
+ `HOME must point inside tmpdir. Current HOME="${process.env.HOME}"`,
46
+ );
47
+ }
48
+
49
+ function setup() {
50
+ sandbox = mkdtempSync(join(tmpdir(), "cli-cmd-session-sync-"));
51
+ mkdirSync(join(sandbox, "project"), { recursive: true });
52
+ mkdirSync(join(sandbox, "home"), { recursive: true });
53
+ originalCwd = process.cwd();
54
+ originalHome = process.env.HOME;
55
+ process.chdir(join(sandbox, "project"));
56
+ process.env.HOME = join(sandbox, "home");
57
+
58
+ // Put a predictable `skillrepo` shim at the front of PATH so
59
+ // mergeSessionHook's `which skillrepo` resolves to it. Saves having
60
+ // to inject a binaryPath at every call site.
61
+ const binDir = join(process.env.HOME, "bin");
62
+ mkdirSync(binDir, { recursive: true });
63
+ const shim = join(binDir, "skillrepo");
64
+ writeFileSync(shim, "#!/bin/sh\nexit 0\n");
65
+ chmodSync(shim, 0o755);
66
+ process.env.PATH = `${binDir}:${process.env.PATH}`;
67
+
68
+ ASSERT_HOME_ISOLATED();
69
+
70
+ stdout = createCaptureStream();
71
+ stderr = createCaptureStream();
72
+ }
73
+
74
+ function teardown() {
75
+ process.chdir(originalCwd);
76
+ process.env.HOME = originalHome;
77
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
78
+ // Strip the prepended bin dir from PATH to keep subsequent tests clean
79
+ if (process.env.PATH?.startsWith(join(sandbox ?? "", "home", "bin"))) {
80
+ process.env.PATH = process.env.PATH.slice(
81
+ join(sandbox, "home", "bin").length + 1,
82
+ );
83
+ }
84
+ }
85
+
86
+ // ──────────────────────────────────────────────────────────────────
87
+
88
+ describe("session-sync — subcommand parsing", () => {
89
+ beforeEach(setup);
90
+ afterEach(teardown);
91
+
92
+ it("rejects invocation without a subcommand", async () => {
93
+ // INTENT: ambiguous invocations must fail loudly with guidance,
94
+ // not silently do nothing. A user who types `skillrepo
95
+ // session-sync` expects some clear response.
96
+ await assert.rejects(
97
+ () => runSessionSync([], { stdout, stderr }),
98
+ (err) =>
99
+ err instanceof CliError &&
100
+ err.exitCode === EXIT_VALIDATION &&
101
+ /subcommand/i.test(err.message),
102
+ );
103
+ });
104
+
105
+ it("rejects unknown subcommands", async () => {
106
+ await assert.rejects(
107
+ () => runSessionSync(["status"], { stdout, stderr }),
108
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
109
+ );
110
+ });
111
+
112
+ it("rejects two subcommands passed together", async () => {
113
+ await assert.rejects(
114
+ () => runSessionSync(["enable", "disable"], { stdout, stderr }),
115
+ (err) =>
116
+ err instanceof CliError &&
117
+ err.exitCode === EXIT_VALIDATION &&
118
+ /exactly one/i.test(err.message),
119
+ );
120
+ });
121
+ });
122
+
123
+ describe("session-sync enable", () => {
124
+ beforeEach(setup);
125
+ afterEach(teardown);
126
+
127
+ it("installs the hook and reports success", async () => {
128
+ // INTENT: the primary success path — user types `session-sync
129
+ // enable`, the hook lands, message confirms.
130
+ ASSERT_HOME_ISOLATED();
131
+ await runSessionSync(["enable"], { stdout, stderr });
132
+
133
+ assert.match(stdout.text(), /installed/i);
134
+ const settingsPath = join(
135
+ process.cwd(),
136
+ ".claude",
137
+ "settings.local.json",
138
+ );
139
+ assert.ok(existsSync(settingsPath), "settings.local.json must exist");
140
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
141
+ const hasHook = parsed.hooks.SessionStart.flatMap((g) => g.hooks).some(
142
+ (h) => h.command.includes(SESSION_HOOK_FINGERPRINT),
143
+ );
144
+ assert.ok(hasHook, "SkillRepo hook must be present");
145
+ });
146
+
147
+ it("is idempotent — second enable returns 'unchanged'", async () => {
148
+ // INTENT: users re-running `session-sync enable` must get a clear
149
+ // "nothing to do" response, not a duplicate hook.
150
+ ASSERT_HOME_ISOLATED();
151
+ await runSessionSync(["enable"], { stdout, stderr });
152
+ stdout.clear();
153
+
154
+ await runSessionSync(["enable"], { stdout, stderr });
155
+ assert.match(stdout.text(), /already installed/i);
156
+ });
157
+
158
+ it("--json emits structured output with action + command + path", async () => {
159
+ // INTENT: automation scripts need a deterministic output format.
160
+ // No ambiguity, no ANSI codes, no surrounding prose.
161
+ ASSERT_HOME_ISOLATED();
162
+ await runSessionSync(["enable", "--json"], { stdout, stderr });
163
+
164
+ const json = JSON.parse(stdout.text());
165
+ assert.equal(json.action, "installed");
166
+ assert.equal(json.path, ".claude/settings.local.json");
167
+ assert.ok(typeof json.command === "string");
168
+ assert.ok(json.command.includes(SESSION_HOOK_FINGERPRINT));
169
+ });
170
+
171
+ it("--global writes to the user-wide settings file", async () => {
172
+ // INTENT: `--global` installs the hook at ~/.claude/settings.local.json
173
+ // so it fires in EVERY Claude Code session (not just this project).
174
+ // Used by users who want SkillRepo integration machine-wide.
175
+ ASSERT_HOME_ISOLATED();
176
+ await runSessionSync(["enable", "--global"], { stdout, stderr });
177
+
178
+ const globalSettings = join(
179
+ process.env.HOME,
180
+ ".claude",
181
+ "settings.local.json",
182
+ );
183
+ assert.ok(existsSync(globalSettings));
184
+ // Project-local file must NOT be touched
185
+ assert.ok(
186
+ !existsSync(join(process.cwd(), ".claude", "settings.local.json")),
187
+ "--global must only touch the user-wide file",
188
+ );
189
+ });
190
+ });
191
+
192
+ describe("session-sync disable", () => {
193
+ beforeEach(setup);
194
+ afterEach(teardown);
195
+
196
+ it("removes the hook and reports success", async () => {
197
+ // INTENT: users disabling the hook must see it gone from the
198
+ // file immediately, with a clear success message.
199
+ ASSERT_HOME_ISOLATED();
200
+ await runSessionSync(["enable"], { stdout, stderr });
201
+ stdout.clear();
202
+
203
+ await runSessionSync(["disable"], { stdout, stderr });
204
+
205
+ const settingsPath = join(
206
+ process.cwd(),
207
+ ".claude",
208
+ "settings.local.json",
209
+ );
210
+ if (existsSync(settingsPath)) {
211
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
212
+ const hasHook = (parsed.hooks?.SessionStart ?? [])
213
+ .flatMap((g) => g?.hooks ?? [])
214
+ .some((h) => h?.command?.includes(SESSION_HOOK_FINGERPRINT));
215
+ assert.ok(!hasHook, "SkillRepo hook must be gone after disable");
216
+ }
217
+ assert.match(stdout.text(), /removed/i);
218
+ });
219
+
220
+ it("reports cleanly when disable runs on a file without the hook", async () => {
221
+ // INTENT: a user running `session-sync disable` when the hook
222
+ // isn't installed must get a clear "nothing to do" response, not
223
+ // an error. This is how automation scripts confirm the final
224
+ // state is "disabled" regardless of prior state.
225
+ ASSERT_HOME_ISOLATED();
226
+ await runSessionSync(["disable"], { stdout, stderr });
227
+ assert.match(stdout.text(), /not enabled|not installed|nothing to do/i);
228
+ });
229
+
230
+ it("preserves user-authored hooks in the same settings file", async () => {
231
+ // INTENT: disable must only strip SkillRepo's entry — everything
232
+ // the user added must survive.
233
+ ASSERT_HOME_ISOLATED();
234
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
235
+ writeFileSync(
236
+ join(process.cwd(), ".claude", "settings.local.json"),
237
+ JSON.stringify(
238
+ {
239
+ hooks: {
240
+ SessionStart: [
241
+ { hooks: [{ type: "command", command: "echo user-hook" }] },
242
+ {
243
+ hooks: [
244
+ {
245
+ type: "command",
246
+ command: buildHookCommand("/some/skillrepo"),
247
+ },
248
+ ],
249
+ },
250
+ ],
251
+ },
252
+ env: { USER_VAR: "value" },
253
+ },
254
+ null,
255
+ 2,
256
+ ),
257
+ );
258
+
259
+ await runSessionSync(["disable"], { stdout, stderr });
260
+
261
+ const parsed = JSON.parse(
262
+ readFileSync(
263
+ join(process.cwd(), ".claude", "settings.local.json"),
264
+ "utf-8",
265
+ ),
266
+ );
267
+ // User's hook survives
268
+ assert.equal(parsed.hooks.SessionStart.length, 1);
269
+ assert.equal(
270
+ parsed.hooks.SessionStart[0].hooks[0].command,
271
+ "echo user-hook",
272
+ );
273
+ // User's env survives
274
+ assert.deepEqual(parsed.env, { USER_VAR: "value" });
275
+ });
276
+
277
+ it("--json emits structured output for the removed case", async () => {
278
+ ASSERT_HOME_ISOLATED();
279
+ await runSessionSync(["enable"], { stdout, stderr });
280
+ stdout.clear();
281
+
282
+ await runSessionSync(["disable", "--json"], { stdout, stderr });
283
+
284
+ const json = JSON.parse(stdout.text());
285
+ assert.equal(json.action, "removed");
286
+ assert.equal(json.path, ".claude/settings.local.json");
287
+ });
288
+
289
+ it("surfaces the parse error in human output when settings.local.json is corrupt", async () => {
290
+ // Round-2 review gap (both architect + code-reviewer): before
291
+ // this branch, the corrupt-file path silently misdiagnosed a
292
+ // broken settings file as "session sync not enabled." Users
293
+ // had no way to tell from the human output that their file
294
+ // was the problem. The --json path already surfaced the error;
295
+ // this test locks the fix for the human output.
296
+ ASSERT_HOME_ISOLATED();
297
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
298
+ writeFileSync(
299
+ join(process.cwd(), ".claude", "settings.local.json"),
300
+ "{ not valid json",
301
+ );
302
+
303
+ await runSessionSync(["disable"], { stdout, stderr });
304
+
305
+ const out = stdout.text();
306
+ assert.match(
307
+ out,
308
+ /Cannot parse/i,
309
+ "corrupt file must produce a visible 'Cannot parse' message, not the generic 'not enabled' message",
310
+ );
311
+ assert.doesNotMatch(
312
+ out,
313
+ /not enabled/i,
314
+ "must NOT show the 'not enabled' fallback when the file exists but is corrupt",
315
+ );
316
+ });
317
+
318
+ it("still emits the parse error via --json for automation consumers", async () => {
319
+ // Confirms the --json path also surfaces the error (was
320
+ // already working — this locks the contract in).
321
+ ASSERT_HOME_ISOLATED();
322
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
323
+ writeFileSync(
324
+ join(process.cwd(), ".claude", "settings.local.json"),
325
+ "{ broken",
326
+ );
327
+
328
+ await runSessionSync(["disable", "--json"], { stdout, stderr });
329
+
330
+ const json = JSON.parse(stdout.text());
331
+ assert.equal(json.action, "skipped");
332
+ assert.ok(json.error, "error field must be present in JSON output");
333
+ assert.match(json.error, /parse/i);
334
+ });
335
+ });
336
+
337
+ describe("session-sync — flag ordering tolerance", () => {
338
+ beforeEach(setup);
339
+ afterEach(teardown);
340
+
341
+ it("accepts flags before the subcommand", async () => {
342
+ // INTENT: users who type `--json enable` vs `enable --json`
343
+ // expect both to work. Neither the command nor the script it
344
+ // generates should care about order.
345
+ ASSERT_HOME_ISOLATED();
346
+ await runSessionSync(["--json", "enable"], { stdout, stderr });
347
+ const json = JSON.parse(stdout.text());
348
+ assert.equal(json.action, "installed");
349
+ });
350
+ });