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.
- package/README.md +72 -6
- package/bin/skillrepo.mjs +14 -0
- package/package.json +1 -1
- package/src/commands/init.mjs +132 -14
- package/src/commands/remove.mjs +8 -13
- package/src/commands/session-sync.mjs +152 -0
- package/src/commands/uninstall.mjs +484 -0
- package/src/commands/update.mjs +125 -8
- package/src/lib/artifact-registry.mjs +265 -0
- package/src/lib/fs-utils.mjs +83 -1
- package/src/lib/mergers/session-hook.mjs +298 -0
- package/src/lib/paths.mjs +21 -0
- package/src/lib/removers/claude-mcp.mjs +67 -0
- package/src/lib/removers/cursor-mcp.mjs +60 -0
- package/src/lib/removers/env-local.mjs +55 -0
- package/src/lib/removers/gitignore.mjs +108 -0
- package/src/lib/removers/settings.mjs +183 -0
- package/src/lib/removers/vscode-mcp.mjs +87 -0
- package/src/lib/removers/windsurf-mcp.mjs +65 -0
- package/src/test/commands/init.test.mjs +211 -0
- package/src/test/commands/session-sync.test.mjs +350 -0
- package/src/test/commands/uninstall.test.mjs +768 -0
- package/src/test/commands/update.test.mjs +158 -0
- package/src/test/lib/artifact-registry.test.mjs +268 -0
- package/src/test/mergers/session-hook.test.mjs +745 -0
- package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
- package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
- package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
- package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
- package/src/test/mergers/uninstall-settings.test.mjs +285 -0
- package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
- 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
|
+
});
|