skillrepo 3.0.0 → 3.1.1

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 (52) hide show
  1. package/README.md +74 -6
  2. package/bin/skillrepo.mjs +14 -0
  3. package/package.json +1 -1
  4. package/src/commands/init.mjs +184 -19
  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 +305 -0
  10. package/src/lib/cli-config.mjs +78 -0
  11. package/src/lib/config.mjs +6 -3
  12. package/src/lib/file-write.mjs +8 -3
  13. package/src/lib/fs-utils.mjs +90 -9
  14. package/src/lib/mergers/session-hook.mjs +378 -0
  15. package/src/lib/paths.mjs +21 -0
  16. package/src/lib/platform.mjs +124 -0
  17. package/src/lib/removers/claude-mcp.mjs +67 -0
  18. package/src/lib/removers/cursor-mcp.mjs +60 -0
  19. package/src/lib/removers/env-local.mjs +55 -0
  20. package/src/lib/removers/gitignore.mjs +108 -0
  21. package/src/lib/removers/settings.mjs +183 -0
  22. package/src/lib/removers/vscode-mcp.mjs +87 -0
  23. package/src/lib/removers/windsurf-mcp.mjs +65 -0
  24. package/src/lib/sync.mjs +26 -0
  25. package/src/test/commands/add.test.mjs +10 -4
  26. package/src/test/commands/get.test.mjs +10 -4
  27. package/src/test/commands/init.test.mjs +428 -4
  28. package/src/test/commands/list.test.mjs +10 -4
  29. package/src/test/commands/remove.test.mjs +10 -4
  30. package/src/test/commands/search.test.mjs +10 -4
  31. package/src/test/commands/session-sync.test.mjs +352 -0
  32. package/src/test/commands/uninstall.test.mjs +774 -0
  33. package/src/test/commands/update.test.mjs +168 -4
  34. package/src/test/helpers/sandbox-home.mjs +161 -0
  35. package/src/test/helpers/skillrepo-shim.mjs +133 -0
  36. package/src/test/integration/file-write.integration.test.mjs +10 -4
  37. package/src/test/lib/artifact-registry.test.mjs +268 -0
  38. package/src/test/lib/cli-config.test.mjs +126 -5
  39. package/src/test/lib/config.test.mjs +10 -4
  40. package/src/test/lib/file-write.test.mjs +24 -10
  41. package/src/test/lib/mcp-merge.test.mjs +10 -4
  42. package/src/test/lib/paths.test.mjs +10 -4
  43. package/src/test/lib/platform.test.mjs +135 -0
  44. package/src/test/lib/sync.test.mjs +20 -4
  45. package/src/test/mergers/session-hook.test.mjs +1175 -0
  46. package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
  47. package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
  48. package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
  49. package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
  50. package/src/test/mergers/uninstall-settings.test.mjs +296 -0
  51. package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
  52. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +128 -0
@@ -0,0 +1,305 @@
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 ends with `<binary-path> update --session-hook ...`; any
92
+ * entry whose command contains ` update --session-hook` (with the
93
+ * leading space) is removed by the uninstall path.
94
+ *
95
+ * The leading space is a lightweight word boundary — it requires
96
+ * that `update` is preceded by whitespace (i.e. it's an argv token
97
+ * after the binary path), not a suffix of a longer identifier like
98
+ * `toolupdate` or `postupdate`. Without the space, a hypothetical
99
+ * binary at `/usr/local/bin/myapp-update` invoked with
100
+ * `--session-hook` as `/usr/local/bin/myapp-update --session-hook`
101
+ * would NOT match (because the substring would be `-update
102
+ * --session-hook`, not ` update --session-hook`), whereas a naive
103
+ * `update --session-hook` fingerprint would have.
104
+ *
105
+ * The leading space does NOT eliminate all false-positive classes.
106
+ * A command like `brew update --session-hook` DOES match the
107
+ * fingerprint — the space between `brew` and `update` is exactly
108
+ * what we key on. The primary protection against real-world false
109
+ * positives is the specificity of the two-token combination
110
+ * `update --session-hook` itself: `--session-hook` is not a
111
+ * conventional flag name used by tools other than SkillRepo, so the
112
+ * chance of a coincidental match is astronomically low. The test
113
+ * at `session-hook.test.mjs` "the fingerprint is specific enough
114
+ * that innocuous user hooks do NOT match it" enumerates plausible
115
+ * user-hook commands and confirms none trip the predicate.
116
+ *
117
+ * The fingerprint is also deliberately platform-neutral. Earlier
118
+ * versions matched the longer `skillrepo update --session-hook`
119
+ * substring, but that pattern silently fails to match Windows hook
120
+ * commands because npm installs the CLI as a `.cmd` shim — the
121
+ * absolute path on Windows ends `...\skillrepo.cmd`, which puts the
122
+ * `.cmd` extension between `skillrepo` and `update` in the command
123
+ * string. The shorter ` update --session-hook` substring is present
124
+ * on both:
125
+ * POSIX: `/usr/local/bin/skillrepo update --session-hook 2>&1 || true`
126
+ * Windows: `C:\path\skillrepo.cmd update --session-hook 2>&1`
127
+ *
128
+ * Backward-compat: any v3.1.0 hook contains
129
+ * `skillrepo update --session-hook`, which is a strict superset of
130
+ * ` update --session-hook` (the space between `skillrepo` and `update`
131
+ * is the space we're matching). So upgrades still correctly identify
132
+ * and update the old entry in place.
133
+ *
134
+ * Exported so #884's installer can import and use the same constant —
135
+ * this is the module boundary that makes #884 depend on #885 rather
136
+ * than the other way around. The architect design (issue #884 section
137
+ * 5.3) notes the bidirectional-fingerprint requirement; centralizing
138
+ * it here enforces it at the language level.
139
+ */
140
+ export const SESSION_HOOK_FINGERPRINT = " update --session-hook";
141
+
142
+ // ── Artifact descriptors ────────────────────────────────────────────
143
+
144
+ /**
145
+ * @typedef {Object} ArtifactDescriptor
146
+ * @property {string} id - Stable identifier. Shows up in --json output.
147
+ * @property {"project" | "global"} scope - Which teardown pass owns this.
148
+ * @property {"json-key" | "json-input" | "line" | "section" | "directory"} kind
149
+ * How the remover deletes this artifact.
150
+ * @property {() => string} pathFn - Resolves the on-disk path (absolute).
151
+ * @property {string} displayPath - Human-readable path for output (e.g.
152
+ * `~/.codeium/windsurf/mcp_config.json`). Never an absolute
153
+ * path — those leak the user's home directory.
154
+ * @property {string[]} [jsonPath] - For json-key: property path to delete.
155
+ * @property {string} [inputId] - For json-input: the id field to match.
156
+ * @property {string} [linePrefix] - For line: lines starting with this
157
+ * string are owned and removed.
158
+ * @property {string} [sectionHeader] - For section: the header line;
159
+ * every line under it up to a blank-line sentinel is owned.
160
+ * @property {string} [commandFingerprint] - For the SessionStart hook
161
+ * entry: `command` field contains this substring.
162
+ */
163
+
164
+ /**
165
+ * The complete v3.1.0 artifact list. Frozen so callers can't mutate
166
+ * the catalog at runtime — if a future feature needs to conditionally
167
+ * include/exclude entries, do the filter at consumption time, not by
168
+ * editing the module.
169
+ *
170
+ * Order matters for display: the uninstall command's pre-removal
171
+ * summary renders in this order, so keep "scope grouped and cheapest
172
+ * operation first" within each scope for a predictable UX.
173
+ */
174
+ export const ARTIFACT_REGISTRY = Object.freeze([
175
+ // ── Project scope ────────────────────────────────────────────────
176
+ Object.freeze({
177
+ id: "claude-mcp-entry",
178
+ scope: "project",
179
+ kind: "json-key",
180
+ pathFn: claudeMcpJson,
181
+ displayPath: ".mcp.json",
182
+ jsonPath: ["mcpServers", "skillrepo"],
183
+ }),
184
+ Object.freeze({
185
+ id: "cursor-mcp-entry",
186
+ scope: "project",
187
+ kind: "json-key",
188
+ pathFn: cursorMcpJson,
189
+ displayPath: ".cursor/mcp.json",
190
+ jsonPath: ["mcpServers", "skillrepo"],
191
+ }),
192
+ Object.freeze({
193
+ id: "vscode-mcp-entry",
194
+ scope: "project",
195
+ kind: "json-key",
196
+ pathFn: vscodeMcpJson,
197
+ displayPath: ".vscode/mcp.json",
198
+ jsonPath: ["servers", "skillrepo"],
199
+ }),
200
+ Object.freeze({
201
+ id: "vscode-mcp-input",
202
+ scope: "project",
203
+ kind: "json-input",
204
+ pathFn: vscodeMcpJson,
205
+ displayPath: ".vscode/mcp.json",
206
+ inputId: VSCODE_INPUT_ID,
207
+ }),
208
+ Object.freeze({
209
+ id: "env-local-key",
210
+ scope: "project",
211
+ kind: "line",
212
+ pathFn: envLocal,
213
+ displayPath: ".env.local",
214
+ linePrefix: `${ENV_LOCAL_KEY_NAME}=`,
215
+ }),
216
+ Object.freeze({
217
+ id: "gitignore-entries",
218
+ scope: "project",
219
+ kind: "section",
220
+ pathFn: gitignorePath,
221
+ displayPath: ".gitignore",
222
+ sectionHeader: GITIGNORE_SECTION_HEADER,
223
+ }),
224
+ Object.freeze({
225
+ id: "settings-session-hook",
226
+ scope: "project",
227
+ kind: "json-key",
228
+ pathFn: () => join(process.cwd(), ".claude", "settings.local.json"),
229
+ displayPath: ".claude/settings.local.json",
230
+ // Hook entries live in `hooks.SessionStart` as an array of
231
+ // objects. The remover filters the array by a predicate on the
232
+ // `command` field rather than by index, because array order is
233
+ // not load-bearing and user-authored hooks may precede or follow
234
+ // SkillRepo's entry.
235
+ commandFingerprint: SESSION_HOOK_FINGERPRINT,
236
+ }),
237
+ Object.freeze({
238
+ id: "skills-dir-project",
239
+ scope: "project",
240
+ kind: "directory",
241
+ pathFn: claudeSkillsProjectRoot,
242
+ displayPath: ".claude/skills/",
243
+ }),
244
+
245
+ // ── Global scope ─────────────────────────────────────────────────
246
+ Object.freeze({
247
+ id: "windsurf-mcp-entry",
248
+ scope: "global",
249
+ kind: "json-key",
250
+ pathFn: windsurfMcpJson,
251
+ displayPath: "~/.codeium/windsurf/mcp_config.json",
252
+ jsonPath: ["mcpServers", "skillrepo"],
253
+ }),
254
+ Object.freeze({
255
+ id: "skills-dir-global",
256
+ scope: "global",
257
+ kind: "directory",
258
+ pathFn: claudeSkillsGlobalRoot,
259
+ displayPath: "~/.claude/skills/",
260
+ }),
261
+ Object.freeze({
262
+ id: "global-config-dir",
263
+ scope: "global",
264
+ kind: "directory",
265
+ // `~/.claude/skillrepo/` — the parent dir of both config.json and
266
+ // .last-sync. Whole-directory removal is correct because both
267
+ // children are CLI-owned and there's no room for user content.
268
+ pathFn: () => join(homedir(), ".claude", "skillrepo"),
269
+ displayPath: "~/.claude/skillrepo/",
270
+ }),
271
+ Object.freeze({
272
+ // Global-scope counterpart to `settings-session-hook` above. Added
273
+ // in #884 alongside the session-sync feature: `skillrepo init
274
+ // --global` and `skillrepo session-sync enable --global` write the
275
+ // hook to the user-wide settings file, not the project-local one.
276
+ // Without this descriptor, `skillrepo uninstall --global` would
277
+ // miss the global hook — a real gap that `session-sync disable
278
+ // --global` could clean up but the project-scope uninstall could
279
+ // not. Mirrors the skills-dir-project / skills-dir-global pair.
280
+ id: "settings-session-hook-global",
281
+ scope: "global",
282
+ kind: "json-key",
283
+ pathFn: () => join(homedir(), ".claude", "settings.local.json"),
284
+ displayPath: "~/.claude/settings.local.json",
285
+ commandFingerprint: SESSION_HOOK_FINGERPRINT,
286
+ }),
287
+ ]);
288
+
289
+ /**
290
+ * Convenience filter by scope. Used by the uninstall command to
291
+ * partition the registry for the "project only" vs "project + global"
292
+ * passes.
293
+ */
294
+ export function artifactsByScope(scope) {
295
+ return ARTIFACT_REGISTRY.filter((a) => a.scope === scope);
296
+ }
297
+
298
+ /**
299
+ * Lookup by id. Returns `undefined` for unknown ids — callers should
300
+ * treat that as a programming error (registry lookup for an id that
301
+ * was never declared), not a user-visible failure.
302
+ */
303
+ export function artifactById(id) {
304
+ return ARTIFACT_REGISTRY.find((a) => a.id === id);
305
+ }
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * Shared credential + flag resolution for command modules.
3
3
  *
4
+ * Also houses process-environment helpers that multiple command
5
+ * modules need — specifically `isNpxInvocation()` which several
6
+ * surfaces use to decide whether the user has a stable global
7
+ * install or is running a transient npx download.
8
+ *
4
9
  * Every command needs to:
5
10
  * 1. Resolve `--key`/`--url`/`--ide`/`--global`/`--json` flags
6
11
  * 2. Fall back to ~/.claude/skillrepo/config.json
@@ -21,6 +26,70 @@ import { authError, validationError } from "./errors.mjs";
21
26
  const VALID_VENDORS = new Set(["claudeCode", "cursor", "windsurf", "vscode"]);
22
27
  const VENDOR_ALIASES = { claude: "claudeCode" };
23
28
 
29
+ /**
30
+ * True when the current process was launched via `npx skillrepo ...`
31
+ * rather than from a stable global install.
32
+ *
33
+ * Why this matters:
34
+ *
35
+ * - `npx skillrepo init` downloads the package into `~/.npm/_npx/<hash>/`
36
+ * and exposes its `.bin/skillrepo` on PATH for the subprocess only.
37
+ * `execFileSync("which", ["skillrepo"])` DOES find that path, but it
38
+ * is a transient cache location. npm eviction, a version bump, or
39
+ * `npm cache clean` later invalidates the absolute path, so any
40
+ * on-disk reference to it (e.g. a SessionStart hook command baked
41
+ * in at install time) silently breaks.
42
+ *
43
+ * - The architect design for #884 explicitly specified that npx
44
+ * users should skip the session-sync step with a "requires a global
45
+ * install" warning. The `which`-based resolver in
46
+ * `mergers/session-hook.mjs` alone is too permissive — it finds the
47
+ * npx cache path and treats it as stable. This helper closes that
48
+ * gap by detecting npx unambiguously.
49
+ *
50
+ * - `init`'s "Next steps" output also needs to know: under npx, the
51
+ * right hint is `npx skillrepo list` (or "install globally first"),
52
+ * not bare `skillrepo list` (which would fail for the user).
53
+ *
54
+ * Detection uses two signals, either one sufficient:
55
+ *
56
+ * 1. `process.argv[1]` contains `/_npx/` (or Windows `\_npx\`) —
57
+ * the primary signal. npx-launched scripts literally live inside
58
+ * `~/.npm/_npx/<hash>/node_modules/.bin/...` so the executable
59
+ * path itself names the cache directory. Highest reliability,
60
+ * no false-positive surface.
61
+ *
62
+ * 2. `process.env._` ends with `/npx` (or `\npx` on Windows) —
63
+ * legacy fallback for shells that set `_` to the launched
64
+ * command. Defensive against shim layouts where argv[1] has
65
+ * been symlinked through a path that doesn't contain `_npx`.
66
+ *
67
+ * Why NOT `process.env.npm_command === "exec"`: this signal was
68
+ * considered but rejected in v3.1.1 review. `npm_command=exec` is
69
+ * also set when a stable-install user runs `skillrepo init` from a
70
+ * `package.json` lifecycle script (e.g. `"postinstall": "skillrepo
71
+ * init --yes"`) or invokes `npm exec skillrepo ...` directly. In
72
+ * those cases the user has a real global install and should NOT
73
+ * have session-sync skipped or see `npx skillrepo` in Next Steps.
74
+ * The argv[1] signal already catches real npx invocations
75
+ * unambiguously; adding npm_command trades a minor coverage gain
76
+ * (shim layouts) for a false-positive surface that affects real
77
+ * users. See v3.1.1 PR review cycle for the full discussion.
78
+ *
79
+ * @returns {boolean}
80
+ */
81
+ export function isNpxInvocation() {
82
+ const execPath = process.argv[1] ?? "";
83
+ if (execPath.includes("/_npx/") || execPath.includes("\\_npx\\")) {
84
+ return true;
85
+ }
86
+ const underscore = process.env._ ?? "";
87
+ if (underscore.endsWith("/npx") || underscore.endsWith("\\npx")) {
88
+ return true;
89
+ }
90
+ return false;
91
+ }
92
+
24
93
  /**
25
94
  * @typedef {Object} ResolvedFlags
26
95
  * @property {string} serverUrl
@@ -85,6 +154,15 @@ export function resolveFlags(argv, opts = {}) {
85
154
  } else if (arg === "--help" || arg === "-h") {
86
155
  // Dispatcher should have intercepted this. Defensive no-op.
87
156
  continue;
157
+ } else if (arg === "--verbose") {
158
+ // Global flag set by the dispatcher into SKILLREPO_VERBOSE=1
159
+ // so http.mjs's retry logger can honor it. It's a first-class
160
+ // flag, not an unknown arg — accept it silently in every
161
+ // command that passes through resolveFlags. Before this
162
+ // branch existed, any command that consumed argv via
163
+ // resolveFlags rejected `--verbose` with "Unknown argument",
164
+ // breaking the flag documented in the top-level --help.
165
+ continue;
88
166
  } else {
89
167
  // Allow the caller to consume a positional arg before we treat
90
168
  // it as unknown. This is how `get @owner/name` and
@@ -43,10 +43,10 @@ import {
43
43
  unlinkSync,
44
44
  } from "node:fs";
45
45
  import { dirname } from "node:path";
46
- import { platform } from "node:os";
47
46
 
48
47
  import { globalConfigPath } from "./paths.mjs";
49
48
  import { diskError, validationError } from "./errors.mjs";
49
+ import { platformConventions } from "./platform.mjs";
50
50
 
51
51
  /**
52
52
  * Current schema version. Bump this on any structural change.
@@ -187,8 +187,11 @@ export function writeConfig(config) {
187
187
 
188
188
  // chmod the temp file before renaming so the destination never
189
189
  // exists with world-readable perms (which would be a brief
190
- // credential leak window on a shared system).
191
- if (platform() !== "win32") {
190
+ // credential leak window on a shared system). Windows callers
191
+ // route through platformConventions().supportsPosixPermissions
192
+ // see platform.mjs for why we skip chmod there instead of pretend-
193
+ // applying it.
194
+ if (platformConventions().supportsPosixPermissions) {
192
195
  try {
193
196
  chmodSync(tmpPath, 0o600);
194
197
  } catch {
@@ -50,7 +50,6 @@ import {
50
50
  statSync,
51
51
  } from "node:fs";
52
52
  import { dirname, join, isAbsolute, relative } from "node:path";
53
- import { platform } from "node:os";
54
53
 
55
54
  import { readFileSafe, writeFileSafe } from "./fs-utils.mjs";
56
55
  import {
@@ -63,6 +62,7 @@ import {
63
62
  gitignorePath,
64
63
  } from "./paths.mjs";
65
64
  import { CliError, validationError, diskError } from "./errors.mjs";
65
+ import { platformConventions } from "./platform.mjs";
66
66
 
67
67
  // ── Constants (mirror the server-side validators in src/lib/skills/) ────
68
68
 
@@ -562,8 +562,13 @@ function writeSkillToDir(skill, targetDir) {
562
562
  }
563
563
  }
564
564
 
565
- // 2 + 3 + 4: rename dance (POSIX atomic on same filesystem; best-effort on Windows)
566
- if (platform() === "win32") {
565
+ // 2 + 3 + 4: rename dance. POSIX is atomic on the same filesystem;
566
+ // Windows has to do remove-then-rename because renameSync fails on
567
+ // existing directory targets. The split is named via
568
+ // platformConventions().supportsAtomicDirectoryRename so the intent
569
+ // reads as a capability check, not a platform check. See
570
+ // platform.mjs for the rationale.
571
+ if (!platformConventions().supportsAtomicDirectoryRename) {
567
572
  // Windows: rename fails on existing destinations and locked files,
568
573
  // so we fall back to remove-then-rename. There is a window where
569
574
  // the live target is gone but the rename has not yet completed.
@@ -3,8 +3,18 @@
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";
17
+ import { platformConventions } from "./platform.mjs";
8
18
 
9
19
  /**
10
20
  * Read a file as UTF-8, returning null if it doesn't exist.
@@ -36,17 +46,88 @@ export function writeFileSafe(filePath, content) {
36
46
  }
37
47
 
38
48
  /**
39
- * Write a file and mark it executable (0o755).
40
- * Used for the Cursor session hook which is invoked directly via shebang.
49
+ * Check if a path exists (file or directory).
41
50
  */
42
- export function writeExecutable(filePath, content) {
43
- writeFileSafe(filePath, content);
44
- chmodSync(filePath, 0o755);
51
+ export function pathExists(p) {
52
+ return existsSync(p);
45
53
  }
46
54
 
47
55
  /**
48
- * Check if a path exists (file or directory).
56
+ * Atomic write via temp-file + rename. Matches the pattern in
57
+ * `config.mjs`: write to `<path>.tmp`, then `renameSync` into place
58
+ * so the destination is never observed in a half-written state, and
59
+ * unlink the temp file on rename failure so a partial credential
60
+ * value is never left behind on disk.
61
+ *
62
+ * Use this (not `writeFileSafe`) for any module that modifies a user
63
+ * config file containing credentials or shared state. The uninstall
64
+ * removers (#885) touch `.env.local`, `.mcp.json`, and
65
+ * `settings.local.json` — all three benefit from atomic writes, the
66
+ * first two because of the credential-leak risk, the third because
67
+ * Claude Code parses it on startup and a half-written JSON would
68
+ * break the user's session.
69
+ *
70
+ * Parent-directory semantics match `writeFileSafe`: created
71
+ * recursively if missing. Directory-as-file collision is rejected
72
+ * before the temp-file is written.
73
+ *
74
+ * @param {string} filePath - Absolute path to the final destination.
75
+ * @param {string} content - UTF-8 content to persist.
76
+ * @param {object} [options]
77
+ * @param {number} [options.mode] - chmod applied to the temp file
78
+ * BEFORE rename so the destination never exists with looser
79
+ * permissions than intended. Skipped on Windows.
49
80
  */
50
- export function pathExists(p) {
51
- return existsSync(p);
81
+ export function writeFileAtomic(filePath, content, { mode } = {}) {
82
+ const dir = dirname(filePath);
83
+ if (!existsSync(dir)) {
84
+ mkdirSync(dir, { recursive: true });
85
+ }
86
+ if (existsSync(filePath) && statSync(filePath).isDirectory()) {
87
+ throw new Error(`${filePath} is a directory, expected a file`);
88
+ }
89
+
90
+ const tmpPath = `${filePath}.tmp`;
91
+ try {
92
+ writeFileSync(tmpPath, content, "utf-8");
93
+ } catch (err) {
94
+ // Re-throw with a clearer message but preserve the cause for
95
+ // --verbose. A temp-file write failure is almost always a
96
+ // permissions or disk-full issue; the original error surfaces it.
97
+ throw new Error(`Cannot write ${tmpPath}: ${err.message}`, { cause: err });
98
+ }
99
+
100
+ if (mode !== undefined && platformConventions().supportsPosixPermissions) {
101
+ try {
102
+ chmodSync(tmpPath, mode);
103
+ } catch {
104
+ // Non-fatal — same rationale as config.mjs: chmod failure
105
+ // doesn't corrupt the file, the destination just has looser
106
+ // permissions than intended. Callers that care can stat the
107
+ // file after the write.
108
+ }
109
+ }
110
+ // On Windows we deliberately skip chmod entirely. Node lets the call
111
+ // succeed on Windows but the mode bits don't map to anything the
112
+ // ACL layer enforces, so a "success" return would mislead the caller
113
+ // into thinking the credential file is access-restricted when it
114
+ // isn't. Windows users needing per-user protection should rely on
115
+ // %APPDATA%'s inherited ACLs (which default to the current user) or
116
+ // apply DACL restrictions at the OS level — outside this CLI's scope.
117
+
118
+ try {
119
+ renameSync(tmpPath, filePath);
120
+ } catch (err) {
121
+ // Clean up the stale temp file so a partial credential value
122
+ // isn't left behind. Best-effort — if the unlink also fails,
123
+ // the original rename error is still what we surface.
124
+ try {
125
+ unlinkSync(tmpPath);
126
+ } catch {
127
+ /* best-effort */
128
+ }
129
+ throw new Error(`Cannot install ${filePath}: ${err.message}`, {
130
+ cause: err,
131
+ });
132
+ }
52
133
  }