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
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Single source of truth for every file and in-file entry `skillrepo init`
3
+ * writes to the user's filesystem (#885).
4
+ *
5
+ * The goal is drift prevention: any future command that writes a new
6
+ * artifact (a new MCP vendor, a new project-local config file, etc.)
7
+ * MUST add its descriptor to this registry in the same PR. The CI
8
+ * enforcement test at `src/test/lib/artifact-registry.test.mjs` walks
9
+ * the mergers/ and removers/ directories on the filesystem and fails
10
+ * loudly if a merger has no matching remover, or if the registry is
11
+ * missing a descriptor for an id that the test's expected-set covers.
12
+ *
13
+ * This file is DATA ONLY. No filesystem access, no dynamic behavior,
14
+ * no side effects — if you find yourself importing `fs` here you are
15
+ * writing in the wrong module. Actual removal logic lives in
16
+ * `src/lib/removers/*.mjs`; this module only catalogs WHAT gets
17
+ * removed, not HOW.
18
+ *
19
+ * Out of scope for v3.1.0 (see #885 section 3 Non-Goals):
20
+ * - v2.0.0 artifacts (.claude/rules/skillrepo-*.md,
21
+ * .claude/hooks/skillrepo-*). v3.0.0 is the minimum supported
22
+ * version; users of earlier versions clean up manually.
23
+ * - The <cwd>/skills/ fallback directory written for non-Claude-Code
24
+ * vendors. Tracked in #876 until those IDEs publish their own
25
+ * on-disk skill discovery conventions.
26
+ *
27
+ * The `settings-session-hook` descriptor is a forward-declaration: it
28
+ * names the SessionStart hook entry that #884 installs. The fingerprint
29
+ * string is exported below so #884's installer can import it and both
30
+ * modules stay in lockstep without a circular dependency.
31
+ */
32
+
33
+ import {
34
+ claudeMcpJson,
35
+ cursorMcpJson,
36
+ vscodeMcpJson,
37
+ windsurfMcpJson,
38
+ envLocal,
39
+ gitignorePath,
40
+ claudeSkillsProjectRoot,
41
+ claudeSkillsGlobalRoot,
42
+ } from "./paths.mjs";
43
+ import { join } from "node:path";
44
+ import { homedir } from "node:os";
45
+
46
+ // ── Fingerprint constants exported for cross-module consumption ───────
47
+
48
+ /**
49
+ * `.gitignore` section header. Lines directly under this header, up to
50
+ * the next blank line or the end of file, are considered SkillRepo-owned
51
+ * and are removed by the gitignore remover. User-authored lines outside
52
+ * the section are never touched — this is the attribution mechanism.
53
+ *
54
+ * MUST stay in lockstep with `src/lib/mergers/gitignore.mjs`
55
+ * SECTION_HEADER. The artifact-registry CI test asserts both modules
56
+ * resolve the same header text.
57
+ */
58
+ export const GITIGNORE_SECTION_HEADER =
59
+ "# SkillRepo CLI (added by `skillrepo init`)";
60
+
61
+ /**
62
+ * Entries written under the gitignore section by init. The remover
63
+ * does not match individual entries — it removes every line between the
64
+ * header and the next blank line — but the expected set is exported so
65
+ * tests can verify the two lists stay in sync.
66
+ */
67
+ export const GITIGNORE_REQUIRED_ENTRIES = Object.freeze([
68
+ ".env.local",
69
+ ".claude/skills/",
70
+ ".claude/settings.local.json",
71
+ ]);
72
+
73
+ /**
74
+ * Environment variable name owned by the CLI in `.env.local`. Any line
75
+ * starting with `${ENV_LOCAL_KEY_NAME}=` is considered SkillRepo-owned.
76
+ * Exact prefix match (not substring) so a user-authored
77
+ * `# SKILLREPO_ACCESS_KEY is set via ...` comment line is never
78
+ * stripped.
79
+ */
80
+ export const ENV_LOCAL_KEY_NAME = "SKILLREPO_ACCESS_KEY";
81
+
82
+ /**
83
+ * VS Code input `id` owned by the CLI. Lives in the `inputs` array of
84
+ * `.vscode/mcp.json` alongside the server entry.
85
+ */
86
+ export const VSCODE_INPUT_ID = "skillrepo-api-key";
87
+
88
+ /**
89
+ * Substring that identifies a SessionStart hook command entry as
90
+ * SkillRepo-owned. The #884 installer writes a hook whose `command`
91
+ * field contains `skillrepo update --session-hook`; any entry whose
92
+ * command contains this substring is removed by the uninstall path.
93
+ *
94
+ * Exported so #884's installer can import and use the same constant —
95
+ * this is the module boundary that makes #884 depend on #885 rather
96
+ * than the other way around. The architect design (issue #884 section
97
+ * 5.3) notes the bidirectional-fingerprint requirement; centralizing
98
+ * it here enforces it at the language level.
99
+ */
100
+ export const SESSION_HOOK_FINGERPRINT = "skillrepo update --session-hook";
101
+
102
+ // ── Artifact descriptors ────────────────────────────────────────────
103
+
104
+ /**
105
+ * @typedef {Object} ArtifactDescriptor
106
+ * @property {string} id - Stable identifier. Shows up in --json output.
107
+ * @property {"project" | "global"} scope - Which teardown pass owns this.
108
+ * @property {"json-key" | "json-input" | "line" | "section" | "directory"} kind
109
+ * How the remover deletes this artifact.
110
+ * @property {() => string} pathFn - Resolves the on-disk path (absolute).
111
+ * @property {string} displayPath - Human-readable path for output (e.g.
112
+ * `~/.codeium/windsurf/mcp_config.json`). Never an absolute
113
+ * path — those leak the user's home directory.
114
+ * @property {string[]} [jsonPath] - For json-key: property path to delete.
115
+ * @property {string} [inputId] - For json-input: the id field to match.
116
+ * @property {string} [linePrefix] - For line: lines starting with this
117
+ * string are owned and removed.
118
+ * @property {string} [sectionHeader] - For section: the header line;
119
+ * every line under it up to a blank-line sentinel is owned.
120
+ * @property {string} [commandFingerprint] - For the SessionStart hook
121
+ * entry: `command` field contains this substring.
122
+ */
123
+
124
+ /**
125
+ * The complete v3.1.0 artifact list. Frozen so callers can't mutate
126
+ * the catalog at runtime — if a future feature needs to conditionally
127
+ * include/exclude entries, do the filter at consumption time, not by
128
+ * editing the module.
129
+ *
130
+ * Order matters for display: the uninstall command's pre-removal
131
+ * summary renders in this order, so keep "scope grouped and cheapest
132
+ * operation first" within each scope for a predictable UX.
133
+ */
134
+ export const ARTIFACT_REGISTRY = Object.freeze([
135
+ // ── Project scope ────────────────────────────────────────────────
136
+ Object.freeze({
137
+ id: "claude-mcp-entry",
138
+ scope: "project",
139
+ kind: "json-key",
140
+ pathFn: claudeMcpJson,
141
+ displayPath: ".mcp.json",
142
+ jsonPath: ["mcpServers", "skillrepo"],
143
+ }),
144
+ Object.freeze({
145
+ id: "cursor-mcp-entry",
146
+ scope: "project",
147
+ kind: "json-key",
148
+ pathFn: cursorMcpJson,
149
+ displayPath: ".cursor/mcp.json",
150
+ jsonPath: ["mcpServers", "skillrepo"],
151
+ }),
152
+ Object.freeze({
153
+ id: "vscode-mcp-entry",
154
+ scope: "project",
155
+ kind: "json-key",
156
+ pathFn: vscodeMcpJson,
157
+ displayPath: ".vscode/mcp.json",
158
+ jsonPath: ["servers", "skillrepo"],
159
+ }),
160
+ Object.freeze({
161
+ id: "vscode-mcp-input",
162
+ scope: "project",
163
+ kind: "json-input",
164
+ pathFn: vscodeMcpJson,
165
+ displayPath: ".vscode/mcp.json",
166
+ inputId: VSCODE_INPUT_ID,
167
+ }),
168
+ Object.freeze({
169
+ id: "env-local-key",
170
+ scope: "project",
171
+ kind: "line",
172
+ pathFn: envLocal,
173
+ displayPath: ".env.local",
174
+ linePrefix: `${ENV_LOCAL_KEY_NAME}=`,
175
+ }),
176
+ Object.freeze({
177
+ id: "gitignore-entries",
178
+ scope: "project",
179
+ kind: "section",
180
+ pathFn: gitignorePath,
181
+ displayPath: ".gitignore",
182
+ sectionHeader: GITIGNORE_SECTION_HEADER,
183
+ }),
184
+ Object.freeze({
185
+ id: "settings-session-hook",
186
+ scope: "project",
187
+ kind: "json-key",
188
+ pathFn: () => join(process.cwd(), ".claude", "settings.local.json"),
189
+ displayPath: ".claude/settings.local.json",
190
+ // Hook entries live in `hooks.SessionStart` as an array of
191
+ // objects. The remover filters the array by a predicate on the
192
+ // `command` field rather than by index, because array order is
193
+ // not load-bearing and user-authored hooks may precede or follow
194
+ // SkillRepo's entry.
195
+ commandFingerprint: SESSION_HOOK_FINGERPRINT,
196
+ }),
197
+ Object.freeze({
198
+ id: "skills-dir-project",
199
+ scope: "project",
200
+ kind: "directory",
201
+ pathFn: claudeSkillsProjectRoot,
202
+ displayPath: ".claude/skills/",
203
+ }),
204
+
205
+ // ── Global scope ─────────────────────────────────────────────────
206
+ Object.freeze({
207
+ id: "windsurf-mcp-entry",
208
+ scope: "global",
209
+ kind: "json-key",
210
+ pathFn: windsurfMcpJson,
211
+ displayPath: "~/.codeium/windsurf/mcp_config.json",
212
+ jsonPath: ["mcpServers", "skillrepo"],
213
+ }),
214
+ Object.freeze({
215
+ id: "skills-dir-global",
216
+ scope: "global",
217
+ kind: "directory",
218
+ pathFn: claudeSkillsGlobalRoot,
219
+ displayPath: "~/.claude/skills/",
220
+ }),
221
+ Object.freeze({
222
+ id: "global-config-dir",
223
+ scope: "global",
224
+ kind: "directory",
225
+ // `~/.claude/skillrepo/` — the parent dir of both config.json and
226
+ // .last-sync. Whole-directory removal is correct because both
227
+ // children are CLI-owned and there's no room for user content.
228
+ pathFn: () => join(homedir(), ".claude", "skillrepo"),
229
+ displayPath: "~/.claude/skillrepo/",
230
+ }),
231
+ Object.freeze({
232
+ // Global-scope counterpart to `settings-session-hook` above. Added
233
+ // in #884 alongside the session-sync feature: `skillrepo init
234
+ // --global` and `skillrepo session-sync enable --global` write the
235
+ // hook to the user-wide settings file, not the project-local one.
236
+ // Without this descriptor, `skillrepo uninstall --global` would
237
+ // miss the global hook — a real gap that `session-sync disable
238
+ // --global` could clean up but the project-scope uninstall could
239
+ // not. Mirrors the skills-dir-project / skills-dir-global pair.
240
+ id: "settings-session-hook-global",
241
+ scope: "global",
242
+ kind: "json-key",
243
+ pathFn: () => join(homedir(), ".claude", "settings.local.json"),
244
+ displayPath: "~/.claude/settings.local.json",
245
+ commandFingerprint: SESSION_HOOK_FINGERPRINT,
246
+ }),
247
+ ]);
248
+
249
+ /**
250
+ * Convenience filter by scope. Used by the uninstall command to
251
+ * partition the registry for the "project only" vs "project + global"
252
+ * passes.
253
+ */
254
+ export function artifactsByScope(scope) {
255
+ return ARTIFACT_REGISTRY.filter((a) => a.scope === scope);
256
+ }
257
+
258
+ /**
259
+ * Lookup by id. Returns `undefined` for unknown ids — callers should
260
+ * treat that as a programming error (registry lookup for an id that
261
+ * was never declared), not a user-visible failure.
262
+ */
263
+ export function artifactById(id) {
264
+ return ARTIFACT_REGISTRY.find((a) => a.id === id);
265
+ }
@@ -3,7 +3,16 @@
3
3
  * Creates directories as needed, handles errors cleanly.
4
4
  */
5
5
 
6
- import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, chmodSync } from "node:fs";
6
+ import {
7
+ readFileSync,
8
+ writeFileSync,
9
+ existsSync,
10
+ mkdirSync,
11
+ statSync,
12
+ chmodSync,
13
+ renameSync,
14
+ unlinkSync,
15
+ } from "node:fs";
7
16
  import { dirname } from "node:path";
8
17
 
9
18
  /**
@@ -50,3 +59,76 @@ export function writeExecutable(filePath, content) {
50
59
  export function pathExists(p) {
51
60
  return existsSync(p);
52
61
  }
62
+
63
+ /**
64
+ * Atomic write via temp-file + rename. Matches the pattern in
65
+ * `config.mjs`: write to `<path>.tmp`, then `renameSync` into place
66
+ * so the destination is never observed in a half-written state, and
67
+ * unlink the temp file on rename failure so a partial credential
68
+ * value is never left behind on disk.
69
+ *
70
+ * Use this (not `writeFileSafe`) for any module that modifies a user
71
+ * config file containing credentials or shared state. The uninstall
72
+ * removers (#885) touch `.env.local`, `.mcp.json`, and
73
+ * `settings.local.json` — all three benefit from atomic writes, the
74
+ * first two because of the credential-leak risk, the third because
75
+ * Claude Code parses it on startup and a half-written JSON would
76
+ * break the user's session.
77
+ *
78
+ * Parent-directory semantics match `writeFileSafe`: created
79
+ * recursively if missing. Directory-as-file collision is rejected
80
+ * before the temp-file is written.
81
+ *
82
+ * @param {string} filePath - Absolute path to the final destination.
83
+ * @param {string} content - UTF-8 content to persist.
84
+ * @param {object} [options]
85
+ * @param {number} [options.mode] - chmod applied to the temp file
86
+ * BEFORE rename so the destination never exists with looser
87
+ * permissions than intended. Skipped on Windows.
88
+ */
89
+ export function writeFileAtomic(filePath, content, { mode } = {}) {
90
+ const dir = dirname(filePath);
91
+ if (!existsSync(dir)) {
92
+ mkdirSync(dir, { recursive: true });
93
+ }
94
+ if (existsSync(filePath) && statSync(filePath).isDirectory()) {
95
+ throw new Error(`${filePath} is a directory, expected a file`);
96
+ }
97
+
98
+ const tmpPath = `${filePath}.tmp`;
99
+ try {
100
+ writeFileSync(tmpPath, content, "utf-8");
101
+ } catch (err) {
102
+ // Re-throw with a clearer message but preserve the cause for
103
+ // --verbose. A temp-file write failure is almost always a
104
+ // permissions or disk-full issue; the original error surfaces it.
105
+ throw new Error(`Cannot write ${tmpPath}: ${err.message}`, { cause: err });
106
+ }
107
+
108
+ if (mode !== undefined && process.platform !== "win32") {
109
+ try {
110
+ chmodSync(tmpPath, mode);
111
+ } catch {
112
+ // Non-fatal — same rationale as config.mjs: chmod failure
113
+ // doesn't corrupt the file, the destination just has looser
114
+ // permissions than intended. Callers that care can stat the
115
+ // file after the write.
116
+ }
117
+ }
118
+
119
+ try {
120
+ renameSync(tmpPath, filePath);
121
+ } catch (err) {
122
+ // Clean up the stale temp file so a partial credential value
123
+ // isn't left behind. Best-effort — if the unlink also fails,
124
+ // the original rename error is still what we surface.
125
+ try {
126
+ unlinkSync(tmpPath);
127
+ } catch {
128
+ /* best-effort */
129
+ }
130
+ throw new Error(`Cannot install ${filePath}: ${err.message}`, {
131
+ cause: err,
132
+ });
133
+ }
134
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * SessionStart hook installer for Claude Code (#884).
3
+ *
4
+ * The inverse partner of `src/lib/removers/settings.mjs` (#885). Both
5
+ * modules identify SkillRepo-owned hooks via the shared
6
+ * `SESSION_HOOK_FINGERPRINT` constant from `artifact-registry.mjs` —
7
+ * the single source of truth that keeps the installer's output and
8
+ * the remover's predicate in lockstep. The round-trip is verified by
9
+ * `src/test/mergers/session-hook.test.mjs`.
10
+ *
11
+ * ## What this installs
12
+ *
13
+ * A Claude Code SessionStart hook shaped as:
14
+ *
15
+ * {
16
+ * "hooks": {
17
+ * "SessionStart": [
18
+ * {
19
+ * "hooks": [
20
+ * { "type": "command", "command": "/abs/path/skillrepo update --session-hook 2>&1 || true" }
21
+ * ]
22
+ * }
23
+ * ]
24
+ * }
25
+ * }
26
+ *
27
+ * Claude Code invokes every hook in `hooks.SessionStart[*].hooks[*]`
28
+ * on session start. The command shape is load-bearing in three ways:
29
+ *
30
+ * 1. **Absolute path**: resolved at install time via `which skillrepo`.
31
+ * Claude Code's hook runner does not load the user's interactive
32
+ * shell profile, so relying on PATH would break for any user
33
+ * whose global `skillrepo` isn't on the minimal shell PATH.
34
+ *
35
+ * 2. **`--session-hook` flag**: tells `update` to honor the
36
+ * exit-0-on-all-errors contract. A sync failure must NEVER block
37
+ * a session start — offline, server 500, revoked key, or any
38
+ * other error → exit 0 with a single failure-message line.
39
+ *
40
+ * 3. **`|| true` shell backstop**: non-negotiable. If a bug in
41
+ * `--session-hook` ever causes non-zero exit, the shell layer
42
+ * still returns 0. Two layers of defense. Per handoff learning
43
+ * #9 — cannot be removed in a future refactor.
44
+ *
45
+ * ## Why settings.local.json (not settings.json)
46
+ *
47
+ * Per-developer. Claude Code's `settings.local.json` is gitignored
48
+ * (v3.0.0 init adds it to the ignore section at step 3). A team-
49
+ * shared hook in `settings.json` would either silently no-op for
50
+ * developers without `skillrepo` installed, or (worse) error on
51
+ * every session for them. Local-scope avoids both.
52
+ *
53
+ * ## Idempotency
54
+ *
55
+ * Re-running `skillrepo init` or `skillrepo session-sync enable`
56
+ * should produce the same end state as the first run. The installer:
57
+ * - writes fresh if no SkillRepo hook is present
58
+ * - updates in place if the fingerprint matches but the absolute
59
+ * path changed (e.g. user moved the binary)
60
+ * - no-ops if the exact command is already present
61
+ * Non-SkillRepo hooks and unrelated groups are never touched.
62
+ *
63
+ * ## Atomic writes
64
+ *
65
+ * settings.local.json is parsed by Claude Code at session start. A
66
+ * half-written file would break every future session until manually
67
+ * fixed. `writeFileAtomic` (temp file + rename + unlink-on-failure)
68
+ * guarantees either the new content is fully in place or the old
69
+ * content is fully preserved.
70
+ */
71
+
72
+ import { existsSync, readFileSync } from "node:fs";
73
+ import { execFileSync } from "node:child_process";
74
+ import { writeFileAtomic } from "../fs-utils.mjs";
75
+ import { SESSION_HOOK_FINGERPRINT } from "../artifact-registry.mjs";
76
+ import {
77
+ claudeSettingsLocal,
78
+ claudeSettingsLocalGlobal,
79
+ } from "../paths.mjs";
80
+ import { diskError, validationError } from "../errors.mjs";
81
+ import { removeSettingsSessionHook } from "../removers/settings.mjs";
82
+
83
+ /**
84
+ * Build the hook command string for a given absolute path. Exported
85
+ * so tests can assert the exact bytes the installer writes.
86
+ *
87
+ * @param {string} binaryPath - Absolute path to the `skillrepo` binary.
88
+ * @returns {string} The full shell command string.
89
+ */
90
+ export function buildHookCommand(binaryPath) {
91
+ if (typeof binaryPath !== "string" || binaryPath.length === 0) {
92
+ throw validationError(
93
+ "buildHookCommand: binaryPath must be a non-empty string.",
94
+ );
95
+ }
96
+ return `${binaryPath} update --session-hook 2>&1 || true`;
97
+ }
98
+
99
+ /**
100
+ * Resolve the absolute path of the `skillrepo` binary via `which`.
101
+ * Returns null if resolution fails (e.g. user ran `npx skillrepo init`
102
+ * without a global install) — the caller should skip hook installation
103
+ * with a clear warning rather than fail init.
104
+ *
105
+ * @returns {string | null}
106
+ */
107
+ export function resolveSkillrepoBinary() {
108
+ try {
109
+ // 3-second timeout — `which` typically returns in milliseconds,
110
+ // but a PATH that includes a network filesystem or a `which`
111
+ // alias that does I/O could hang indefinitely. Bounding the
112
+ // call ensures `skillrepo init` never stalls on binary
113
+ // resolution. Per code-reviewer round-1 LOW finding.
114
+ const result = execFileSync("which", ["skillrepo"], {
115
+ encoding: "utf-8",
116
+ stdio: ["ignore", "pipe", "ignore"],
117
+ timeout: 3000,
118
+ }).trim();
119
+ if (!result) return null;
120
+ // Sanity: the resolved path must be absolute. A relative result
121
+ // would be meaningless at session-start time because the Claude
122
+ // Code hook runner's cwd is undefined.
123
+ if (!result.startsWith("/")) return null;
124
+ return result;
125
+ } catch {
126
+ // `which` exits non-zero if the binary isn't found. Treat as
127
+ // "no global install, skip session-sync" — caller handles.
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Install (or update) the SkillRepo SessionStart hook. Creates the
134
+ * settings.local.json file if it doesn't exist.
135
+ *
136
+ * @param {object} [options]
137
+ * @param {string} [options.binaryPath] - Absolute path to `skillrepo`.
138
+ * Default: resolved via `which skillrepo`. Passing explicitly
139
+ * is used by tests to inject a deterministic path.
140
+ * @param {boolean} [options.global=false] - When true, installs to the
141
+ * user-global `~/.claude/settings.local.json` instead of the
142
+ * project-local file.
143
+ * @returns {{
144
+ * path: string;
145
+ * action: "installed" | "updated" | "unchanged" | "skipped";
146
+ * reason?: string;
147
+ * command?: string;
148
+ * }}
149
+ * `action`:
150
+ * - `"installed"` — no prior SkillRepo hook, we added one
151
+ * - `"updated"` — prior SkillRepo hook existed but the
152
+ * command differed (e.g. binary path changed); replaced
153
+ * in place
154
+ * - `"unchanged"` — exact command already present; no-op
155
+ * - `"skipped"` — prerequisite missing (no binary path, file
156
+ * corrupt, etc.). `reason` carries the human message.
157
+ */
158
+ export function mergeSessionHook({
159
+ binaryPath: binaryPathOpt,
160
+ global = false,
161
+ } = {}) {
162
+ const filePath = global ? claudeSettingsLocalGlobal() : claudeSettingsLocal();
163
+ const displayPath = global
164
+ ? "~/.claude/settings.local.json"
165
+ : ".claude/settings.local.json";
166
+
167
+ const binaryPath = binaryPathOpt ?? resolveSkillrepoBinary();
168
+ if (!binaryPath) {
169
+ return {
170
+ path: displayPath,
171
+ action: "skipped",
172
+ reason:
173
+ "Could not resolve a stable path for `skillrepo`. Session sync requires a global install. Run `npm install -g skillrepo` and re-run `skillrepo session-sync enable`.",
174
+ };
175
+ }
176
+
177
+ const desiredCommand = buildHookCommand(binaryPath);
178
+
179
+ // Parse existing file (or start fresh). A corrupt-but-present file
180
+ // is a hard error: silently overwriting it would destroy any user-
181
+ // authored hooks we can't read.
182
+ let config = {};
183
+ if (existsSync(filePath)) {
184
+ const raw = readFileSync(filePath, "utf-8");
185
+ if (raw.trim().length > 0) {
186
+ try {
187
+ config = JSON.parse(raw);
188
+ } catch (err) {
189
+ throw diskError(
190
+ `Cannot parse ${displayPath}: ${err.message}. Fix or delete the file, then re-run.`,
191
+ { cause: err },
192
+ );
193
+ }
194
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
195
+ throw diskError(
196
+ `${displayPath} must be a JSON object at the top level.`,
197
+ );
198
+ }
199
+ }
200
+ }
201
+
202
+ // Walk `hooks.SessionStart[i].hooks[j].command` looking for an
203
+ // existing SkillRepo entry (fingerprint-matched). This MUST mirror
204
+ // the remover's walk in src/lib/removers/settings.mjs — the shared
205
+ // SESSION_HOOK_FINGERPRINT import is the mechanism that keeps
206
+ // them in lockstep. The round-trip test in session-hook.test.mjs
207
+ // locks the contract in.
208
+ if (!config.hooks || typeof config.hooks !== "object") {
209
+ config.hooks = {};
210
+ }
211
+ if (!Array.isArray(config.hooks.SessionStart)) {
212
+ config.hooks.SessionStart = [];
213
+ }
214
+
215
+ let foundAction = null; // null → install fresh
216
+ for (const group of config.hooks.SessionStart) {
217
+ if (!group || typeof group !== "object" || !Array.isArray(group.hooks)) {
218
+ continue;
219
+ }
220
+ for (const inner of group.hooks) {
221
+ if (
222
+ inner &&
223
+ typeof inner === "object" &&
224
+ typeof inner.command === "string" &&
225
+ inner.command.includes(SESSION_HOOK_FINGERPRINT)
226
+ ) {
227
+ if (inner.command === desiredCommand) {
228
+ foundAction = "unchanged";
229
+ } else {
230
+ inner.command = desiredCommand;
231
+ // Also normalize the `type` field in case an older format
232
+ // was present (or the entry was hand-edited).
233
+ inner.type = "command";
234
+ foundAction = "updated";
235
+ }
236
+ break;
237
+ }
238
+ }
239
+ if (foundAction) break;
240
+ }
241
+
242
+ if (!foundAction) {
243
+ // Fresh install — append a new group with a single hook. We
244
+ // append (not prepend) so user-authored groups retain their
245
+ // relative order. Claude Code fires all groups in sequence.
246
+ config.hooks.SessionStart.push({
247
+ hooks: [{ type: "command", command: desiredCommand }],
248
+ });
249
+ foundAction = "installed";
250
+ }
251
+
252
+ if (foundAction === "unchanged") {
253
+ // No-op. Skip the write so we don't touch mtime for nothing.
254
+ return {
255
+ path: displayPath,
256
+ action: "unchanged",
257
+ command: desiredCommand,
258
+ };
259
+ }
260
+
261
+ writeFileAtomic(filePath, JSON.stringify(config, null, 2) + "\n");
262
+
263
+ return {
264
+ path: displayPath,
265
+ action: foundAction,
266
+ command: desiredCommand,
267
+ };
268
+ }
269
+
270
+ /**
271
+ * Remove the SkillRepo SessionStart hook from settings.local.json.
272
+ *
273
+ * Thin adapter over `removeSettingsSessionHook` from
274
+ * `src/lib/removers/settings.mjs`. The consolidation to a single
275
+ * source of truth was the architect's round-1 priority tightening:
276
+ * before this PR, two separate implementations of the same walk
277
+ * existed (one here, one in settings.mjs) and they had already
278
+ * diverged in one observable behavior. Keeping both was a genuine
279
+ * maintenance hazard.
280
+ *
281
+ * This wrapper only forwards to the settings remover — it exists
282
+ * for the `session-sync disable` command to have a single import
283
+ * surface aligned with its installer counterpart (`mergeSessionHook`
284
+ * in this module). The actual logic lives in settings.mjs.
285
+ *
286
+ * @param {object} [options]
287
+ * @param {boolean} [options.global=false] - Forwarded to the
288
+ * settings remover; operates on `~/.claude/settings.local.json`
289
+ * when true.
290
+ * @returns {{
291
+ * path: string;
292
+ * action: "removed" | "would-remove" | "skipped" | "unchanged";
293
+ * error?: string;
294
+ * }}
295
+ */
296
+ export function removeSessionHook({ global = false } = {}) {
297
+ return removeSettingsSessionHook({ global });
298
+ }
package/src/lib/paths.mjs CHANGED
@@ -75,3 +75,24 @@ export const envLocal = () => join(cwd(), ".env.local");
75
75
  * project /skills/ fallback directory is gitignored on first write.
76
76
  */
77
77
  export const gitignorePath = () => join(cwd(), ".gitignore");
78
+
79
+ // ── Claude Code settings ──────────────────────────────────────────────
80
+ //
81
+ // settings.local.json is the per-developer, per-project Claude Code
82
+ // settings file. It's gitignored (the init flow adds it to .gitignore
83
+ // at step 3), so the SessionStart hook added by #884 lives there
84
+ // rather than in settings.json — that keeps each developer's sync
85
+ // behavior independent even when they share a repo. Claude Code
86
+ // applies both settings.json (checked in) and settings.local.json
87
+ // (gitignored) with local taking precedence.
88
+ //
89
+ // The global variant at ~/.claude/settings.local.json is the user-
90
+ // wide equivalent, used when init runs with --global.
91
+
92
+ /** Project-local Claude Code settings file (per-developer, gitignored). */
93
+ export const claudeSettingsLocal = () =>
94
+ join(cwd(), ".claude", "settings.local.json");
95
+
96
+ /** User-wide Claude Code settings file. Used by `init --global`. */
97
+ export const claudeSettingsLocalGlobal = () =>
98
+ join(homedir(), ".claude", "settings.local.json");