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.
Files changed (72) hide show
  1. package/README.md +276 -145
  2. package/bin/skillrepo.mjs +224 -36
  3. package/package.json +6 -3
  4. package/src/commands/add.mjs +176 -0
  5. package/src/commands/get.mjs +116 -0
  6. package/src/commands/init.mjs +589 -143
  7. package/src/commands/list.mjs +176 -0
  8. package/src/commands/remove.mjs +162 -0
  9. package/src/commands/search.mjs +188 -0
  10. package/src/commands/session-sync.mjs +152 -0
  11. package/src/commands/uninstall.mjs +484 -0
  12. package/src/commands/update.mjs +184 -0
  13. package/src/lib/artifact-registry.mjs +265 -0
  14. package/src/lib/cli-config.mjs +230 -0
  15. package/src/lib/config.mjs +238 -0
  16. package/src/lib/detect-ides.mjs +0 -19
  17. package/src/lib/errors.mjs +264 -0
  18. package/src/lib/file-write.mjs +705 -0
  19. package/src/lib/fs-utils.mjs +83 -1
  20. package/src/lib/http.mjs +817 -37
  21. package/src/lib/identifier.mjs +153 -0
  22. package/src/lib/mcp-merge.mjs +275 -0
  23. package/src/lib/mergers/gitignore.mjs +73 -18
  24. package/src/lib/mergers/session-hook.mjs +298 -0
  25. package/src/lib/paths.mjs +67 -17
  26. package/src/lib/prompt.mjs +11 -44
  27. package/src/lib/removers/claude-mcp.mjs +67 -0
  28. package/src/lib/removers/cursor-mcp.mjs +60 -0
  29. package/src/lib/removers/env-local.mjs +55 -0
  30. package/src/lib/removers/gitignore.mjs +108 -0
  31. package/src/lib/removers/settings.mjs +183 -0
  32. package/src/lib/removers/vscode-mcp.mjs +87 -0
  33. package/src/lib/removers/windsurf-mcp.mjs +65 -0
  34. package/src/lib/sync.mjs +305 -0
  35. package/src/test/commands/add.test.mjs +285 -0
  36. package/src/test/commands/get.test.mjs +176 -0
  37. package/src/test/commands/init.test.mjs +697 -0
  38. package/src/test/commands/list.test.mjs +172 -0
  39. package/src/test/commands/remove.test.mjs +234 -0
  40. package/src/test/commands/search.test.mjs +204 -0
  41. package/src/test/commands/session-sync.test.mjs +350 -0
  42. package/src/test/commands/uninstall.test.mjs +768 -0
  43. package/src/test/commands/update.test.mjs +322 -0
  44. package/src/test/detect-ides.test.mjs +9 -14
  45. package/src/test/dispatcher.test.mjs +224 -0
  46. package/src/test/e2e/cli-commands.test.mjs +576 -0
  47. package/src/test/e2e/mock-server.mjs +364 -22
  48. package/src/test/helpers/capture-stream.mjs +48 -0
  49. package/src/test/integration/file-write.integration.test.mjs +279 -0
  50. package/src/test/lib/artifact-registry.test.mjs +268 -0
  51. package/src/test/lib/cli-config.test.mjs +407 -0
  52. package/src/test/lib/config.test.mjs +257 -0
  53. package/src/test/lib/errors.test.mjs +359 -0
  54. package/src/test/lib/file-write.test.mjs +784 -0
  55. package/src/test/lib/http.test.mjs +1198 -0
  56. package/src/test/lib/identifier.test.mjs +157 -0
  57. package/src/test/lib/mcp-merge.test.mjs +345 -0
  58. package/src/test/lib/paths.test.mjs +83 -0
  59. package/src/test/lib/sync.test.mjs +514 -0
  60. package/src/test/mergers/gitignore.test.mjs +145 -20
  61. package/src/test/mergers/session-hook.test.mjs +745 -0
  62. package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
  63. package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
  64. package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
  65. package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
  66. package/src/test/mergers/uninstall-settings.test.mjs +285 -0
  67. package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
  68. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
  69. package/src/lib/write-configs.mjs +0 -202
  70. package/src/test/e2e/HANDOFF.md +0 -223
  71. package/src/test/e2e/cli-init.test.mjs +0 -213
  72. 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
+ }