skillrepo 2.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 +276 -145
- package/bin/skillrepo.mjs +224 -36
- package/package.json +6 -3
- package/src/commands/add.mjs +176 -0
- package/src/commands/get.mjs +116 -0
- package/src/commands/init.mjs +589 -143
- package/src/commands/list.mjs +176 -0
- package/src/commands/remove.mjs +162 -0
- package/src/commands/search.mjs +188 -0
- package/src/commands/session-sync.mjs +152 -0
- package/src/commands/uninstall.mjs +484 -0
- package/src/commands/update.mjs +184 -0
- package/src/lib/artifact-registry.mjs +265 -0
- package/src/lib/cli-config.mjs +230 -0
- package/src/lib/config.mjs +238 -0
- package/src/lib/detect-ides.mjs +0 -19
- package/src/lib/errors.mjs +264 -0
- package/src/lib/file-write.mjs +705 -0
- package/src/lib/fs-utils.mjs +83 -1
- package/src/lib/http.mjs +817 -37
- package/src/lib/identifier.mjs +153 -0
- package/src/lib/mcp-merge.mjs +275 -0
- package/src/lib/mergers/gitignore.mjs +73 -18
- package/src/lib/mergers/session-hook.mjs +298 -0
- package/src/lib/paths.mjs +67 -17
- package/src/lib/prompt.mjs +11 -44
- 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 +305 -0
- package/src/test/commands/add.test.mjs +285 -0
- package/src/test/commands/get.test.mjs +176 -0
- package/src/test/commands/init.test.mjs +697 -0
- package/src/test/commands/list.test.mjs +172 -0
- package/src/test/commands/remove.test.mjs +234 -0
- package/src/test/commands/search.test.mjs +204 -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 +322 -0
- package/src/test/detect-ides.test.mjs +9 -14
- package/src/test/dispatcher.test.mjs +224 -0
- package/src/test/e2e/cli-commands.test.mjs +576 -0
- package/src/test/e2e/mock-server.mjs +364 -22
- package/src/test/helpers/capture-stream.mjs +48 -0
- package/src/test/integration/file-write.integration.test.mjs +279 -0
- package/src/test/lib/artifact-registry.test.mjs +268 -0
- package/src/test/lib/cli-config.test.mjs +407 -0
- package/src/test/lib/config.test.mjs +257 -0
- package/src/test/lib/errors.test.mjs +359 -0
- package/src/test/lib/file-write.test.mjs +784 -0
- package/src/test/lib/http.test.mjs +1198 -0
- package/src/test/lib/identifier.test.mjs +157 -0
- package/src/test/lib/mcp-merge.test.mjs +345 -0
- package/src/test/lib/paths.test.mjs +83 -0
- package/src/test/lib/sync.test.mjs +514 -0
- package/src/test/mergers/gitignore.test.mjs +145 -20
- 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
- package/src/lib/write-configs.mjs +0 -202
- package/src/test/e2e/HANDOFF.md +0 -223
- package/src/test/e2e/cli-init.test.mjs +0 -213
- package/src/test/e2e/payload-factory.mjs +0 -22
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec-compliant skill file-write pipeline (#681).
|
|
3
|
+
*
|
|
4
|
+
* Writes skill directories from server-returned skill payloads to per-vendor
|
|
5
|
+
* placement targets on disk. Path-preserving (Option B in the #646 plan):
|
|
6
|
+
* the file paths the server returns are written verbatim, so any references
|
|
7
|
+
* inside SKILL.md to supporting files continue to resolve. Layout
|
|
8
|
+
* conformance is the registry's concern (tracked in follow-up #874), not
|
|
9
|
+
* the CLI's.
|
|
10
|
+
*
|
|
11
|
+
* ── Design ─────────────────────────────────────────────────────────────
|
|
12
|
+
*
|
|
13
|
+
* • Atomic update path on POSIX uses the .tmp / .old rename dance:
|
|
14
|
+
*
|
|
15
|
+
* 1. Populate <skill>.tmp/ with all files
|
|
16
|
+
* 2. If <skill>/ already exists, rename it to <skill>.old/
|
|
17
|
+
* 3. Rename <skill>.tmp/ to <skill>/
|
|
18
|
+
* 4. rm -rf <skill>.old/
|
|
19
|
+
*
|
|
20
|
+
* A crash between any two steps leaves a recoverable state. The
|
|
21
|
+
* `cleanupOrphans()` function called at the start of every write
|
|
22
|
+
* command removes leftover .tmp/ and .old/ siblings.
|
|
23
|
+
*
|
|
24
|
+
* • Windows is best-effort: fs.rename fails on existing destinations
|
|
25
|
+
* and locked files. We fall back to direct-write semantics. A crash
|
|
26
|
+
* mid-update on Windows can leave a partially-updated skill — the
|
|
27
|
+
* next `update` run fully overwrites it.
|
|
28
|
+
*
|
|
29
|
+
* • Safety checks (NOT layout enforcement):
|
|
30
|
+
* - Path traversal (..) — rejected
|
|
31
|
+
* - Absolute paths — rejected
|
|
32
|
+
* - Depth > 5 — rejected (matches server MAX_PATH_DEPTH)
|
|
33
|
+
* - Blocked extensions — rejected (matches server BLOCKED_EXTENSIONS)
|
|
34
|
+
* - Skill name vs frontmatter `name` — must match
|
|
35
|
+
*
|
|
36
|
+
* • Vendor placement: Claude Code uses .claude/skills/<name>/ (project)
|
|
37
|
+
* or ~/.claude/skills/<name>/ (--global) per Anthropic's documented
|
|
38
|
+
* convention. Other detected vendors without their own convention
|
|
39
|
+
* fall back to <cwd>/skills/<name>/, with /skills/ added to .gitignore
|
|
40
|
+
* on first creation. See follow-up #876.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import {
|
|
44
|
+
existsSync,
|
|
45
|
+
mkdirSync,
|
|
46
|
+
writeFileSync,
|
|
47
|
+
renameSync,
|
|
48
|
+
rmSync,
|
|
49
|
+
readdirSync,
|
|
50
|
+
statSync,
|
|
51
|
+
} from "node:fs";
|
|
52
|
+
import { dirname, join, isAbsolute, relative } from "node:path";
|
|
53
|
+
import { platform } from "node:os";
|
|
54
|
+
|
|
55
|
+
import { readFileSafe, writeFileSafe } from "./fs-utils.mjs";
|
|
56
|
+
import {
|
|
57
|
+
claudeSkillsProject,
|
|
58
|
+
claudeSkillsGlobal,
|
|
59
|
+
projectSkillsFallback,
|
|
60
|
+
projectSkillsFallbackRoot,
|
|
61
|
+
claudeSkillsProjectRoot,
|
|
62
|
+
claudeSkillsGlobalRoot,
|
|
63
|
+
gitignorePath,
|
|
64
|
+
} from "./paths.mjs";
|
|
65
|
+
import { CliError, validationError, diskError } from "./errors.mjs";
|
|
66
|
+
|
|
67
|
+
// ── Constants (mirror the server-side validators in src/lib/skills/) ────
|
|
68
|
+
|
|
69
|
+
/** Max nesting depth for file paths — must stay in sync with src/lib/skills/constants.ts MAX_PATH_DEPTH. */
|
|
70
|
+
export const MAX_PATH_DEPTH = 5;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Blocked file extensions — must stay in sync with
|
|
74
|
+
* src/lib/skills/constants.ts BLOCKED_EXTENSIONS. Duplicated here
|
|
75
|
+
* because the CLI is a separate package that cannot import server-
|
|
76
|
+
* side TypeScript modules.
|
|
77
|
+
*/
|
|
78
|
+
export const BLOCKED_EXTENSIONS = new Set([
|
|
79
|
+
".exe", ".dll", ".so", ".dylib", ".bin",
|
|
80
|
+
".bat", ".cmd", ".com", ".msi", ".scr",
|
|
81
|
+
".app", ".dmg", ".pkg", ".deb", ".rpm",
|
|
82
|
+
".jar", ".war", ".class",
|
|
83
|
+
".wasm",
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
const GITIGNORE_SKILLS_LINE = "/skills/";
|
|
87
|
+
const GITIGNORE_SKILLS_HEADER = "# SkillRepo (CLI-managed library skills)";
|
|
88
|
+
|
|
89
|
+
// ── Types (JSDoc only — this is plain JS) ───────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @typedef {Object} SkillFile
|
|
93
|
+
* @property {string} path - Relative path within the skill directory (e.g., "SKILL.md", "scripts/extract.py").
|
|
94
|
+
* @property {string} content - File content as a UTF-8 string (the CLI fetches via JSON; binary blobs out of scope for v3.0.0).
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @typedef {Object} Skill
|
|
99
|
+
* @property {string} owner - Account slug owning the skill.
|
|
100
|
+
* @property {string} name - Skill name (must match the directory name and SKILL.md frontmatter).
|
|
101
|
+
* @property {SkillFile[]} files - All files in the skill, including SKILL.md at index 0 (or anywhere — finder is by path).
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @typedef {"claudeProject" | "claudeGlobal" | "projectFallback"} PlacementTarget
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
// ── Public API ──────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Validate a single file path inside a skill directory.
|
|
112
|
+
*
|
|
113
|
+
* Safety-only checks. Returns null for valid, or an error message string.
|
|
114
|
+
* Mirrors the server-side `validateFilePath` in `src/lib/skills/file-validation.ts`
|
|
115
|
+
* except we do NOT enforce a directory whitelist — the CLI is path-preserving.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} rawPath
|
|
118
|
+
* @returns {string | null}
|
|
119
|
+
*/
|
|
120
|
+
export function validateFilePath(rawPath) {
|
|
121
|
+
// Decode URL-encoded characters before validation
|
|
122
|
+
let path;
|
|
123
|
+
try {
|
|
124
|
+
path = decodeURIComponent(rawPath);
|
|
125
|
+
} catch {
|
|
126
|
+
return `Invalid URL encoding in path "${rawPath}".`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Path traversal
|
|
130
|
+
if (path.includes("..")) {
|
|
131
|
+
return `Blocked path traversal in "${rawPath}".`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Absolute paths (POSIX or Windows drive letter)
|
|
135
|
+
if (path.startsWith("/") || /^[a-zA-Z]:/.test(path)) {
|
|
136
|
+
return `Blocked absolute path "${rawPath}".`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Depth check
|
|
140
|
+
if (path.split("/").length > MAX_PATH_DEPTH) {
|
|
141
|
+
return `Path "${rawPath}" exceeds maximum nesting depth of ${MAX_PATH_DEPTH}.`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Blocked extensions
|
|
145
|
+
const dotIdx = path.lastIndexOf(".");
|
|
146
|
+
const ext = dotIdx >= 0 ? path.substring(dotIdx).toLowerCase() : "";
|
|
147
|
+
if (BLOCKED_EXTENSIONS.has(ext)) {
|
|
148
|
+
return `Blocked file type "${ext}" in "${rawPath}".`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Resolve the absolute filesystem directory for a skill at a given
|
|
156
|
+
* placement target. The directory does not need to exist yet.
|
|
157
|
+
*
|
|
158
|
+
* @param {PlacementTarget} target
|
|
159
|
+
* @param {string} skillName
|
|
160
|
+
* @returns {string}
|
|
161
|
+
*/
|
|
162
|
+
export function resolvePlacementDir(target, skillName) {
|
|
163
|
+
switch (target) {
|
|
164
|
+
case "claudeProject":
|
|
165
|
+
return claudeSkillsProject(skillName);
|
|
166
|
+
case "claudeGlobal":
|
|
167
|
+
return claudeSkillsGlobal(skillName);
|
|
168
|
+
case "projectFallback":
|
|
169
|
+
return projectSkillsFallback(skillName);
|
|
170
|
+
default: {
|
|
171
|
+
throw validationError(`Unknown placement target: ${target}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Compute the placement targets for a write based on detected/requested
|
|
178
|
+
* vendors and the --global flag.
|
|
179
|
+
*
|
|
180
|
+
* Rules:
|
|
181
|
+
* - --global → only ["claudeGlobal"] (other vendors don't have a
|
|
182
|
+
* documented global convention; we don't synthesize one)
|
|
183
|
+
* - claudeCode in vendors → "claudeProject"
|
|
184
|
+
* - any non-claudeCode vendor in vendors (cursor, windsurf, vscode)
|
|
185
|
+
* → "projectFallback" (single fallback, deduped — multiple
|
|
186
|
+
* non-claude vendors share the same fallback)
|
|
187
|
+
*
|
|
188
|
+
* @param {object} options
|
|
189
|
+
* @param {string[]} options.vendors - List of vendor keys (claudeCode, cursor, windsurf, vscode).
|
|
190
|
+
* @param {boolean} [options.global] - If true, write to personal/global Claude Code dir only.
|
|
191
|
+
* @returns {PlacementTarget[]}
|
|
192
|
+
*/
|
|
193
|
+
export function placementTargetsFor({ vendors, global = false }) {
|
|
194
|
+
if (global) {
|
|
195
|
+
return ["claudeGlobal"];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!Array.isArray(vendors) || vendors.length === 0) {
|
|
199
|
+
throw validationError(
|
|
200
|
+
"No vendors specified. Pass --ide claude (or another vendor) explicitly.",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const targets = [];
|
|
205
|
+
let needsFallback = false;
|
|
206
|
+
for (const vendor of vendors) {
|
|
207
|
+
if (vendor === "claudeCode") {
|
|
208
|
+
targets.push("claudeProject");
|
|
209
|
+
} else if (vendor === "cursor" || vendor === "windsurf" || vendor === "vscode") {
|
|
210
|
+
needsFallback = true;
|
|
211
|
+
} else {
|
|
212
|
+
throw validationError(`Unknown vendor: ${vendor}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (needsFallback) {
|
|
216
|
+
targets.push("projectFallback");
|
|
217
|
+
}
|
|
218
|
+
return targets;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Find the SKILL.md file inside a skill's files array and return its
|
|
223
|
+
* frontmatter `name` value, or null if none could be parsed.
|
|
224
|
+
*
|
|
225
|
+
* Lightweight YAML frontmatter parser — looks for `---` fences and a
|
|
226
|
+
* `name:` line inside them. Sufficient for the spec-required `name`
|
|
227
|
+
* field; we don't attempt to parse arbitrary YAML.
|
|
228
|
+
*
|
|
229
|
+
* @param {SkillFile[]} files
|
|
230
|
+
* @returns {string | null}
|
|
231
|
+
*/
|
|
232
|
+
export function readFrontmatterName(files) {
|
|
233
|
+
const skillMd = files.find((f) => f.path === "SKILL.md");
|
|
234
|
+
if (!skillMd || typeof skillMd.content !== "string") return null;
|
|
235
|
+
const lines = skillMd.content.split(/\r?\n/);
|
|
236
|
+
if (lines[0]?.trim() !== "---") return null;
|
|
237
|
+
for (let i = 1; i < lines.length; i++) {
|
|
238
|
+
const line = lines[i];
|
|
239
|
+
if (line.trim() === "---") return null; // end of frontmatter, no name
|
|
240
|
+
const m = line.match(/^name:\s*(.+?)\s*$/);
|
|
241
|
+
if (m) {
|
|
242
|
+
// Strip optional surrounding quotes
|
|
243
|
+
return m[1].replace(/^['"]|['"]$/g, "");
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Write a single skill to all configured placement targets.
|
|
251
|
+
*
|
|
252
|
+
* Atomic on POSIX via the .tmp/.old rename dance. Best-effort on Windows.
|
|
253
|
+
*
|
|
254
|
+
* @param {Skill} skill
|
|
255
|
+
* @param {object} options
|
|
256
|
+
* @param {string[]} [options.vendors] - Vendor keys; required unless `global`.
|
|
257
|
+
* @param {boolean} [options.global] - If true, write to global Claude Code dir.
|
|
258
|
+
* @returns {{ written: string[] }} Per-target absolute directory paths.
|
|
259
|
+
*
|
|
260
|
+
* Note: `writeSkillDir` does NOT short-circuit when an existing skill on
|
|
261
|
+
* disk already matches the incoming payload. PR2's `sync.mjs` performs
|
|
262
|
+
* the change-detection (via SHAs from the server) and only invokes
|
|
263
|
+
* `writeSkillDir` for skills that genuinely changed. A future
|
|
264
|
+
* skip-if-unchanged enhancement at this layer would duplicate that work
|
|
265
|
+
* and require re-reading every file from disk.
|
|
266
|
+
*
|
|
267
|
+
* Pre-flight: `.gitignore` setup for the projectFallback target runs
|
|
268
|
+
* BEFORE any per-target write. This is intentional — running it inline
|
|
269
|
+
* inside the loop would let a read-only `.gitignore` failure abort the
|
|
270
|
+
* loop AFTER an earlier vendor (e.g., claudeCode) had already
|
|
271
|
+
* succeeded, leaving the user with partial state and a thrown error
|
|
272
|
+
* that lies about what was written. Failing the pre-flight aborts
|
|
273
|
+
* cleanly with NO disk writes performed.
|
|
274
|
+
*/
|
|
275
|
+
export function writeSkillDir(skill, options = {}) {
|
|
276
|
+
validateSkill(skill);
|
|
277
|
+
|
|
278
|
+
const targets = placementTargetsFor(options);
|
|
279
|
+
|
|
280
|
+
// Pre-flight: if any target requires the project /skills/ fallback,
|
|
281
|
+
// ensure .gitignore is set up before starting any writes. A failure
|
|
282
|
+
// here is recoverable (user fixes their .gitignore and re-runs)
|
|
283
|
+
// because nothing has hit disk yet. A failure here AFTER a successful
|
|
284
|
+
// claudeProject write would leave the user with a half-applied state
|
|
285
|
+
// and an error message that doesn't reflect what's actually on disk.
|
|
286
|
+
if (targets.includes("projectFallback")) {
|
|
287
|
+
ensureFallbackGitignore();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const written = [];
|
|
291
|
+
|
|
292
|
+
for (const target of targets) {
|
|
293
|
+
const targetDir = resolvePlacementDir(target, skill.name);
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
writeSkillToDir(skill, targetDir);
|
|
297
|
+
written.push(targetDir);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
if (err instanceof CliError) throw err;
|
|
300
|
+
throw diskError(`Failed to write ${targetDir}: ${err.message}`, { cause: err });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { written };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Remove a skill directory from all configured placement targets.
|
|
309
|
+
*
|
|
310
|
+
* Used by `remove` (direct local delete after a successful DELETE call)
|
|
311
|
+
* and by `update` when processing tombstones from the sync response.
|
|
312
|
+
*
|
|
313
|
+
* @param {string} skillName
|
|
314
|
+
* @param {object} options
|
|
315
|
+
* @param {string[]} [options.vendors]
|
|
316
|
+
* @param {boolean} [options.global]
|
|
317
|
+
* @returns {{ removed: string[], notFound: string[] }}
|
|
318
|
+
*/
|
|
319
|
+
export function removeSkillDir(skillName, options = {}) {
|
|
320
|
+
if (!isValidSkillName(skillName)) {
|
|
321
|
+
throw validationError(`Invalid skill name: "${skillName}"`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const targets = placementTargetsFor(options);
|
|
325
|
+
const removed = [];
|
|
326
|
+
const notFound = [];
|
|
327
|
+
|
|
328
|
+
for (const target of targets) {
|
|
329
|
+
const targetDir = resolvePlacementDir(target, skillName);
|
|
330
|
+
if (!existsSync(targetDir)) {
|
|
331
|
+
notFound.push(targetDir);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
336
|
+
removed.push(targetDir);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
throw diskError(`Failed to remove ${targetDir}: ${err.message}`, { cause: err });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return { removed, notFound };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Scan all configured placement roots and remove orphan .tmp/ and .old/
|
|
347
|
+
* directories left over from a crashed write. Called at the start of
|
|
348
|
+
* every write-flavored command (update, get, add, init).
|
|
349
|
+
*
|
|
350
|
+
* Safety invariants:
|
|
351
|
+
*
|
|
352
|
+
* 1. Only directories whose names end in `.tmp` or `.old` are
|
|
353
|
+
* considered. Spec-valid skill names cannot end with these
|
|
354
|
+
* suffixes because `isValidSkillName` rejects any name containing
|
|
355
|
+
* `.`, so a legitimate skill never collides with the orphan
|
|
356
|
+
* pattern. This is invisible at the call site, so it's
|
|
357
|
+
* load-bearing for safety: if the spec ever relaxed the name
|
|
358
|
+
* regex, this function would need a stricter check.
|
|
359
|
+
*
|
|
360
|
+
* 2. A `.tmp/` whose corresponding live target does NOT exist is
|
|
361
|
+
* preserved, NOT deleted. This protects the Windows recovery
|
|
362
|
+
* path: when `rmSync(targetDir)` succeeded but `renameSync(tmpDir,
|
|
363
|
+
* targetDir)` failed, the user's only copy of the skill is in
|
|
364
|
+
* `<name>.tmp/`. Deleting it here would compound the data loss.
|
|
365
|
+
* The user has to manually rename it (the disk error from
|
|
366
|
+
* writeSkillToDir tells them how).
|
|
367
|
+
*
|
|
368
|
+
* 3. `.old/` directories are always cleanable — they only exist as
|
|
369
|
+
* transient state during a successful or failed rename dance, and
|
|
370
|
+
* the live target is the authoritative source.
|
|
371
|
+
*
|
|
372
|
+
* Idempotent: safe to call when no orphans exist.
|
|
373
|
+
*
|
|
374
|
+
* @param {object} options
|
|
375
|
+
* @param {string[]} [options.vendors]
|
|
376
|
+
* @param {boolean} [options.global]
|
|
377
|
+
* @returns {{ cleaned: string[] }}
|
|
378
|
+
*/
|
|
379
|
+
export function cleanupOrphans(options = {}) {
|
|
380
|
+
const roots = [];
|
|
381
|
+
if (options.global) {
|
|
382
|
+
roots.push(claudeSkillsGlobalRoot());
|
|
383
|
+
} else {
|
|
384
|
+
if (Array.isArray(options.vendors) && options.vendors.length > 0) {
|
|
385
|
+
if (options.vendors.includes("claudeCode")) {
|
|
386
|
+
roots.push(claudeSkillsProjectRoot());
|
|
387
|
+
}
|
|
388
|
+
if (
|
|
389
|
+
options.vendors.includes("cursor") ||
|
|
390
|
+
options.vendors.includes("windsurf") ||
|
|
391
|
+
options.vendors.includes("vscode")
|
|
392
|
+
) {
|
|
393
|
+
roots.push(projectSkillsFallbackRoot());
|
|
394
|
+
}
|
|
395
|
+
} else {
|
|
396
|
+
// No vendors specified — clean everything we know about.
|
|
397
|
+
roots.push(claudeSkillsProjectRoot());
|
|
398
|
+
roots.push(projectSkillsFallbackRoot());
|
|
399
|
+
roots.push(claudeSkillsGlobalRoot());
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const cleaned = [];
|
|
404
|
+
for (const root of roots) {
|
|
405
|
+
if (!existsSync(root)) continue;
|
|
406
|
+
let entries;
|
|
407
|
+
try {
|
|
408
|
+
entries = readdirSync(root);
|
|
409
|
+
} catch {
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
for (const entry of entries) {
|
|
413
|
+
if (!(entry.endsWith(".tmp") || entry.endsWith(".old"))) continue;
|
|
414
|
+
|
|
415
|
+
const orphanPath = join(root, entry);
|
|
416
|
+
let st;
|
|
417
|
+
try {
|
|
418
|
+
st = statSync(orphanPath);
|
|
419
|
+
} catch {
|
|
420
|
+
continue; // Orphan vanished mid-iteration
|
|
421
|
+
}
|
|
422
|
+
if (!st.isDirectory()) continue;
|
|
423
|
+
|
|
424
|
+
// Invariant #2: preserve a .tmp/ whose live target is missing —
|
|
425
|
+
// it is the user's only copy of a skill that crashed mid-rename.
|
|
426
|
+
if (entry.endsWith(".tmp")) {
|
|
427
|
+
const liveTarget = join(root, entry.slice(0, -".tmp".length));
|
|
428
|
+
if (!existsSync(liveTarget)) {
|
|
429
|
+
// Recoverable orphan — leave it alone so the user can rename
|
|
430
|
+
// it manually per the diskError hint from writeSkillToDir.
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
rmSync(orphanPath, { recursive: true, force: true });
|
|
437
|
+
cleaned.push(orphanPath);
|
|
438
|
+
} catch {
|
|
439
|
+
// Best-effort — a locked file or permission error here is not
|
|
440
|
+
// fatal. The next run will retry.
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return { cleaned };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ── Internals ───────────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Validate that a skill name is well-formed per the agentskills.io spec.
|
|
451
|
+
* Lowercase alphanumeric + hyphens, no leading/trailing/consecutive hyphens, 1-64 chars.
|
|
452
|
+
*/
|
|
453
|
+
export function isValidSkillName(name) {
|
|
454
|
+
if (typeof name !== "string" || name.length === 0 || name.length > 64) return false;
|
|
455
|
+
if (!/^[a-z0-9-]+$/.test(name)) return false;
|
|
456
|
+
if (name.startsWith("-") || name.endsWith("-")) return false;
|
|
457
|
+
if (name.includes("--")) return false;
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Validate the entire skill payload before any disk work. Throws CliError
|
|
463
|
+
* on first violation so the caller can map to an exit code.
|
|
464
|
+
*/
|
|
465
|
+
function validateSkill(skill) {
|
|
466
|
+
if (!skill || typeof skill !== "object") {
|
|
467
|
+
throw validationError("Skill payload is missing or not an object");
|
|
468
|
+
}
|
|
469
|
+
if (!isValidSkillName(skill.name)) {
|
|
470
|
+
throw validationError(`Invalid skill name: "${skill.name}"`);
|
|
471
|
+
}
|
|
472
|
+
if (typeof skill.owner !== "string" || skill.owner.length === 0) {
|
|
473
|
+
throw validationError(`Skill "${skill.name}" is missing an owner`);
|
|
474
|
+
}
|
|
475
|
+
if (!Array.isArray(skill.files) || skill.files.length === 0) {
|
|
476
|
+
throw validationError(`Skill "${skill.owner}/${skill.name}" has no files`);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Spec compliance: SKILL.md must exist at the root
|
|
480
|
+
const hasSkillMd = skill.files.some((f) => f.path === "SKILL.md");
|
|
481
|
+
if (!hasSkillMd) {
|
|
482
|
+
throw validationError(
|
|
483
|
+
`Skill "${skill.owner}/${skill.name}" is missing a SKILL.md at the root (agentskills.io spec)`,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Spec compliance: frontmatter `name` must match the skill's name
|
|
488
|
+
const frontmatterName = readFrontmatterName(skill.files);
|
|
489
|
+
if (frontmatterName !== null && frontmatterName !== skill.name) {
|
|
490
|
+
throw validationError(
|
|
491
|
+
`Skill "${skill.owner}/${skill.name}" has a SKILL.md frontmatter ` +
|
|
492
|
+
`name "${frontmatterName}" that does not match the parent directory name. ` +
|
|
493
|
+
`This violates the agentskills.io spec.`,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Per-file safety checks
|
|
498
|
+
const seenPaths = new Set();
|
|
499
|
+
for (const file of skill.files) {
|
|
500
|
+
if (!file || typeof file.path !== "string") {
|
|
501
|
+
throw validationError(`Skill "${skill.owner}/${skill.name}" has a file with no path`);
|
|
502
|
+
}
|
|
503
|
+
const err = validateFilePath(file.path);
|
|
504
|
+
if (err) {
|
|
505
|
+
throw validationError(`Skill "${skill.owner}/${skill.name}": ${err}`);
|
|
506
|
+
}
|
|
507
|
+
if (seenPaths.has(file.path)) {
|
|
508
|
+
throw validationError(
|
|
509
|
+
`Skill "${skill.owner}/${skill.name}" has duplicate file path "${file.path}"`,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
seenPaths.add(file.path);
|
|
513
|
+
if (typeof file.content !== "string") {
|
|
514
|
+
throw validationError(
|
|
515
|
+
`Skill "${skill.owner}/${skill.name}" file "${file.path}" has non-string content (binary blobs are not supported in v3.0.0)`,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Write a skill to a single target directory using the atomic dance.
|
|
523
|
+
*
|
|
524
|
+
* Throws CliError (specifically diskError) on every failure path so the
|
|
525
|
+
* caller can rely on the exit code without re-wrapping. Callers MAY
|
|
526
|
+
* still wrap to enrich the message with the per-target context, but the
|
|
527
|
+
* exit code is correct from this function alone.
|
|
528
|
+
*/
|
|
529
|
+
function writeSkillToDir(skill, targetDir) {
|
|
530
|
+
const tmpDir = `${targetDir}.tmp`;
|
|
531
|
+
const oldDir = `${targetDir}.old`;
|
|
532
|
+
|
|
533
|
+
// Pre-flight: clean any stale .tmp from a previous crash
|
|
534
|
+
if (existsSync(tmpDir)) {
|
|
535
|
+
try {
|
|
536
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
537
|
+
} catch (err) {
|
|
538
|
+
throw diskError(`Cannot remove stale ${tmpDir}: ${err.message}`, { cause: err });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// 1. Populate <name>.tmp/
|
|
543
|
+
try {
|
|
544
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
545
|
+
} catch (err) {
|
|
546
|
+
throw diskError(`Cannot create ${tmpDir}: ${err.message}`, { cause: err });
|
|
547
|
+
}
|
|
548
|
+
for (const file of skill.files) {
|
|
549
|
+
const filePath = join(tmpDir, file.path);
|
|
550
|
+
// Defense in depth: confirm the resolved path is still inside tmpDir.
|
|
551
|
+
// validateFilePath already rejected `..` and absolute paths, but a
|
|
552
|
+
// race or symlink could in principle defeat that check.
|
|
553
|
+
const rel = relative(tmpDir, filePath);
|
|
554
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
555
|
+
throw diskError(`Refusing to write outside skill directory: ${file.path}`);
|
|
556
|
+
}
|
|
557
|
+
try {
|
|
558
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
559
|
+
writeFileSync(filePath, file.content, "utf-8");
|
|
560
|
+
} catch (err) {
|
|
561
|
+
throw diskError(`Cannot write ${filePath}: ${err.message}`, { cause: err });
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// 2 + 3 + 4: rename dance (POSIX atomic on same filesystem; best-effort on Windows)
|
|
566
|
+
if (platform() === "win32") {
|
|
567
|
+
// Windows: rename fails on existing destinations and locked files,
|
|
568
|
+
// so we fall back to remove-then-rename. There is a window where
|
|
569
|
+
// the live target is gone but the rename has not yet completed.
|
|
570
|
+
// If the rename fails (e.g., file lock from an IDE indexer scan),
|
|
571
|
+
// we surface a SPECIFIC disk error pointing the user at the .tmp/
|
|
572
|
+
// directory so they can recover manually. The cleanup-orphans
|
|
573
|
+
// function will NOT delete a `.tmp/` whose live target is missing
|
|
574
|
+
// — it only deletes `.tmp/` siblings whose live target also exists,
|
|
575
|
+
// so the user's data is preserved on disk.
|
|
576
|
+
if (existsSync(targetDir)) {
|
|
577
|
+
try {
|
|
578
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
579
|
+
} catch (err) {
|
|
580
|
+
// Live target still exists — .tmp/ is fine to clean later
|
|
581
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
582
|
+
throw diskError(
|
|
583
|
+
`Cannot replace existing ${targetDir} on Windows: ${err.message}`,
|
|
584
|
+
{ cause: err, hint: "A file in that directory may be locked by another process." },
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
renameSync(tmpDir, targetDir);
|
|
590
|
+
} catch (err) {
|
|
591
|
+
throw diskError(
|
|
592
|
+
`Skill files were prepared at ${tmpDir} but the final rename to ${targetDir} failed: ${err.message}`,
|
|
593
|
+
{
|
|
594
|
+
cause: err,
|
|
595
|
+
hint:
|
|
596
|
+
`The skill files are recoverable at ${tmpDir} — rename it to ${targetDir} manually. ` +
|
|
597
|
+
`cleanupOrphans() will NOT delete this directory while the live target is missing.`,
|
|
598
|
+
},
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// POSIX path
|
|
605
|
+
if (existsSync(targetDir)) {
|
|
606
|
+
// Step 2: move existing target out of the way
|
|
607
|
+
if (existsSync(oldDir)) {
|
|
608
|
+
// Stale .old from a previous crash — clean it first
|
|
609
|
+
try {
|
|
610
|
+
rmSync(oldDir, { recursive: true, force: true });
|
|
611
|
+
} catch (err) {
|
|
612
|
+
throw diskError(`Cannot remove stale ${oldDir}: ${err.message}`, { cause: err });
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
try {
|
|
616
|
+
renameSync(targetDir, oldDir);
|
|
617
|
+
} catch (err) {
|
|
618
|
+
throw diskError(`Cannot stage existing ${targetDir} for replacement: ${err.message}`, { cause: err });
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Step 3: rename tmp into place (atomic)
|
|
623
|
+
try {
|
|
624
|
+
renameSync(tmpDir, targetDir);
|
|
625
|
+
} catch (err) {
|
|
626
|
+
// Rollback: restore the old directory if we can. Either way, throw
|
|
627
|
+
// a typed diskError with the specifics so the caller never sees a
|
|
628
|
+
// raw OS error escape this function.
|
|
629
|
+
let rollbackOk = false;
|
|
630
|
+
if (existsSync(oldDir)) {
|
|
631
|
+
try {
|
|
632
|
+
renameSync(oldDir, targetDir);
|
|
633
|
+
rollbackOk = true;
|
|
634
|
+
} catch {
|
|
635
|
+
// Both rename attempts failed — fall through to throw below
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
throw diskError(
|
|
639
|
+
`Failed to install ${targetDir}: ${err.message}` +
|
|
640
|
+
(rollbackOk
|
|
641
|
+
? ` (previous version restored from ${oldDir})`
|
|
642
|
+
: ` and rollback from ${oldDir} also failed — manual recovery required`),
|
|
643
|
+
{ cause: err },
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Step 4: clean up the old directory
|
|
648
|
+
if (existsSync(oldDir)) {
|
|
649
|
+
try {
|
|
650
|
+
rmSync(oldDir, { recursive: true, force: true });
|
|
651
|
+
} catch (err) {
|
|
652
|
+
// Non-fatal: the new skill is in place; the .old dir is stale
|
|
653
|
+
// state that cleanupOrphans() will sweep on the next run. We log
|
|
654
|
+
// this via process.stderr so a user who cares can see it.
|
|
655
|
+
process.stderr.write(
|
|
656
|
+
` warning: leftover ${oldDir} could not be removed (${err.message}). ` +
|
|
657
|
+
`Will be cleaned on the next run.\n`,
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Ensure the project /skills/ directory is gitignored. Idempotent —
|
|
665
|
+
* skips if the entry is already present. Creates .gitignore if missing.
|
|
666
|
+
*
|
|
667
|
+
* Throws diskError on any write failure so a read-only or symlinked
|
|
668
|
+
* .gitignore surfaces as a user-visible error instead of silent data
|
|
669
|
+
* loss. This is the explicit fix for the architect's PR1 review item:
|
|
670
|
+
* `writeFileSafe` was previously called without a try/catch and a
|
|
671
|
+
* failing write would leave the user with skills on disk but no
|
|
672
|
+
* .gitignore protection — the next `git add` would commit them.
|
|
673
|
+
*/
|
|
674
|
+
function ensureFallbackGitignore() {
|
|
675
|
+
const filePath = gitignorePath();
|
|
676
|
+
let existing = null;
|
|
677
|
+
try {
|
|
678
|
+
existing = readFileSafe(filePath);
|
|
679
|
+
} catch (err) {
|
|
680
|
+
throw diskError(`Cannot read ${filePath}: ${err.message}`, {
|
|
681
|
+
cause: err,
|
|
682
|
+
hint: "Update your .gitignore manually to add /skills/ before re-running.",
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (existing !== null && existing.includes(GITIGNORE_SKILLS_LINE)) {
|
|
687
|
+
return; // Already present — no-op
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const newContent =
|
|
691
|
+
existing === null
|
|
692
|
+
? `${GITIGNORE_SKILLS_HEADER}\n${GITIGNORE_SKILLS_LINE}\n`
|
|
693
|
+
: `${existing}${existing.endsWith("\n") ? "" : "\n"}\n${GITIGNORE_SKILLS_HEADER}\n${GITIGNORE_SKILLS_LINE}\n`;
|
|
694
|
+
|
|
695
|
+
try {
|
|
696
|
+
writeFileSafe(filePath, newContent);
|
|
697
|
+
} catch (err) {
|
|
698
|
+
throw diskError(`Cannot update ${filePath}: ${err.message}`, {
|
|
699
|
+
cause: err,
|
|
700
|
+
hint:
|
|
701
|
+
"The CLI needs to add /skills/ to .gitignore so library skills don't get committed. " +
|
|
702
|
+
"Update .gitignore manually and re-run, or remove the read-only/symlinked constraint.",
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
}
|