@iseoane/agent-depot 0.2.0 → 0.3.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 (86) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +117 -3
  3. package/dist/src/app-cli.js +172 -0
  4. package/dist/src/app-flow.js +457 -0
  5. package/dist/src/app-latest.js +63 -0
  6. package/dist/src/app-owned-skills.js +42 -0
  7. package/dist/src/app-recipes.js +116 -0
  8. package/dist/src/app-schema.js +45 -0
  9. package/dist/src/app-version.js +46 -0
  10. package/dist/src/atomic-file.js +26 -0
  11. package/dist/src/cli.js +1256 -0
  12. package/dist/src/git-source.js +409 -0
  13. package/dist/src/path-safety.js +31 -0
  14. package/dist/src/process-runner.js +41 -0
  15. package/dist/src/project-installation.js +1411 -0
  16. package/dist/src/project-manifest.js +511 -0
  17. package/dist/src/skill-adoption.js +77 -0
  18. package/dist/src/skill-discovery.js +467 -0
  19. package/dist/src/skill-hosts.js +103 -0
  20. package/dist/src/skill-install.js +374 -0
  21. package/dist/src/skill-removal.js +119 -0
  22. package/dist/src/skill-update.js +244 -0
  23. package/dist/src/source-state.js +240 -0
  24. package/dist/src/sources.js +391 -0
  25. package/dist/src/tui/adoption-actions.js +144 -0
  26. package/dist/src/tui/app-actions.js +157 -0
  27. package/dist/src/tui/app.js +63 -0
  28. package/dist/src/tui/batch.js +17 -0
  29. package/dist/src/tui/bulk-actions.js +243 -0
  30. package/dist/src/tui/catalog-actions.js +9 -0
  31. package/dist/src/tui/catalog-browse.js +59 -0
  32. package/dist/src/tui/catalog-filter.js +20 -0
  33. package/dist/src/tui/catalog-flow.js +107 -0
  34. package/dist/src/tui/catalog-installs.js +193 -0
  35. package/dist/src/tui/catalog-panel.js +47 -0
  36. package/dist/src/tui/catalog-state.js +92 -0
  37. package/dist/src/tui/catalog-tree.js +35 -0
  38. package/dist/src/tui/catalog-view.js +89 -0
  39. package/dist/src/tui/environment.js +1 -0
  40. package/dist/src/tui/installations-browse.js +136 -0
  41. package/dist/src/tui/installations-mode.js +1 -0
  42. package/dist/src/tui/installations-panel.js +27 -0
  43. package/dist/src/tui/installations-rows.js +11 -0
  44. package/dist/src/tui/installations-tree.js +80 -0
  45. package/dist/src/tui/installations-view.js +154 -0
  46. package/dist/src/tui/installations.js +172 -0
  47. package/dist/src/tui/keys.js +31 -0
  48. package/dist/src/tui/manage-actions.js +174 -0
  49. package/dist/src/tui/manage-panel.js +65 -0
  50. package/dist/src/tui/mode-keys.js +61 -0
  51. package/dist/src/tui/panel-parts.js +23 -0
  52. package/dist/src/tui/render.js +8 -0
  53. package/dist/src/tui/sources-mode.js +17 -0
  54. package/dist/src/tui/sources-view.js +276 -0
  55. package/dist/src/tui/theme.js +29 -0
  56. package/dist/src/tui/tree-navigation.js +68 -0
  57. package/dist/src/tui/tree.js +53 -0
  58. package/dist/src/tui/unmanaged-actions.js +178 -0
  59. package/dist/src/tui/updates-flow.js +89 -0
  60. package/dist/src/tui/updates-keys.js +104 -0
  61. package/dist/src/tui/updates-mode.js +1 -0
  62. package/dist/src/tui/updates-model.js +48 -0
  63. package/dist/src/tui/updates-panel.js +44 -0
  64. package/dist/src/tui/updates-view.js +101 -0
  65. package/dist/src/tui/updates.js +230 -0
  66. package/dist/src/tui/view-state.js +42 -0
  67. package/dist/src/tui/window.js +31 -0
  68. package/dist/src/unmanaged-removal.js +60 -0
  69. package/dist/src/update-batch.js +376 -0
  70. package/dist/src/update-flow.js +135 -0
  71. package/dist/src/usage-error.js +7 -0
  72. package/dist/src/user-global-skill-inventory.js +565 -0
  73. package/dist/src/user-method.js +14 -0
  74. package/package.json +7 -4
  75. package/skills/agent-depot-apprecipe/SKILL.md +44 -0
  76. package/skills/agent-depot-apprecipe/assets/example.md +25 -0
  77. package/skills/doctor-md-agents/scripts/test_collect_sessions.py +0 -780
  78. package/skills/doctor-md-agents/scripts/test_extract_rules.py +0 -530
  79. package/skills/doctor-md-agents/scripts/test_render_report.py +0 -383
  80. package/skills/doctor-md-agents/scripts/test_scan_corrections.py +0 -343
  81. package/skills/doctor-md-agents/scripts/test_verify_citations.py +0 -236
  82. package/skills/doctor-md-agents/scripts/testdata/old_extract_rules_pre_t1.py.txt +0 -507
  83. package/skills/doctor-md-skill/scripts/test_collect_sessions.py +0 -593
  84. package/skills/doctor-md-skill/scripts/test_detect_machine_sessions.py +0 -181
  85. package/skills/doctor-md-skill/scripts/test_render_report.py +0 -241
  86. package/skills/doctor-md-skill/scripts/test_verify_citations.py +0 -196
@@ -0,0 +1,1411 @@
1
+ import { chmod, lstat, mkdir, readFile, readlink, readdir, rename, rmdir, rm, symlink, unlink, writeFile } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import path from "node:path";
4
+ import { compareSkillTrees, readExistingSkillTree } from "./skill-adoption.js";
5
+ import { pathsOverlap } from "./path-safety.js";
6
+ import { parseSkillFrontmatter, skillTreeBaseline } from "./skill-discovery.js";
7
+ /** Refuses a removal batch whose selected filesystem targets overlap. */
8
+ export function assertNoOverlappingProjectSkillRemovalTargets(inspections) {
9
+ const paths = inspections.flatMap((inspection) => inspection.paths);
10
+ for (let leftIndex = 0; leftIndex < paths.length; leftIndex += 1) {
11
+ for (let rightIndex = leftIndex + 1; rightIndex < paths.length; rightIndex += 1) {
12
+ const left = path.resolve(paths[leftIndex]);
13
+ const right = path.resolve(paths[rightIndex]);
14
+ const relative = path.relative(left, right);
15
+ const reverse = path.relative(right, left);
16
+ if (relative === "" || reverse === "" || (!relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) ||
17
+ (!reverse.startsWith(`..${path.sep}`) && !path.isAbsolute(reverse))) {
18
+ throw new ProjectInstallationError("overlapping-targets", `Cannot remove selected Skills because removal targets overlap: ${JSON.stringify(left)} and ${JSON.stringify(right)}; no path was changed`);
19
+ }
20
+ }
21
+ }
22
+ }
23
+ export async function inspectProjectSkillRemoval(selection, options) {
24
+ if (!selection.installation) {
25
+ throw new ProjectInstallationError("removal-unavailable", "Cannot remove a Skill without a recorded installation");
26
+ }
27
+ const fileSystem = options.fileSystem ?? nodeFileSystem;
28
+ if (!fileSystem.readdir || !fileSystem.readFile) {
29
+ throw new ProjectInstallationError("removal-unavailable", "Skill removal requires filesystem read support");
30
+ }
31
+ const projectRoot = path.resolve(options.projectRoot);
32
+ const skillName = skillNameFromSelection(selection);
33
+ const targetPath = resolveInstallationPath(projectRoot, selection.installation.path);
34
+ await assertNoSymlinkComponents(projectRoot, fileSystem);
35
+ await assertDirectory(projectRoot, fileSystem, "user-global root");
36
+ await assertNoSymlinkAncestors(projectRoot, relativeSegments(projectRoot, targetPath), fileSystem);
37
+ const nonCanonicalPath = await classifyTrackedInstallation(projectRoot, selection.installation.path, skillName, options, fileSystem);
38
+ let targetInformation;
39
+ try {
40
+ targetInformation = await fileSystem.lstat(targetPath);
41
+ }
42
+ catch (error) {
43
+ if (isMissing(error)) {
44
+ throw new ProjectInstallationError("missing-installation", `The tracked Skill installation does not exist: ${targetPath}; no path was changed`);
45
+ }
46
+ throw error;
47
+ }
48
+ assertRealDirectory(targetPath, targetInformation);
49
+ const actualTree = await readExistingSkillTree(targetPath, selection.path, {
50
+ lstat: fileSystem.lstat,
51
+ readdir: fileSystem.readdir,
52
+ readFile: fileSystem.readFile,
53
+ });
54
+ const actualBaseline = skillTreeBaseline(actualTree);
55
+ const modified = selection.installation.baseline === undefined ||
56
+ actualBaseline.digest !== selection.installation.baseline.digest;
57
+ const paths = [targetPath];
58
+ // A Claude exposure created by Agent Depot is removable only when it is the
59
+ // exact relative symlink to this canonical installation. Never follow or
60
+ // remove an unrelated path merely because the Skill selected Claude.
61
+ const canonicalPath = path.join(projectRoot, ".agents", "skills", skillName);
62
+ const claudePath = path.join(projectRoot, ".claude", "skills", skillName);
63
+ if (selection.hosts.includes("claude") && targetPath === canonicalPath) {
64
+ await assertNoSymlinkAncestors(projectRoot, relativeSegments(projectRoot, path.dirname(claudePath)), fileSystem);
65
+ try {
66
+ const claudeInformation = await fileSystem.lstat(claudePath);
67
+ if (!claudeInformation.isSymbolicLink()) {
68
+ throw new ProjectInstallationError("unsafe-target", `Cannot remove the tracked Skill because its Claude location is not the managed symlink: ${claudePath}; no path was changed`);
69
+ }
70
+ const target = await fileSystem.readlink(claudePath);
71
+ if (path.resolve(path.dirname(claudePath), target) !== canonicalPath) {
72
+ throw new ProjectInstallationError("unsafe-target", `Cannot remove the tracked Skill because its Claude symlink does not point to the canonical installation: ${claudePath}; no path was changed`);
73
+ }
74
+ paths.push(claudePath);
75
+ }
76
+ catch (error) {
77
+ if (!isMissing(error))
78
+ throw error;
79
+ }
80
+ }
81
+ return Object.freeze({
82
+ skillName,
83
+ paths: Object.freeze(paths),
84
+ digest: actualBaseline.digest,
85
+ adopted: selection.installation.adopted,
86
+ modified,
87
+ ...(nonCanonicalPath === undefined ? {} : { nonCanonicalPath }),
88
+ });
89
+ }
90
+ /**
91
+ * Removes a previously inspected user-global Skill after rechecking ownership.
92
+ * `deletePaths` narrows the deletion to a subset of the inspected paths (used
93
+ * when only some Hosts are removed); the whole inspection is still rechecked.
94
+ */
95
+ export async function removeProjectSkill(selection, inspection, options, deletePaths) {
96
+ assertNoOverlappingProjectSkillRemovalTargets([inspection]);
97
+ const current = await inspectProjectSkillRemoval(selection, options);
98
+ if (current.skillName !== inspection.skillName ||
99
+ current.paths.length !== inspection.paths.length ||
100
+ current.paths.some((candidate, index) => candidate !== inspection.paths[index]) ||
101
+ current.digest !== inspection.digest ||
102
+ current.adopted !== inspection.adopted ||
103
+ current.modified !== inspection.modified ||
104
+ current.nonCanonicalPath !== inspection.nonCanonicalPath) {
105
+ throw new ProjectInstallationError("removal-changed", "The inspected Skill removal target, digest, adoption, or modification state changed; no path was removed");
106
+ }
107
+ assertNonCanonicalConfirmed(current.nonCanonicalPath, options);
108
+ const fileSystem = options.fileSystem ?? nodeFileSystem;
109
+ const selected = deletePaths ?? current.paths;
110
+ if (selected.some((candidate) => !current.paths.includes(candidate))) {
111
+ throw new ProjectInstallationError("removal-changed", "A path selected for removal is not part of the inspected Skill installation; no path was removed");
112
+ }
113
+ for (const candidate of [...current.paths].reverse().filter((candidate) => selected.includes(candidate))) {
114
+ if (deletePaths === undefined) {
115
+ await fileSystem.rm(candidate);
116
+ }
117
+ else {
118
+ await unlinkManagedSymlink(candidate, fileSystem);
119
+ }
120
+ }
121
+ }
122
+ /**
123
+ * Deletes one managed symlink without ever following or recursing into it:
124
+ * look at the path immediately before, require a symlink, then unlink it.
125
+ * Partial Host removal only ever deletes the Claude symlink, so it uses this
126
+ * instead of the recursive `rm` that full removal needs for the canonical directory.
127
+ */
128
+ async function unlinkManagedSymlink(candidate, fileSystem) {
129
+ const information = await fileSystem.lstat(candidate);
130
+ if (!information.isSymbolicLink()) {
131
+ throw new ProjectInstallationError("unsafe-target", `Refusing to delete ${JSON.stringify(candidate)} because it is no longer a symlink; no path was changed`);
132
+ }
133
+ await (fileSystem.unlink ?? unlink)(candidate);
134
+ }
135
+ /**
136
+ * Inspects what exposing the installed Skill to `hosts` would change without
137
+ * writing. Non-Claude Hosts share the canonical `.agents/skills` location, and
138
+ * Claude is a symlink to it; anything else at a target location is a conflict.
139
+ */
140
+ export async function inspectProjectSkillHostAddition(selection, hosts, options) {
141
+ const removal = await inspectProjectSkillRemoval(selection, options);
142
+ const fileSystem = options.fileSystem ?? nodeFileSystem;
143
+ const projectRoot = path.resolve(options.projectRoot);
144
+ const canonicalPath = path.join(projectRoot, ".agents", "skills", removal.skillName);
145
+ const claudePath = path.join(projectRoot, ".claude", "skills", removal.skillName);
146
+ const installedAt = resolveInstallationPath(projectRoot, selection.installation.path);
147
+ if (installedAt !== canonicalPath) {
148
+ throw new ProjectInstallationError("host-exposure-unavailable", `Cannot expose ${JSON.stringify(removal.skillName)} to more Hosts because it is installed at ${JSON.stringify(installedAt)}, not the canonical ${JSON.stringify(canonicalPath)}; exposing it would require copying or moving it. No path was changed.`);
149
+ }
150
+ const createPaths = [];
151
+ if (hosts.includes("claude")) {
152
+ await assertNoSymlinkAncestors(projectRoot, [".claude", "skills"], fileSystem);
153
+ const existing = await fileSystem.lstat(claudePath).catch((error) => {
154
+ if (isMissing(error))
155
+ return undefined;
156
+ throw error;
157
+ });
158
+ if (existing === undefined) {
159
+ createPaths.push(claudePath);
160
+ }
161
+ else {
162
+ const target = existing.isSymbolicLink() ? await fileSystem.readlink(claudePath) : undefined;
163
+ if (target === undefined || path.resolve(path.dirname(claudePath), target) !== canonicalPath) {
164
+ throw new ProjectInstallationError("target-conflict", `Cannot expose ${JSON.stringify(removal.skillName)} to Claude because ${JSON.stringify(claudePath)} already exists and is not the managed symlink to the installed Skill; no path was changed.`);
165
+ }
166
+ }
167
+ }
168
+ return Object.freeze({ skillName: removal.skillName, canonicalPath, claudePath, digest: removal.digest, createPaths: Object.freeze(createPaths) });
169
+ }
170
+ /**
171
+ * Creates the Host locations of an inspected addition after rechecking that
172
+ * nothing changed. The returned rollback removes only what this call created.
173
+ */
174
+ export async function addProjectSkillHosts(selection, hosts, inspection, options) {
175
+ const current = await inspectProjectSkillHostAddition(selection, hosts, options);
176
+ if (current.digest !== inspection.digest || current.createPaths.length !== inspection.createPaths.length ||
177
+ current.createPaths.some((candidate, index) => candidate !== inspection.createPaths[index])) {
178
+ throw new ProjectInstallationError("host-addition-changed", "The inspected Skill installation or Host locations changed; no path was created");
179
+ }
180
+ const fileSystem = options.fileSystem ?? nodeFileSystem;
181
+ const createdPaths = [];
182
+ const uncertainCreates = new Set();
183
+ try {
184
+ if (current.createPaths.includes(current.claudePath)) {
185
+ await createAdditionalClaudeExposure(path.resolve(options.projectRoot), current.canonicalPath, current.claudePath, fileSystem, createdPaths, uncertainCreates);
186
+ }
187
+ }
188
+ catch (error) {
189
+ return rollbackAndRethrow(error, createdPaths, uncertainCreates, fileSystem);
190
+ }
191
+ return { rollback: createCreatedPathsRollback(createdPaths, uncertainCreates, fileSystem) };
192
+ }
193
+ export async function inspectProjectSkillUpdate(selection, options) {
194
+ if (!selection.installation) {
195
+ throw new ProjectInstallationError("update-unavailable", "Cannot inspect a Skill without a recorded installation");
196
+ }
197
+ const fileSystem = options.fileSystem ?? nodeFileSystem;
198
+ if (!fileSystem.readdir || !fileSystem.readFile) {
199
+ throw new ProjectInstallationError("update-unavailable", "Skill update inspection requires filesystem read support");
200
+ }
201
+ const projectRoot = path.resolve(options.projectRoot);
202
+ const targetPath = resolveInstallationPath(projectRoot, selection.installation.path);
203
+ await assertNoSymlinkComponents(projectRoot, fileSystem);
204
+ await assertDirectory(projectRoot, fileSystem, "project root");
205
+ await assertNoSymlinkAncestors(projectRoot, relativeSegments(projectRoot, targetPath), fileSystem);
206
+ const nonCanonicalPath = await classifyTrackedInstallation(projectRoot, selection.installation.path, skillNameFromSelection(selection), options, fileSystem);
207
+ const information = await fileSystem.lstat(targetPath);
208
+ assertRealDirectory(targetPath, information);
209
+ const actualTree = await readExistingSkillTree(targetPath, selection.path, {
210
+ lstat: fileSystem.lstat,
211
+ readdir: fileSystem.readdir,
212
+ readFile: fileSystem.readFile,
213
+ });
214
+ const actualBaseline = skillTreeBaseline(actualTree);
215
+ return Object.freeze({
216
+ actualBaseline,
217
+ ...(selection.installation.baseline === undefined ? {} : { recordedBaseline: selection.installation.baseline }),
218
+ requiresOverwriteConfirmation: selection.installation.baseline === undefined ||
219
+ actualBaseline.digest !== selection.installation.baseline.digest,
220
+ ...(nonCanonicalPath === undefined ? {} : { nonCanonicalPath }),
221
+ });
222
+ }
223
+ export async function updateProjectSkill(request, options) {
224
+ const transaction = await updateProjectSkillTransaction(request, options);
225
+ await transaction.commit();
226
+ return transaction.result;
227
+ }
228
+ export async function updateProjectSkillTransaction(request, options) {
229
+ const fileSystem = options.fileSystem ?? nodeFileSystem;
230
+ if (!fileSystem.rename) {
231
+ throw new ProjectInstallationError("update-unavailable", "Transactional Skill updates require an atomic filesystem rename adapter");
232
+ }
233
+ if (!request.selection.installation) {
234
+ throw new ProjectInstallationError("update-unavailable", "Cannot update a Skill without a recorded installation");
235
+ }
236
+ const skillName = skillNameFromSelection(request.selection);
237
+ const projectRoot = path.resolve(options.projectRoot);
238
+ const targetPath = resolveInstallationPath(projectRoot, request.selection.installation.path);
239
+ await assertNoSymlinkComponents(projectRoot, fileSystem);
240
+ await assertDirectory(projectRoot, fileSystem, "project root");
241
+ await assertNoSymlinkAncestors(projectRoot, relativeSegments(projectRoot, targetPath), fileSystem);
242
+ assertNonCanonicalConfirmed(await classifyTrackedInstallation(projectRoot, request.selection.installation.path, skillName, options, fileSystem), options);
243
+ const targetInformation = await fileSystem.lstat(targetPath).catch((error) => {
244
+ if (isMissing(error)) {
245
+ throw new ProjectInstallationError("missing-installation", `The tracked Skill installation does not exist: ${targetPath}`);
246
+ }
247
+ throw error;
248
+ });
249
+ assertRealDirectory(targetPath, targetInformation);
250
+ if (!fileSystem.readdir || !fileSystem.readFile) {
251
+ throw new ProjectInstallationError("update-unavailable", "Transactional Skill updates require filesystem read support");
252
+ }
253
+ const actualTree = await readExistingSkillTree(targetPath, request.selection.path, {
254
+ lstat: fileSystem.lstat,
255
+ readdir: fileSystem.readdir,
256
+ readFile: fileSystem.readFile,
257
+ });
258
+ const recordedBaseline = request.selection.installation.baseline;
259
+ if (!recordedBaseline) {
260
+ throw new ProjectInstallationError("unknown-baseline", "The tracked Skill has no content baseline; refusing to overwrite it");
261
+ }
262
+ const actualBaseline = skillTreeBaseline(actualTree);
263
+ const locallyModified = actualBaseline.digest !== recordedBaseline.digest;
264
+ if (locallyModified && request.confirmOverwrite !== true) {
265
+ throw new ProjectInstallationError("overwrite-confirmation-required", `The tracked Skill at ${JSON.stringify(targetPath)} differs from its recorded baseline; explicit overwrite confirmation is required and no path was changed`);
266
+ }
267
+ const files = await readAndValidateTree({ ...request, previewTree: request.previewTree, portableV1: true }, skillName, options.sourceAccess);
268
+ const { stagePath, backupPath } = await prepareReplacementPaths(projectRoot, targetPath, "update", fileSystem);
269
+ let staged = false;
270
+ let moved = false;
271
+ let replacementIdentity;
272
+ try {
273
+ await fileSystem.mkdir(stagePath);
274
+ staged = true;
275
+ await writeStagedTree(stagePath, files, fileSystem);
276
+ await fileSystem.rename(targetPath, backupPath);
277
+ moved = true;
278
+ await fileSystem.rename(stagePath, targetPath);
279
+ replacementIdentity = pathIdentity(await fileSystem.lstat(targetPath));
280
+ staged = false;
281
+ }
282
+ catch (error) {
283
+ const rollbackErrors = [];
284
+ if (staged) {
285
+ await fileSystem.rm(stagePath).catch((cleanupError) => rollbackErrors.push(errorMessage(cleanupError)));
286
+ }
287
+ if (moved) {
288
+ await fileSystem.rm(targetPath).catch((cleanupError) => rollbackErrors.push(errorMessage(cleanupError)));
289
+ await fileSystem.rename(backupPath, targetPath).catch((cleanupError) => rollbackErrors.push(errorMessage(cleanupError)));
290
+ }
291
+ if (rollbackErrors.length > 0) {
292
+ throw new ProjectInstallationError("rollback-failed", `${errorMessage(error)}; rollback failed: ${rollbackErrors.join("; ")}`);
293
+ }
294
+ throw error;
295
+ }
296
+ const result = Object.freeze({
297
+ skillName,
298
+ canonicalPath: targetPath,
299
+ hosts: Object.freeze([...request.selection.hosts]),
300
+ adopted: request.selection.installation.adopted,
301
+ adoptedPaths: Object.freeze([targetPath]),
302
+ ...installedTreeSummary(request, files),
303
+ });
304
+ let state = "open";
305
+ return Object.freeze({
306
+ result,
307
+ rollback: async () => {
308
+ if (state !== "open")
309
+ return;
310
+ state = "rolled-back";
311
+ const errors = [];
312
+ await removeVerifiedReplacement(targetPath, replacementIdentity, fileSystem, errors);
313
+ if (errors.length === 0) {
314
+ try {
315
+ await fileSystem.rename(backupPath, targetPath);
316
+ }
317
+ catch (error) {
318
+ errors.push(`could not restore original Skill: ${errorMessage(error)}`);
319
+ }
320
+ }
321
+ if (errors.length > 0) {
322
+ throw new ProjectInstallationError("rollback-failed", `Rollback was incomplete: ${errors.join("; ")}`);
323
+ }
324
+ },
325
+ commit: async () => {
326
+ if (state !== "open")
327
+ return;
328
+ await fileSystem.rm(backupPath);
329
+ state = "committed";
330
+ },
331
+ });
332
+ }
333
+ export class ProjectInstallationError extends Error {
334
+ code;
335
+ constructor(code, message) {
336
+ super(message);
337
+ this.name = "ProjectInstallationError";
338
+ this.code = code;
339
+ }
340
+ }
341
+ const V1_HOSTS = ["pi", "claude", "codex", "opencode"];
342
+ const nodeFileSystem = {
343
+ lstat,
344
+ readdir: async (candidate) => readdir(candidate),
345
+ readFile: async (candidate) => readFile(candidate),
346
+ mkdir: async (candidate) => {
347
+ await mkdir(candidate);
348
+ },
349
+ writeFile: async (candidate, content) => {
350
+ await writeFile(candidate, content, { flag: "wx" });
351
+ },
352
+ chmod,
353
+ symlink: async (target, candidate, type) => {
354
+ await symlink(target, candidate, type);
355
+ },
356
+ rename,
357
+ readlink,
358
+ unlink,
359
+ rm: async (candidate) => {
360
+ await rm(candidate, { recursive: true, force: true });
361
+ },
362
+ rmdir,
363
+ };
364
+ /**
365
+ * Classifies known installation locations without changing the filesystem.
366
+ * Existing directories are read through lstat/readdir/readFile only; symlinks
367
+ * are never followed while deciding whether a destination is safe.
368
+ */
369
+ export async function inspectProjectSkillInstallation(request, options) {
370
+ const fileSystem = options.fileSystem ?? nodeFileSystem;
371
+ if (!fileSystem.readdir || !fileSystem.readFile) {
372
+ throw new ProjectInstallationError("adoption-unavailable", "Cannot inspect an existing project Skill because directory and file reading are unavailable; retry with the standard filesystem adapter.");
373
+ }
374
+ const skillName = skillNameFromSelection(request.selection);
375
+ const projectRoot = path.resolve(options.projectRoot);
376
+ await assertNoSymlinkComponents(projectRoot, fileSystem);
377
+ await assertDirectory(projectRoot, fileSystem, "project root");
378
+ const files = await readAndValidateTree(request, skillName, options.sourceAccess);
379
+ validateCompatibility(request, skillName, files);
380
+ const canonicalPath = path.join(projectRoot, ".agents", "skills", skillName);
381
+ const claudePath = path.join(projectRoot, ".claude", "skills", skillName);
382
+ await assertNoSymlinkAncestors(projectRoot, [".agents", "skills"], fileSystem);
383
+ await assertNoSymlinkAncestors(projectRoot, [".claude", "skills"], fileSystem);
384
+ const locations = [];
385
+ const expectedTree = files.map((file) => file.source);
386
+ const treeAccess = { lstat: fileSystem.lstat, readdir: fileSystem.readdir, readFile: fileSystem.readFile };
387
+ for (const candidate of [canonicalPath, claudePath]) {
388
+ const canonicalIdentical = locations.some((location) => location.path === canonicalPath && location.status === "identical");
389
+ locations.push(await inspectSkillLocation(candidate, {
390
+ canonicalPath,
391
+ claudePath,
392
+ canonicalIdentical,
393
+ selectionPath: request.selection.path,
394
+ expectedTree,
395
+ fileSystem,
396
+ treeAccess,
397
+ }));
398
+ }
399
+ const firstExisting = locations.find((location) => location.status !== "missing");
400
+ const targetPath = firstExisting?.path ?? canonicalPath;
401
+ const collision = locations.find((location) => location.status === "collision")?.collision;
402
+ return Object.freeze({
403
+ canonicalPath,
404
+ claudePath,
405
+ targetPath,
406
+ locations: Object.freeze(locations),
407
+ ...(collision === undefined ? {} : { collision }),
408
+ });
409
+ }
410
+ function collisionLocation(candidate, reason, evidence = {}) {
411
+ const collision = Object.freeze({ path: candidate, reason });
412
+ return Object.freeze({ path: candidate, status: "collision", collision, ...evidence });
413
+ }
414
+ async function inspectSkillLocation(candidate, context) {
415
+ const { fileSystem } = context;
416
+ let information;
417
+ try {
418
+ information = await fileSystem.lstat(candidate);
419
+ }
420
+ catch (error) {
421
+ if (isMissing(error))
422
+ return Object.freeze({ path: candidate, status: "missing" });
423
+ throw error;
424
+ }
425
+ if (information.isSymbolicLink()) {
426
+ return await isClaudeExposureOfCanonical(candidate, context)
427
+ ? Object.freeze({ path: candidate, status: "identical" })
428
+ : collisionLocation(candidate, "symbolic-link");
429
+ }
430
+ if (!information.isDirectory()) {
431
+ return collisionLocation(candidate, "not-directory", { identity: pathIdentityKey(pathIdentity(information)) });
432
+ }
433
+ let actualTree;
434
+ try {
435
+ actualTree = await readExistingSkillTree(candidate, context.selectionPath, context.treeAccess);
436
+ }
437
+ catch {
438
+ return collisionLocation(candidate, "unsafe-file");
439
+ }
440
+ const comparison = compareSkillTrees(context.expectedTree, actualTree);
441
+ const evidence = {
442
+ identity: pathIdentityKey(pathIdentity(information)),
443
+ digest: skillTreeBaseline(actualTree).digest,
444
+ };
445
+ return comparison.identical
446
+ ? Object.freeze({ path: candidate, status: "identical", ...evidence })
447
+ : collisionLocation(candidate, comparison.reason ?? "content-mismatch", evidence);
448
+ }
449
+ /** A Claude symlink is an exposure only when it points to the canonical Skill that was itself found identical. */
450
+ async function isClaudeExposureOfCanonical(candidate, context) {
451
+ if (candidate !== context.claudePath || !context.canonicalIdentical)
452
+ return false;
453
+ try {
454
+ const target = await context.fileSystem.readlink(candidate);
455
+ return path.resolve(path.dirname(candidate), target) === context.canonicalPath;
456
+ }
457
+ catch {
458
+ return false;
459
+ }
460
+ }
461
+ /** Validates the immutable preview tree and its requested Host compatibility without writing. */
462
+ export async function validateProjectSkillInstallationPreview(request, sourceAccess) {
463
+ const skillName = skillNameFromSelection(request.selection);
464
+ const files = await readAndValidateTree(request, skillName, sourceAccess);
465
+ validateCompatibility(request, skillName, files);
466
+ }
467
+ /**
468
+ * Installs one selected Skill into a scope without consulting user-global
469
+ * state. Existing identical paths are adopted; explicit overwrite is limited
470
+ * to an inspected, untracked real directory and retains a persistent backup.
471
+ */
472
+ export async function installProjectSkill(request, options) {
473
+ const transaction = await installProjectSkillTransaction(request, options);
474
+ return transaction.result;
475
+ }
476
+ /**
477
+ * Explicit host evidence can reject before reading source bytes. The portable
478
+ * baseline is different: its evidence is the validated tree itself, never a
479
+ * caller's assertion alone.
480
+ */
481
+ function validateEarlyCompatibility(request, skillName) {
482
+ const hasDeclaredIncompatibility = Object.values(request.compatibility ?? {}).some((value) => value === false || typeof value === "string");
483
+ const hasUnsupportedSelectedHost = request.compatibleHosts !== undefined &&
484
+ request.selection.hosts.some((host) => !request.compatibleHosts?.includes(host));
485
+ if (hasUnsupportedSelectedHost || hasDeclaredIncompatibility || request.portableV1 !== true) {
486
+ return validateCompatibility(request, skillName, undefined);
487
+ }
488
+ return undefined;
489
+ }
490
+ /**
491
+ * Installs one Skill and retains a transaction receipt for a surrounding
492
+ * manifest/state transaction. Explicit overwrite moves the original directory
493
+ * aside atomically so rollback can restore it if persistence fails.
494
+ */
495
+ export async function installProjectSkillTransaction(request, options) {
496
+ const fileSystem = options.fileSystem ?? nodeFileSystem;
497
+ const skillName = skillNameFromSelection(request.selection);
498
+ const projectRoot = path.resolve(options.projectRoot);
499
+ await assertNoSymlinkComponents(projectRoot, fileSystem);
500
+ await assertDirectory(projectRoot, fileSystem, "project root");
501
+ const earlyHosts = validateEarlyCompatibility(request, skillName);
502
+ const files = await readAndValidateTree(request, skillName, options.sourceAccess);
503
+ const baseline = skillTreeBaseline(files.map((file) => file.source));
504
+ const hosts = earlyHosts ?? validateCompatibility(request, skillName, files);
505
+ const canonicalSkillsPath = path.join(projectRoot, ".agents", "skills");
506
+ const canonicalPath = path.join(canonicalSkillsPath, skillName);
507
+ const claudeSkillsPath = path.join(projectRoot, ".claude", "skills");
508
+ const claudePath = path.join(claudeSkillsPath, skillName);
509
+ if (request.overwrite === true) {
510
+ const inspection = request.inspection ?? await inspectProjectSkillInstallation(request, options);
511
+ if (inspection.canonicalPath !== canonicalPath || inspection.claudePath !== claudePath) {
512
+ throw new ProjectInstallationError("target-changed", "The inspected overwrite receipt belongs to a different Skill destination; no path was changed");
513
+ }
514
+ if (inspection.collision !== undefined) {
515
+ assertOverwriteCollision(inspection);
516
+ return overwriteProjectSkillTransaction(request, options, files, hosts, inspection);
517
+ }
518
+ }
519
+ const createdPaths = [];
520
+ const uncertainCreates = new Set();
521
+ try {
522
+ // Check every existing ancestor before creating either host tree. This
523
+ // prevents a later host check from following a pre-existing symlink after
524
+ // the other host's directories have already been created.
525
+ await assertNoSymlinkAncestors(projectRoot, [".agents", "skills", skillName], fileSystem);
526
+ // A Claude Skill path may be the managed symlink to the canonical tree;
527
+ // adoption validates that final component before following it.
528
+ await assertNoSymlinkAncestors(projectRoot, [".claude", "skills"], fileSystem);
529
+ const adoption = await adoptExistingProjectSkill(projectRoot, skillName, request.selection.path, files, hosts, fileSystem, request.confirmAdditionalHostExposure === true, createdPaths, uncertainCreates);
530
+ if (adoption) {
531
+ return Object.freeze({
532
+ result: buildInstallationResult({
533
+ request, skillName, canonicalPath: adoption.canonicalPath, claudePath, hosts, files, baseline,
534
+ adoptedPaths: adoption.paths,
535
+ }),
536
+ rollback: createCreatedPathsRollback(createdPaths, uncertainCreates, fileSystem),
537
+ });
538
+ }
539
+ await ensureDirectoryTree(projectRoot, [".agents", "skills"], createdPaths, uncertainCreates, fileSystem);
540
+ if (hosts.includes("claude")) {
541
+ await ensureDirectoryTree(projectRoot, [".claude", "skills"], createdPaths, uncertainCreates, fileSystem);
542
+ }
543
+ await assertMissing(canonicalPath, fileSystem, canonicalPath);
544
+ if (hosts.includes("claude")) {
545
+ await assertMissing(claudePath, fileSystem, claudePath);
546
+ }
547
+ await createDirectory(canonicalPath, createdPaths, uncertainCreates, fileSystem);
548
+ for (const file of files) {
549
+ await writeInstalledFile(canonicalPath, file, createdPaths, uncertainCreates, fileSystem);
550
+ }
551
+ if (hosts.includes("claude")) {
552
+ await createClaudeSkillSymlink(claudePath, canonicalPath, createdPaths, uncertainCreates, fileSystem);
553
+ }
554
+ return Object.freeze({
555
+ result: buildInstallationResult({ request, skillName, canonicalPath, claudePath, hosts, files, baseline, adoptedPaths: undefined }),
556
+ rollback: createCreatedPathsRollback(createdPaths, uncertainCreates, fileSystem),
557
+ });
558
+ }
559
+ catch (error) {
560
+ return rollbackAndRethrow(error, createdPaths, uncertainCreates, fileSystem);
561
+ }
562
+ }
563
+ /** The result fields describing the installed tree, shared by the update and overwrite results. */
564
+ function installedTreeSummary(request, files) {
565
+ return {
566
+ files: Object.freeze(files.map((file) => file.relativePath)),
567
+ baseline: skillTreeBaseline(files.map((file) => file.source)),
568
+ ...(request.resolvedVersion === undefined ? {} : { resolvedVersion: request.resolvedVersion }),
569
+ };
570
+ }
571
+ function buildInstallationResult(parts) {
572
+ const { request, skillName, canonicalPath, claudePath, hosts, files, baseline, adoptedPaths } = parts;
573
+ return Object.freeze({
574
+ skillName,
575
+ canonicalPath,
576
+ ...(hosts.includes("claude") ? { claudePath } : {}),
577
+ hosts: Object.freeze([...hosts]),
578
+ files: Object.freeze(files.map((file) => file.relativePath)),
579
+ adopted: adoptedPaths !== undefined,
580
+ adoptedPaths: Object.freeze([...(adoptedPaths ?? [])]),
581
+ baseline,
582
+ ...(request.resolvedVersion === undefined ? {} : { resolvedVersion: request.resolvedVersion }),
583
+ });
584
+ }
585
+ function createCreatedPathsRollback(createdPaths, uncertainCreates, fileSystem) {
586
+ return async () => {
587
+ const cleanupErrors = await rollbackCreatedPaths(createdPaths, uncertainCreates, fileSystem);
588
+ if (cleanupErrors.length > 0) {
589
+ throw new ProjectInstallationError("rollback-failed", `Rollback was incomplete: ${cleanupErrors.join("; ")}`);
590
+ }
591
+ };
592
+ }
593
+ /** Writes one validated file and confirms the result is a regular file, not a link or other entry. */
594
+ async function writeRegularFile(destination, file, description, fileSystem) {
595
+ await fileSystem.writeFile(destination, file.source.content);
596
+ const information = await fileSystem.lstat(destination);
597
+ if (information.isSymbolicLink() || !information.isFile()) {
598
+ throw new ProjectInstallationError("unsafe-target", `${description} file is not a regular file: ${destination}`);
599
+ }
600
+ return information;
601
+ }
602
+ async function writeInstalledFile(canonicalPath, file, createdPaths, uncertainCreates, fileSystem) {
603
+ const destination = path.join(canonicalPath, ...file.relativePath.split("/"));
604
+ await ensureDirectoryTree(canonicalPath, file.relativePath.split("/").slice(0, -1), createdPaths, uncertainCreates, fileSystem);
605
+ uncertainCreates.add(destination);
606
+ const information = await writeRegularFile(destination, file, "Installed", fileSystem);
607
+ createdPaths.push({ path: destination, kind: "file", identity: pathIdentity(information) });
608
+ uncertainCreates.delete(destination);
609
+ await fileSystem.chmod(destination, file.source.executable ? 0o755 : 0o644);
610
+ }
611
+ async function createClaudeSkillSymlink(claudePath, canonicalPath, createdPaths, uncertainCreates, fileSystem) {
612
+ const linkTarget = path.relative(path.dirname(claudePath), canonicalPath);
613
+ uncertainCreates.add(claudePath);
614
+ try {
615
+ await fileSystem.symlink(linkTarget, claudePath, "dir");
616
+ }
617
+ catch (error) {
618
+ const reason = error instanceof Error ? error.message : String(error);
619
+ throw new ProjectInstallationError("claude-symlink-failed", `Cannot create the Claude Skill symlink at ${JSON.stringify(claudePath)}: ${reason}. ` +
620
+ "Grant this process permission to create directory symlinks (on Windows, enable Developer Mode or the required symlink privilege) and retry. " +
621
+ "No duplicate managed copy was created; the canonical installation was rolled back.");
622
+ }
623
+ await recordCreatedClaudeSymlink(claudePath, createdPaths, uncertainCreates, fileSystem);
624
+ }
625
+ async function recordCreatedClaudeSymlink(claudePath, createdPaths, uncertainCreates, fileSystem) {
626
+ const information = await fileSystem.lstat(claudePath);
627
+ if (!information.isSymbolicLink()) {
628
+ throw new ProjectInstallationError("unsafe-target", `Claude installation path is not a symbolic link: ${claudePath}`);
629
+ }
630
+ createdPaths.push({ path: claudePath, kind: "symlink", identity: pathIdentity(information) });
631
+ uncertainCreates.delete(claudePath);
632
+ }
633
+ function assertOverwriteCollision(inspection) {
634
+ const collision = inspection.collision;
635
+ if (!collision)
636
+ return;
637
+ if (collision.reason !== "missing-file" && collision.reason !== "extra-file" && collision.reason !== "content-mismatch") {
638
+ throw new ProjectInstallationError("unsafe-target", `Cannot overwrite ${JSON.stringify(collision.path)} because it is not an untracked real Skill directory; no path was changed`);
639
+ }
640
+ const conflictingLocations = inspection.locations.filter((location) => location.status === "collision");
641
+ if (conflictingLocations.length !== 1) {
642
+ throw new ProjectInstallationError("ambiguous-target", `Cannot overwrite Skill ${JSON.stringify(inspection.targetPath)} because its Host locations are ambiguous; no path was changed`);
643
+ }
644
+ const otherExisting = inspection.locations.filter((location) => location.path !== collision.path && location.status !== "missing");
645
+ if (otherExisting.some((location) => location.path !== inspection.claudePath || location.status !== "identical")) {
646
+ throw new ProjectInstallationError("ambiguous-target", `Cannot overwrite Skill ${JSON.stringify(inspection.targetPath)} because another Host location is also present; no path was changed`);
647
+ }
648
+ // A real Claude directory is an intentional adoption when it is the only
649
+ // existing location (see adoptExistingProjectSkill). It is ambiguous during
650
+ // canonical replacement, however: leaving that duplicate directory in place
651
+ // would make Claude continue reading the old bytes after the canonical move.
652
+ const claudeLocation = inspection.locations.find((location) => location.path === inspection.claudePath);
653
+ if (inspection.targetPath === inspection.canonicalPath && claudeLocation?.status === "identical" && claudeLocation.identity !== undefined) {
654
+ throw new ProjectInstallationError("ambiguous-target", `Cannot overwrite ${JSON.stringify(inspection.targetPath)} because the Claude location is a separate real directory rather than the managed symlink; no path was changed`);
655
+ }
656
+ }
657
+ function targetLocation(inspection) {
658
+ return inspection.locations.find((location) => location.path === inspection.targetPath) ??
659
+ (() => { throw new ProjectInstallationError("unsafe-target", `Cannot identify overwrite target ${inspection.targetPath}`); })();
660
+ }
661
+ function assertNoOverlappingOverwritePaths(targetPath, backupPath, protectedPaths) {
662
+ const candidates = [targetPath, backupPath, ...protectedPaths].map((candidate) => path.resolve(candidate));
663
+ for (let leftIndex = 0; leftIndex < candidates.length; leftIndex += 1) {
664
+ for (let rightIndex = leftIndex + 1; rightIndex < candidates.length; rightIndex += 1) {
665
+ if (!pathsOverlap(candidates[leftIndex], candidates[rightIndex]))
666
+ continue;
667
+ throw new ProjectInstallationError("managed-target", `Cannot overwrite ${JSON.stringify(targetPath)} because the installation or retained backup overlaps a managed Skill path; no path was changed`);
668
+ }
669
+ }
670
+ }
671
+ async function assertOverwriteInspectionUnchanged(request, options, expected) {
672
+ const current = await inspectProjectSkillInstallation(request, options);
673
+ const expectedTarget = targetLocation(expected);
674
+ const currentTarget = targetLocation(current);
675
+ const locationsMatch = current.locations.length === expected.locations.length && current.locations.every((location, index) => {
676
+ const expectedLocation = expected.locations[index];
677
+ return expectedLocation !== undefined &&
678
+ location.path === expectedLocation.path &&
679
+ location.status === expectedLocation.status &&
680
+ location.collision?.reason === expectedLocation.collision?.reason &&
681
+ location.identity === expectedLocation.identity &&
682
+ location.digest === expectedLocation.digest;
683
+ });
684
+ if (current.canonicalPath !== expected.canonicalPath ||
685
+ current.claudePath !== expected.claudePath ||
686
+ current.targetPath !== expected.targetPath ||
687
+ current.collision?.path !== expected.collision?.path ||
688
+ current.collision?.reason !== expected.collision?.reason ||
689
+ !locationsMatch ||
690
+ currentTarget.identity !== expectedTarget.identity ||
691
+ currentTarget.digest !== expectedTarget.digest) {
692
+ throw new ProjectInstallationError("target-changed", `The inspected overwrite target changed before mutation; no path was changed`);
693
+ }
694
+ }
695
+ async function assertBackupMatchesInspection(backupPath, request, expected, fileSystem) {
696
+ if (expected.identity === undefined || expected.digest === undefined) {
697
+ throw new ProjectInstallationError("backup-changed", `The retained backup at ${JSON.stringify(backupPath)} has no complete inspection receipt; the operation was stopped without deleting data`);
698
+ }
699
+ const information = await fileSystem.lstat(backupPath);
700
+ if (information.isSymbolicLink() || !information.isDirectory() || pathIdentityKey(pathIdentity(information)) !== expected.identity) {
701
+ throw new ProjectInstallationError("backup-changed", `The retained backup at ${JSON.stringify(backupPath)} changed identity after the original tree was moved; the operation was stopped without deleting data`);
702
+ }
703
+ if (!fileSystem.readdir || !fileSystem.readFile) {
704
+ throw new ProjectInstallationError("backup-changed", "The retained backup cannot be verified without directory and file reads; the operation was stopped without deleting data");
705
+ }
706
+ let tree;
707
+ try {
708
+ tree = await readExistingSkillTree(backupPath, request.selection.path, {
709
+ lstat: fileSystem.lstat,
710
+ readdir: fileSystem.readdir,
711
+ readFile: fileSystem.readFile,
712
+ });
713
+ }
714
+ catch (error) {
715
+ throw new ProjectInstallationError("backup-changed", `The retained backup at ${JSON.stringify(backupPath)} could not be read safely (${errorMessage(error)}); the operation was stopped without deleting data`);
716
+ }
717
+ const digest = skillTreeBaseline(tree).digest;
718
+ if (digest !== expected.digest) {
719
+ throw new ProjectInstallationError("backup-changed", `The retained backup at ${JSON.stringify(backupPath)} differs from the preview receipt; the operation was stopped without deleting data`);
720
+ }
721
+ }
722
+ async function assertReplacementMatchesPreview(targetPath, request, files, expectedIdentity, fileSystem) {
723
+ const information = await fileSystem.lstat(targetPath);
724
+ if (information.isSymbolicLink() || !information.isDirectory() || !samePathIdentity(expectedIdentity, pathIdentity(information))) {
725
+ throw new ProjectInstallationError("replacement-changed", `Preserved concurrently replaced Skill at ${JSON.stringify(targetPath)}; rollback did not delete it`);
726
+ }
727
+ if (!fileSystem.readdir || !fileSystem.readFile) {
728
+ throw new ProjectInstallationError("replacement-changed", "The replacement cannot be verified without directory and file reads; rollback did not delete it");
729
+ }
730
+ let tree;
731
+ try {
732
+ tree = await readExistingSkillTree(targetPath, request.selection.path, {
733
+ lstat: fileSystem.lstat,
734
+ readdir: fileSystem.readdir,
735
+ readFile: fileSystem.readFile,
736
+ });
737
+ }
738
+ catch (error) {
739
+ throw new ProjectInstallationError("replacement-changed", `Preserved concurrently replaced Skill at ${JSON.stringify(targetPath)} (${errorMessage(error)}); rollback did not delete it`);
740
+ }
741
+ const expectedDigest = skillTreeBaseline(files.map((file) => file.source)).digest;
742
+ if (skillTreeBaseline(tree).digest !== expectedDigest) {
743
+ throw new ProjectInstallationError("replacement-changed", `Preserved concurrently modified Skill at ${JSON.stringify(targetPath)}; rollback did not delete it`);
744
+ }
745
+ }
746
+ async function collectError(errors, action) {
747
+ try {
748
+ await action();
749
+ }
750
+ catch (error) {
751
+ errors.push(errorMessage(error));
752
+ }
753
+ }
754
+ async function restoreOverwrite(context, progress) {
755
+ const { stagePath, fileSystem } = context;
756
+ const errors = [];
757
+ errors.push(...await rollbackCreatedPaths(progress.exposureCreated, progress.exposureUncertain, fileSystem));
758
+ if (progress.stageCreated) {
759
+ await fileSystem.rm(stagePath).catch((error) => errors.push(`could not remove staging tree: ${errorMessage(error)}`));
760
+ }
761
+ if (progress.moved) {
762
+ await restoreMovedOriginal(context, progress, errors);
763
+ }
764
+ return errors;
765
+ }
766
+ async function restoreMovedOriginal(context, progress, errors) {
767
+ const { request, files, inspection, targetPath, backupPath, fileSystem } = context;
768
+ const { replacementIdentity } = progress;
769
+ if (replacementIdentity !== undefined) {
770
+ await collectError(errors, () => assertReplacementMatchesPreview(targetPath, request, files, replacementIdentity, fileSystem));
771
+ }
772
+ else if (progress.replacementInstalled) {
773
+ errors.push(`preserved replacement at ${JSON.stringify(targetPath)} because its identity could not be verified`);
774
+ }
775
+ if (errors.length === 0 && replacementIdentity !== undefined) {
776
+ await collectError(errors, () => assertBackupMatchesInspection(backupPath, request, targetLocation(inspection), fileSystem));
777
+ }
778
+ if (errors.length === 0 && replacementIdentity !== undefined) {
779
+ await removeVerifiedReplacement(targetPath, replacementIdentity, fileSystem, errors);
780
+ }
781
+ if (errors.length === 0) {
782
+ await fileSystem.rename(backupPath, targetPath).catch((error) => {
783
+ errors.push(`could not restore original Skill: ${errorMessage(error)}`);
784
+ });
785
+ }
786
+ }
787
+ /**
788
+ * Recheck ownership immediately before the destructive remove. Portable
789
+ * filesystem APIs do not provide a cross-process compare-and-swap, so a
790
+ * writer can still race after this check; any observed identity/content
791
+ * drift is nevertheless preserved and fails closed.
792
+ */
793
+ async function removeVerifiedReplacement(targetPath, replacementIdentity, fileSystem, errors) {
794
+ try {
795
+ const current = await fileSystem.lstat(targetPath);
796
+ if (!replacementIdentity || !samePathIdentity(replacementIdentity, pathIdentity(current))) {
797
+ errors.push(`preserved concurrently replaced Skill at ${JSON.stringify(targetPath)}`);
798
+ }
799
+ else {
800
+ await fileSystem.rm(targetPath);
801
+ }
802
+ }
803
+ catch (error) {
804
+ if (!isMissing(error))
805
+ errors.push(`could not remove replacement: ${errorMessage(error)}`);
806
+ }
807
+ }
808
+ async function overwriteProjectSkillTransaction(request, options, files, hosts, inspection) {
809
+ const fileSystem = options.fileSystem ?? nodeFileSystem;
810
+ if (!fileSystem.rename) {
811
+ throw new ProjectInstallationError("overwrite-unavailable", "Explicit Skill overwrite requires an atomic filesystem rename adapter");
812
+ }
813
+ const targetPath = inspection.targetPath;
814
+ const projectRoot = path.resolve(options.projectRoot);
815
+ const { stagePath, backupPath } = await prepareReplacementPaths(projectRoot, targetPath, "overwrite", fileSystem);
816
+ assertNoOverlappingOverwritePaths(targetPath, backupPath, request.protectedPaths ?? []);
817
+ await assertMissing(stagePath, fileSystem, stagePath);
818
+ await assertMissing(backupPath, fileSystem, backupPath);
819
+ const progress = {
820
+ stageCreated: false,
821
+ moved: false,
822
+ replacementInstalled: false,
823
+ replacementIdentity: undefined,
824
+ exposureCreated: [],
825
+ exposureUncertain: new Set(),
826
+ };
827
+ const { exposureCreated, exposureUncertain } = progress;
828
+ const restore = () => restoreOverwrite({ request, files, inspection, targetPath, stagePath, backupPath, fileSystem }, progress);
829
+ try {
830
+ await fileSystem.mkdir(stagePath);
831
+ progress.stageCreated = true;
832
+ await writeStagedTree(stagePath, files, fileSystem);
833
+ // Revalidate after all potentially slow staging work and immediately before
834
+ // moving the user's original tree. The preview bytes are immutable and no
835
+ // external installation method has run at this point.
836
+ await assertOverwriteInspectionUnchanged(request, options, inspection);
837
+ await fileSystem.rename(targetPath, backupPath);
838
+ progress.moved = true;
839
+ // Rename preserves the original tree as a recovery boundary. Verify both
840
+ // its native directory identity and its complete content receipt before the
841
+ // staged tree is exposed at the installation path.
842
+ await assertBackupMatchesInspection(backupPath, request, targetLocation(inspection), fileSystem);
843
+ await fileSystem.rename(stagePath, targetPath);
844
+ progress.stageCreated = false;
845
+ progress.replacementInstalled = true;
846
+ progress.replacementIdentity = pathIdentity(await fileSystem.lstat(targetPath));
847
+ const claudeLocation = inspection.locations.find((location) => location.path === inspection.claudePath);
848
+ if (targetPath === inspection.canonicalPath && hosts.includes("claude") && claudeLocation?.status === "missing") {
849
+ await ensureDirectoryTree(projectRoot, [".claude", "skills"], exposureCreated, exposureUncertain, fileSystem);
850
+ const linkTarget = path.relative(path.dirname(inspection.claudePath), inspection.canonicalPath);
851
+ exposureUncertain.add(inspection.claudePath);
852
+ await fileSystem.symlink(linkTarget, inspection.claudePath, "dir");
853
+ const linkInformation = await fileSystem.lstat(inspection.claudePath);
854
+ if (!linkInformation.isSymbolicLink()) {
855
+ throw new ProjectInstallationError("unsafe-target", `Claude installation path is not a symbolic link: ${inspection.claudePath}`);
856
+ }
857
+ exposureCreated.push({ path: inspection.claudePath, kind: "symlink", identity: pathIdentity(linkInformation) });
858
+ exposureUncertain.delete(inspection.claudePath);
859
+ }
860
+ }
861
+ catch (error) {
862
+ const rollbackErrors = await restore();
863
+ if (rollbackErrors.length > 0) {
864
+ throw new ProjectInstallationError("rollback-failed", `${errorMessage(error)}; rollback was incomplete: ${rollbackErrors.join("; ")}`);
865
+ }
866
+ throw error;
867
+ }
868
+ const result = Object.freeze({
869
+ skillName: skillNameFromSelection(request.selection),
870
+ canonicalPath: targetPath,
871
+ ...(hosts.includes("claude") ? { claudePath: inspection.claudePath } : {}),
872
+ backupPath,
873
+ hosts: Object.freeze([...hosts]),
874
+ adopted: false,
875
+ adoptedPaths: Object.freeze([]),
876
+ ...installedTreeSummary(request, files),
877
+ });
878
+ let state = "open";
879
+ return Object.freeze({
880
+ result,
881
+ rollback: async () => {
882
+ if (state !== "open")
883
+ return;
884
+ state = "rolled-back";
885
+ const rollbackErrors = await restore();
886
+ if (rollbackErrors.length > 0) {
887
+ throw new ProjectInstallationError("rollback-failed", `Rollback was incomplete: ${rollbackErrors.join("; ")}`);
888
+ }
889
+ },
890
+ });
891
+ }
892
+ function adoptionTreeAccess(fileSystem) {
893
+ const unavailable = (what) => new ProjectInstallationError("adoption-unavailable", `Cannot inspect an existing project Skill because ${what} reading is unavailable; retry with the standard filesystem adapter.`);
894
+ return {
895
+ lstat: fileSystem.lstat,
896
+ readdir: async (candidate) => {
897
+ if (!fileSystem.readdir)
898
+ throw unavailable("directory");
899
+ return fileSystem.readdir(candidate);
900
+ },
901
+ readFile: async (candidate) => {
902
+ if (!fileSystem.readFile)
903
+ throw unavailable("file");
904
+ return fileSystem.readFile(candidate);
905
+ },
906
+ };
907
+ }
908
+ async function assertExistingSkillMatches(candidate, sourceSkillPath, expectedTree, treeAccess) {
909
+ let actualTree;
910
+ try {
911
+ actualTree = await readExistingSkillTree(candidate, sourceSkillPath, treeAccess);
912
+ }
913
+ catch (error) {
914
+ throw new ProjectInstallationError("unsafe-target", `Cannot safely inspect existing Skill at ${JSON.stringify(candidate)}: ${errorMessage(error)}. No path was changed.`);
915
+ }
916
+ const comparison = compareSkillTrees(expectedTree, actualTree);
917
+ if (!comparison.identical) {
918
+ throw new ProjectInstallationError("adoption-conflict", `Cannot adopt because an existing Skill already exists at ${JSON.stringify(candidate)} with different content (${comparison.reason}); explicit confirmation would be required to overwrite it, but this safe install flow has no overwrite operation. No path was changed.`);
919
+ }
920
+ }
921
+ async function createAdditionalClaudeExposure(projectRoot, canonicalPath, claudePath, fileSystem, createdPaths, uncertainCreates) {
922
+ await ensureDirectoryTree(projectRoot, [".claude", "skills"], createdPaths, uncertainCreates, fileSystem);
923
+ await assertMissing(claudePath, fileSystem, claudePath);
924
+ uncertainCreates.add(claudePath);
925
+ try {
926
+ await fileSystem.symlink(path.relative(path.dirname(claudePath), canonicalPath), claudePath, "dir");
927
+ }
928
+ catch (error) {
929
+ throw new ProjectInstallationError("claude-symlink-failed", `Cannot create the additional Claude Skill symlink at ${JSON.stringify(claudePath)}: ${errorMessage(error)}. No existing path was changed.`);
930
+ }
931
+ await recordCreatedClaudeSymlink(claudePath, createdPaths, uncertainCreates, fileSystem);
932
+ }
933
+ function assertAdditionalExposureAllowed(existingPaths, canonicalPath, missingClaude, confirmed) {
934
+ if (!confirmed) {
935
+ const missing = missingClaude ? "Claude exposure" : "another Host location";
936
+ throw new ProjectInstallationError("adoption-confirmation-required", `An identical Skill was found at ${JSON.stringify(existingPaths[0])}, but adding the missing ${missing} requires explicit confirmation; ` +
937
+ "adoption will not create, move, or overwrite any path. Re-run with --confirm-additional-host and --yes.");
938
+ }
939
+ if (!existingPaths.includes(canonicalPath)) {
940
+ throw new ProjectInstallationError("adoption-confirmation-required", `An identical Skill was found at ${JSON.stringify(existingPaths[0])}, but safely exposing it at another Host location would require moving or copying it; ` +
941
+ "this operation does not move or duplicate adopted Skills, so no path was changed.");
942
+ }
943
+ }
944
+ async function adoptExistingProjectSkill(projectRoot, skillName, sourceSkillPath, expectedFiles, hosts, fileSystem, confirmAdditionalHostExposure, createdPaths, uncertainCreates) {
945
+ const canonicalPath = path.join(projectRoot, ".agents", "skills", skillName);
946
+ const claudePath = path.join(projectRoot, ".claude", "skills", skillName);
947
+ const existingPaths = [];
948
+ const expectedTree = expectedFiles.map((file) => file.source);
949
+ const treeAccess = adoptionTreeAccess(fileSystem);
950
+ for (const candidate of [canonicalPath, claudePath]) {
951
+ let information;
952
+ try {
953
+ information = await fileSystem.lstat(candidate);
954
+ }
955
+ catch (error) {
956
+ if (isMissing(error))
957
+ continue;
958
+ throw error;
959
+ }
960
+ if (information.isSymbolicLink()) {
961
+ if (candidate !== claudePath) {
962
+ throw new ProjectInstallationError("unsafe-target", `Cannot inspect existing Skill at ${JSON.stringify(candidate)} because it is a symbolic link; no path was changed.`);
963
+ }
964
+ await assertClaudeSymlinkTargetsCanonical(candidate, canonicalPath, existingPaths, fileSystem);
965
+ }
966
+ else if (!information.isDirectory()) {
967
+ throw new ProjectInstallationError("adoption-conflict", `Cannot adopt because the existing Skill path ${JSON.stringify(candidate)} is not a directory; explicit confirmation would be required to overwrite it, but this safe install flow has no overwrite operation. No path was changed.`);
968
+ }
969
+ else {
970
+ await assertExistingSkillMatches(candidate, sourceSkillPath, expectedTree, treeAccess);
971
+ }
972
+ existingPaths.push(candidate);
973
+ }
974
+ if (existingPaths.length === 0)
975
+ return undefined;
976
+ const missingClaude = hosts.includes("claude") && !existingPaths.includes(claudePath);
977
+ const missingCanonical = hosts.some((host) => host !== "claude") && !existingPaths.includes(canonicalPath);
978
+ if (missingCanonical || missingClaude) {
979
+ assertAdditionalExposureAllowed(existingPaths, canonicalPath, missingClaude, confirmAdditionalHostExposure);
980
+ await createAdditionalClaudeExposure(projectRoot, canonicalPath, claudePath, fileSystem, createdPaths, uncertainCreates);
981
+ existingPaths.push(claudePath);
982
+ }
983
+ return Object.freeze({
984
+ canonicalPath: existingPaths.includes(canonicalPath) ? canonicalPath : claudePath,
985
+ paths: Object.freeze(existingPaths),
986
+ });
987
+ }
988
+ async function assertClaudeSymlinkTargetsCanonical(claudePath, canonicalPath, existingPaths, fileSystem) {
989
+ let target;
990
+ try {
991
+ target = await fileSystem.readlink(claudePath);
992
+ }
993
+ catch (error) {
994
+ throw new ProjectInstallationError("unsafe-target", `Cannot inspect the Claude Skill symlink at ${JSON.stringify(claudePath)}: ${errorMessage(error)}. No path was changed.`);
995
+ }
996
+ const resolvedTarget = path.resolve(path.dirname(claudePath), target);
997
+ if (resolvedTarget !== canonicalPath || !existingPaths.includes(canonicalPath)) {
998
+ throw new ProjectInstallationError("unsafe-target", `Cannot adopt the Claude Skill symlink at ${JSON.stringify(claudePath)} because it does not point to the existing canonical Skill; no path was changed.`);
999
+ }
1000
+ }
1001
+ async function readAndValidateTree(request, skillName, sourceAccess) {
1002
+ const tree = request.previewTree ?? await sourceAccess.readSkillTree(request.source, request.selection.path);
1003
+ const prefix = `${request.selection.path}/`;
1004
+ const seen = new Set();
1005
+ const validated = [];
1006
+ for (const file of tree) {
1007
+ if (!file.path.startsWith(prefix)) {
1008
+ throw new ProjectInstallationError("unsafe-skill-tree", `Skill ${JSON.stringify(skillName)} returned a file outside its selected directory: ${JSON.stringify(file.path)}`);
1009
+ }
1010
+ const relativePath = file.path.slice(prefix.length);
1011
+ if (!isSafeRelativePath(relativePath) || seen.has(relativePath)) {
1012
+ throw new ProjectInstallationError("unsafe-skill-tree", `Skill ${JSON.stringify(skillName)} returned an unsafe or duplicate file path: ${JSON.stringify(file.path)}`);
1013
+ }
1014
+ seen.add(relativePath);
1015
+ validated.push({
1016
+ relativePath,
1017
+ source: Object.freeze({
1018
+ path: file.path,
1019
+ content: Uint8Array.from(file.content),
1020
+ executable: file.executable,
1021
+ }),
1022
+ });
1023
+ }
1024
+ if (!seen.has("SKILL.md")) {
1025
+ throw new ProjectInstallationError("unsafe-skill-tree", `Skill ${JSON.stringify(skillName)} does not contain its required SKILL.md file`);
1026
+ }
1027
+ if (request.portableV1 === true) {
1028
+ const excluded = validated.find((file) => portableV1ExcludedDirectory(file.relativePath));
1029
+ if (excluded) {
1030
+ throw new ProjectInstallationError("incompatible-skill-tree", `Skill ${JSON.stringify(skillName)} contains ${JSON.stringify(excluded.relativePath)}, which is excluded by the portable V1 format rules`);
1031
+ }
1032
+ }
1033
+ return Object.freeze(validated);
1034
+ }
1035
+ function skillNameFromSelection(selection) {
1036
+ const skillName = path.posix.basename(selection.path);
1037
+ if (!isSafeSegment(skillName)) {
1038
+ throw new ProjectInstallationError("unsafe-skill-name", `Selected Skill path ${JSON.stringify(selection.path)} does not identify a safe Skill directory name`);
1039
+ }
1040
+ return skillName;
1041
+ }
1042
+ function validateCompatibility(request, skillName, files) {
1043
+ const formatSupported = request.portableV1 === true
1044
+ ? files === undefined
1045
+ ? new Set(request.selection.hosts)
1046
+ : isPortableV1Tree(files) ? new Set(V1_HOSTS) : new Set()
1047
+ : new Set();
1048
+ const supported = request.compatibleHosts === undefined
1049
+ ? formatSupported
1050
+ : new Set(V1_HOSTS.filter((host) => formatSupported.has(host) && request.compatibleHosts?.includes(host)));
1051
+ const incompatible = request.selection.hosts.filter((host) => {
1052
+ const declared = request.compatibility?.[host];
1053
+ return !supported.has(host) || declared === false || typeof declared === "string";
1054
+ });
1055
+ if (incompatible.length > 0) {
1056
+ const host = incompatible[0];
1057
+ const declared = request.compatibility?.[host];
1058
+ const reason = typeof declared === "string"
1059
+ ? declared
1060
+ : request.compatibleHosts === undefined && request.portableV1 !== true
1061
+ ? "no compatibility evidence or explicit portable V1 baseline was supplied"
1062
+ : request.compatibleHosts === undefined
1063
+ ? "the Skill tree does not satisfy the portable V1 format rules"
1064
+ : `this Skill does not support the selected Host ${JSON.stringify(host)}`;
1065
+ throw new ProjectInstallationError("incompatible-host", `Skill ${JSON.stringify(skillName)} is incompatible with Host ${JSON.stringify(host)}: ${reason}. ` +
1066
+ "Choose a compatible Host or remove the Host from the project selection; nothing was installed.");
1067
+ }
1068
+ return Object.freeze([...request.selection.hosts]);
1069
+ }
1070
+ async function assertNoSymlinkComponents(candidate, fileSystem) {
1071
+ const parsed = path.parse(candidate);
1072
+ const relative = path.relative(parsed.root, candidate);
1073
+ const segments = relative === "" ? [] : relative.split(path.sep);
1074
+ let current = parsed.root;
1075
+ for (const segment of segments) {
1076
+ current = path.join(current, segment);
1077
+ try {
1078
+ const information = await fileSystem.lstat(current);
1079
+ if (information.isSymbolicLink()) {
1080
+ throw new ProjectInstallationError("unsafe-project-root", `The project root contains a symbolic-link ancestor: ${current}`);
1081
+ }
1082
+ }
1083
+ catch (error) {
1084
+ if (isMissing(error)) {
1085
+ return;
1086
+ }
1087
+ throw error;
1088
+ }
1089
+ }
1090
+ }
1091
+ async function assertDirectory(candidate, fileSystem, label) {
1092
+ try {
1093
+ const information = await fileSystem.lstat(candidate);
1094
+ if (information.isSymbolicLink() || !information.isDirectory()) {
1095
+ throw new ProjectInstallationError("unsafe-project-root", `The ${label} is not a real directory: ${candidate}`);
1096
+ }
1097
+ }
1098
+ catch (error) {
1099
+ if (isMissing(error)) {
1100
+ throw new ProjectInstallationError("missing-project-root", `The ${label} does not exist: ${candidate}`);
1101
+ }
1102
+ throw error;
1103
+ }
1104
+ }
1105
+ function safeChildPath(parent, segment) {
1106
+ if (!isSafeSegment(segment)) {
1107
+ throw new ProjectInstallationError("unsafe-target", `Unsafe project installation path segment: ${segment}`);
1108
+ }
1109
+ return path.join(parent, segment);
1110
+ }
1111
+ async function ensureDirectoryTree(root, segments, createdPaths, uncertainCreates, fileSystem) {
1112
+ let current = root;
1113
+ for (const segment of segments) {
1114
+ current = safeChildPath(current, segment);
1115
+ try {
1116
+ const information = await fileSystem.lstat(current);
1117
+ assertRealDirectory(current, information);
1118
+ }
1119
+ catch (error) {
1120
+ if (!isMissing(error)) {
1121
+ throw error;
1122
+ }
1123
+ uncertainCreates.add(current);
1124
+ await fileSystem.mkdir(current);
1125
+ const information = await fileSystem.lstat(current);
1126
+ assertRealDirectory(current, information);
1127
+ createdPaths.push({ path: current, kind: "directory", identity: pathIdentity(information) });
1128
+ uncertainCreates.delete(current);
1129
+ }
1130
+ }
1131
+ return current;
1132
+ }
1133
+ async function createDirectory(candidate, createdPaths, uncertainCreates, fileSystem) {
1134
+ uncertainCreates.add(candidate);
1135
+ await fileSystem.mkdir(candidate);
1136
+ const information = await fileSystem.lstat(candidate);
1137
+ assertRealDirectory(candidate, information);
1138
+ createdPaths.push({ path: candidate, kind: "directory", identity: pathIdentity(information) });
1139
+ uncertainCreates.delete(candidate);
1140
+ }
1141
+ function assertRealDirectory(candidate, information) {
1142
+ if (information.isSymbolicLink() || !information.isDirectory()) {
1143
+ throw new ProjectInstallationError("target-conflict", `Installation path is not a real directory: ${candidate}`);
1144
+ }
1145
+ }
1146
+ async function assertMissing(candidate, fileSystem, displayPath) {
1147
+ try {
1148
+ await fileSystem.lstat(candidate);
1149
+ }
1150
+ catch (error) {
1151
+ if (isMissing(error)) {
1152
+ return;
1153
+ }
1154
+ throw error;
1155
+ }
1156
+ throw new ProjectInstallationError("target-conflict", `Cannot install because the target already exists at ${JSON.stringify(displayPath)}; explicit confirmation would be required to overwrite it, but this safe install flow has no overwrite operation. No path was changed.`);
1157
+ }
1158
+ /** Verifies the target's ancestors and names the sibling stage and backup paths of an in-place replacement. */
1159
+ async function prepareReplacementPaths(projectRoot, targetPath, operation, fileSystem) {
1160
+ const parent = path.dirname(targetPath);
1161
+ await assertNoSymlinkAncestors(projectRoot, relativeSegments(projectRoot, parent), fileSystem);
1162
+ return {
1163
+ stagePath: path.join(parent, `.agent-depot-${operation}-${randomUUID()}`),
1164
+ backupPath: path.join(parent, `.agent-depot-backup-${randomUUID()}`),
1165
+ };
1166
+ }
1167
+ async function assertNoSymlinkAncestors(root, segments, fileSystem) {
1168
+ let current = root;
1169
+ for (const segment of segments) {
1170
+ current = safeChildPath(current, segment);
1171
+ try {
1172
+ const information = await fileSystem.lstat(current);
1173
+ if (information.isSymbolicLink()) {
1174
+ throw new ProjectInstallationError("unsafe-target", `Installation path contains a symbolic-link ancestor: ${current}`);
1175
+ }
1176
+ }
1177
+ catch (error) {
1178
+ if (isMissing(error)) {
1179
+ return;
1180
+ }
1181
+ throw error;
1182
+ }
1183
+ }
1184
+ }
1185
+ /** Rolls back what a failed install created, then rethrows the original error (or a rollback-failed error naming both). */
1186
+ async function rollbackAndRethrow(error, createdPaths, uncertainCreates, fileSystem) {
1187
+ const cleanupErrors = await rollbackCreatedPaths(createdPaths, uncertainCreates, fileSystem);
1188
+ if (cleanupErrors.length > 0) {
1189
+ const original = error instanceof Error ? error.message : String(error);
1190
+ throw new ProjectInstallationError("rollback-failed", `${original} Rollback was incomplete: ${cleanupErrors.join("; ")}`);
1191
+ }
1192
+ throw error;
1193
+ }
1194
+ async function rollbackCreatedPaths(createdPaths, uncertainCreates, fileSystem) {
1195
+ const errors = [];
1196
+ for (const created of [...createdPaths].reverse()) {
1197
+ try {
1198
+ const information = await fileSystem.lstat(created.path);
1199
+ if (!samePathIdentity(created.identity, pathIdentity(information))) {
1200
+ errors.push(`preserved concurrently replaced path ${JSON.stringify(created.path)}`);
1201
+ continue;
1202
+ }
1203
+ if (created.kind === "directory") {
1204
+ if (information.isSymbolicLink() || !information.isDirectory()) {
1205
+ errors.push(`preserved changed path ${JSON.stringify(created.path)}`);
1206
+ continue;
1207
+ }
1208
+ await fileSystem.rmdir(created.path);
1209
+ }
1210
+ else {
1211
+ await fileSystem.rm(created.path);
1212
+ }
1213
+ }
1214
+ catch (error) {
1215
+ if (!isMissing(error)) {
1216
+ errors.push(`could not remove ${JSON.stringify(created.path)}: ${errorMessage(error)}`);
1217
+ }
1218
+ }
1219
+ }
1220
+ for (const candidate of uncertainCreates) {
1221
+ try {
1222
+ await fileSystem.lstat(candidate);
1223
+ errors.push(`could not verify ownership of ${JSON.stringify(candidate)}; it was preserved`);
1224
+ }
1225
+ catch (error) {
1226
+ if (!isMissing(error)) {
1227
+ errors.push(`could not inspect uncertain path ${JSON.stringify(candidate)}: ${errorMessage(error)}`);
1228
+ }
1229
+ }
1230
+ }
1231
+ return errors;
1232
+ }
1233
+ function pathIdentity(information) {
1234
+ return {
1235
+ device: information.dev,
1236
+ inode: information.ino,
1237
+ birthtimeMs: information.birthtimeMs,
1238
+ ctimeMs: information.ctimeMs,
1239
+ size: information.size,
1240
+ mode: information.mode,
1241
+ directory: information.isDirectory(),
1242
+ };
1243
+ }
1244
+ function hasNativeIdentity(identity) {
1245
+ return Number.isFinite(identity.device) && Number.isFinite(identity.inode) &&
1246
+ (identity.device !== 0 || identity.inode !== 0);
1247
+ }
1248
+ /**
1249
+ * Filesystems such as ext4 reuse a freed inode immediately, so a replacement
1250
+ * file can share dev/ino with the one we created. For non-directories the
1251
+ * native identity is therefore paired with `size` and a non-zero `birthtimeMs`:
1252
+ * both stay fixed between creation and rollback (chmod changes neither, and
1253
+ * identity is recorded before it), while a replacement almost always changes
1254
+ * at least one. `mode` and `ctime` are deliberately excluded because chmod and
1255
+ * rename legitimately change them.
1256
+ */
1257
+ function nativeFileSuffix(identity) {
1258
+ if (identity.directory)
1259
+ return "";
1260
+ const birth = Number.isFinite(identity.birthtimeMs) && identity.birthtimeMs > 0 ? identity.birthtimeMs : 0;
1261
+ return `:${identity.size}:${birth}`;
1262
+ }
1263
+ function pathIdentityKey(identity) {
1264
+ // Rename may update ctime while preserving the native directory identity.
1265
+ // Use dev/inode when the platform provides them; only the fallback needs the
1266
+ // additional metadata because it has no native identity to compare.
1267
+ if (hasNativeIdentity(identity)) {
1268
+ return `native:${identity.device}:${identity.inode}${nativeFileSuffix(identity)}`;
1269
+ }
1270
+ return `fallback:${identity.birthtimeMs}:${identity.size}:${identity.mode}`;
1271
+ }
1272
+ function samePathIdentity(left, right) {
1273
+ // Prefer native device/inode identity. The birth/ctime fallback supports
1274
+ // hosts where the native values are unavailable while still failing closed
1275
+ // when no stable identity can be established.
1276
+ if (hasNativeIdentity(left)) {
1277
+ return pathIdentityKey(left) === pathIdentityKey(right);
1278
+ }
1279
+ return Number.isFinite(left.birthtimeMs) && left.birthtimeMs > 0 &&
1280
+ left.birthtimeMs === right.birthtimeMs && left.ctimeMs === right.ctimeMs &&
1281
+ left.size === right.size && left.mode === right.mode;
1282
+ }
1283
+ async function writeStagedTree(stagePath, files, fileSystem) {
1284
+ for (const file of files) {
1285
+ const segments = file.relativePath.split("/");
1286
+ const destination = path.join(stagePath, ...segments);
1287
+ await ensureDirectoryTree(stagePath, segments.slice(0, -1), [], new Set(), fileSystem);
1288
+ await writeRegularFile(destination, file, "Updated", fileSystem);
1289
+ await fileSystem.chmod(destination, file.source.executable ? 0o755 : 0o644);
1290
+ }
1291
+ }
1292
+ const MANAGED_SKILL_ROOTS = new Set([".agents", ".claude"]);
1293
+ function resolveInstallationPath(projectRoot, installationPath) {
1294
+ const candidate = path.resolve(projectRoot, ...installationPath.split("/"));
1295
+ const relative = path.relative(projectRoot, candidate);
1296
+ if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
1297
+ throw new ProjectInstallationError("unsafe-target", `Tracked Skill installation escapes the project root: ${installationPath}`);
1298
+ }
1299
+ return candidate;
1300
+ }
1301
+ function isCanonicalInstallation(relative) {
1302
+ const segments = relative.split(path.sep);
1303
+ return segments.length === 3 && MANAGED_SKILL_ROOTS.has(segments[0]) && segments[1] === "skills" &&
1304
+ isSafeSegment(segments[2]);
1305
+ }
1306
+ /**
1307
+ * A project manifest is repository-controlled input, so only the canonical
1308
+ * layout (<root>/.agents|.claude/skills/<name>) is trusted as a replace/delete
1309
+ * target. An adopted Skill elsewhere is accepted only after strict validation
1310
+ * and is reported so the caller can require explicit confirmation of that path.
1311
+ * User-global records come from Agent Depot's own state file and stay trusted.
1312
+ * Returns the absolute non-canonical path, or undefined for a trusted path.
1313
+ */
1314
+ async function classifyTrackedInstallation(projectRoot, installationPath, skillName, options, fileSystem) {
1315
+ const targetPath = resolveInstallationPath(projectRoot, installationPath);
1316
+ const relative = path.relative(projectRoot, targetPath);
1317
+ if (options.installationTrust === "user-global-state" || isCanonicalInstallation(relative)) {
1318
+ return undefined;
1319
+ }
1320
+ const reject = (reason) => {
1321
+ throw new ProjectInstallationError("unsafe-target", `Tracked Skill installation ${JSON.stringify(installationPath)} is outside .agents/skills and .claude/skills and ${reason}; no path was changed`);
1322
+ };
1323
+ const segments = relative.split(path.sep);
1324
+ if (!isSafeRelativePath(installationPath))
1325
+ reject("is not a safe relative path");
1326
+ if (segments.some((segment) => segment.toLowerCase() === ".git"))
1327
+ reject("is inside .git");
1328
+ if (segments[segments.length - 1] !== skillName)
1329
+ reject(`does not end in the Skill name ${JSON.stringify(skillName)}`);
1330
+ for (const other of options.otherInstallationPaths ?? []) {
1331
+ const inside = path.relative(targetPath, path.resolve(projectRoot, ...other.split("/")));
1332
+ if (inside !== "" && inside !== ".." && !inside.startsWith(`..${path.sep}`) && !path.isAbsolute(inside)) {
1333
+ reject(`contains another tracked installation (${JSON.stringify(other)})`);
1334
+ }
1335
+ }
1336
+ let current = projectRoot;
1337
+ for (const segment of segments) {
1338
+ current = path.join(current, segment);
1339
+ const information = await fileSystem.lstat(current).catch((error) => {
1340
+ if (isMissing(error)) {
1341
+ throw new ProjectInstallationError("missing-installation", `The tracked Skill installation does not exist: ${targetPath}; no path was changed`);
1342
+ }
1343
+ throw error;
1344
+ });
1345
+ if (information.isSymbolicLink() || !information.isDirectory())
1346
+ reject(`has a component that is not a real directory: ${current}`);
1347
+ }
1348
+ if (!fileSystem.readFile) {
1349
+ throw new ProjectInstallationError("update-unavailable", "Validating a non-canonical Skill installation requires filesystem read support");
1350
+ }
1351
+ const skillFile = path.join(targetPath, "SKILL.md");
1352
+ const skillInformation = await fileSystem.lstat(skillFile).catch((error) => {
1353
+ if (isMissing(error))
1354
+ return undefined;
1355
+ throw error;
1356
+ });
1357
+ const declared = skillInformation?.isFile() === true
1358
+ ? parseSkillFrontmatter(Buffer.from(await fileSystem.readFile(skillFile)).toString("utf8"))?.name
1359
+ : undefined;
1360
+ if (declared !== skillName)
1361
+ reject(`does not contain a SKILL.md whose name is ${JSON.stringify(skillName)}`);
1362
+ return targetPath;
1363
+ }
1364
+ function assertNonCanonicalConfirmed(nonCanonicalPath, options) {
1365
+ if (nonCanonicalPath !== undefined && options.confirmedNonCanonicalPath !== nonCanonicalPath) {
1366
+ throw new ProjectInstallationError("non-canonical-confirmation-required", `The tracked Skill is installed at the non-canonical location ${JSON.stringify(nonCanonicalPath)}; explicit confirmation of that exact path is required and no path was changed`);
1367
+ }
1368
+ }
1369
+ function relativeSegments(root, candidate) {
1370
+ const relative = path.relative(root, candidate);
1371
+ return relative === "" ? [] : relative.split(path.sep);
1372
+ }
1373
+ function errorMessage(error) {
1374
+ return error instanceof Error ? error.message : String(error);
1375
+ }
1376
+ function isSafeRelativePath(candidate) {
1377
+ if (candidate.length === 0 || candidate.includes("\\") || path.posix.isAbsolute(candidate)) {
1378
+ return false;
1379
+ }
1380
+ const normalized = path.posix.normalize(candidate);
1381
+ if (normalized !== candidate || normalized === "." || normalized === ".." || normalized.startsWith("../")) {
1382
+ return false;
1383
+ }
1384
+ return normalized.split("/").every(isSafeSegment);
1385
+ }
1386
+ function isPortableV1Tree(files) {
1387
+ return files.every((file) => !portableV1ExcludedDirectory(file.relativePath));
1388
+ }
1389
+ function portableV1ExcludedDirectory(relativePath) {
1390
+ const excludedTopLevelDirectories = new Set([".claude", ".codex", ".opencode", ".pi", "extensions", "plugins", "mcp"]);
1391
+ const firstSegment = relativePath.split("/", 1)[0];
1392
+ return excludedTopLevelDirectories.has(firstSegment ?? "");
1393
+ }
1394
+ function isSafeSegment(candidate) {
1395
+ if (candidate.length === 0 || candidate === "." || candidate === ".." || candidate.includes("/") || candidate.includes("\\")) {
1396
+ return false;
1397
+ }
1398
+ // Reject the Windows-invalid subset on every host so a portable Skill cannot
1399
+ // succeed on one host and become un-installable on another.
1400
+ if (Array.from(candidate).some((character) => {
1401
+ const code = character.codePointAt(0) ?? 0;
1402
+ return code <= 0x1f || code === 0x7f;
1403
+ }) || /[<>:"|?*]/u.test(candidate) || /[. ]$/u.test(candidate)) {
1404
+ return false;
1405
+ }
1406
+ const deviceName = candidate.split(".", 1)[0].toLowerCase();
1407
+ return !/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/u.test(deviceName);
1408
+ }
1409
+ function isMissing(error) {
1410
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
1411
+ }