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.
- package/README.md +74 -6
- package/bin/skillrepo.mjs +14 -0
- package/package.json +1 -1
- package/src/commands/init.mjs +184 -19
- 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 +305 -0
- package/src/lib/cli-config.mjs +78 -0
- package/src/lib/config.mjs +6 -3
- package/src/lib/file-write.mjs +8 -3
- package/src/lib/fs-utils.mjs +90 -9
- package/src/lib/mergers/session-hook.mjs +378 -0
- package/src/lib/paths.mjs +21 -0
- package/src/lib/platform.mjs +124 -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/lib/sync.mjs +26 -0
- package/src/test/commands/add.test.mjs +10 -4
- package/src/test/commands/get.test.mjs +10 -4
- package/src/test/commands/init.test.mjs +428 -4
- package/src/test/commands/list.test.mjs +10 -4
- package/src/test/commands/remove.test.mjs +10 -4
- package/src/test/commands/search.test.mjs +10 -4
- package/src/test/commands/session-sync.test.mjs +352 -0
- package/src/test/commands/uninstall.test.mjs +774 -0
- package/src/test/commands/update.test.mjs +168 -4
- package/src/test/helpers/sandbox-home.mjs +161 -0
- package/src/test/helpers/skillrepo-shim.mjs +133 -0
- package/src/test/integration/file-write.integration.test.mjs +10 -4
- package/src/test/lib/artifact-registry.test.mjs +268 -0
- package/src/test/lib/cli-config.test.mjs +126 -5
- package/src/test/lib/config.test.mjs +10 -4
- package/src/test/lib/file-write.test.mjs +24 -10
- package/src/test/lib/mcp-merge.test.mjs +10 -4
- package/src/test/lib/paths.test.mjs +10 -4
- package/src/test/lib/platform.test.mjs +135 -0
- package/src/test/lib/sync.test.mjs +20 -4
- package/src/test/mergers/session-hook.test.mjs +1175 -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 +296 -0
- package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
- 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
|
+
}
|
package/src/lib/cli-config.mjs
CHANGED
|
@@ -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
|
package/src/lib/config.mjs
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/src/lib/file-write.mjs
CHANGED
|
@@ -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
|
|
566
|
-
|
|
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.
|
package/src/lib/fs-utils.mjs
CHANGED
|
@@ -3,8 +3,18 @@
|
|
|
3
3
|
* Creates directories as needed, handles errors cleanly.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
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
|
-
*
|
|
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
|
|
43
|
-
|
|
44
|
-
chmodSync(filePath, 0o755);
|
|
51
|
+
export function pathExists(p) {
|
|
52
|
+
return existsSync(p);
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
/**
|
|
48
|
-
*
|
|
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
|
|
51
|
-
|
|
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
|
}
|