@iseoane/agent-depot 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +8 -3
  3. package/dist/src/cli.js +1164 -0
  4. package/dist/src/git-source.js +409 -0
  5. package/dist/src/path-safety.js +31 -0
  6. package/dist/src/process-runner.js +32 -0
  7. package/dist/src/project-installation.js +1411 -0
  8. package/dist/src/project-manifest.js +511 -0
  9. package/dist/src/skill-adoption.js +77 -0
  10. package/dist/src/skill-discovery.js +467 -0
  11. package/dist/src/skill-hosts.js +103 -0
  12. package/dist/src/skill-install.js +362 -0
  13. package/dist/src/skill-removal.js +119 -0
  14. package/dist/src/skill-update.js +244 -0
  15. package/dist/src/source-state.js +265 -0
  16. package/dist/src/sources.js +391 -0
  17. package/dist/src/tui/adoption-actions.js +144 -0
  18. package/dist/src/tui/app.js +63 -0
  19. package/dist/src/tui/batch.js +17 -0
  20. package/dist/src/tui/bulk-actions.js +243 -0
  21. package/dist/src/tui/catalog-actions.js +9 -0
  22. package/dist/src/tui/catalog-browse.js +59 -0
  23. package/dist/src/tui/catalog-filter.js +20 -0
  24. package/dist/src/tui/catalog-flow.js +107 -0
  25. package/dist/src/tui/catalog-installs.js +189 -0
  26. package/dist/src/tui/catalog-panel.js +47 -0
  27. package/dist/src/tui/catalog-state.js +92 -0
  28. package/dist/src/tui/catalog-tree.js +35 -0
  29. package/dist/src/tui/catalog-view.js +89 -0
  30. package/dist/src/tui/environment.js +1 -0
  31. package/dist/src/tui/installations-browse.js +114 -0
  32. package/dist/src/tui/installations-mode.js +1 -0
  33. package/dist/src/tui/installations-panel.js +27 -0
  34. package/dist/src/tui/installations-rows.js +11 -0
  35. package/dist/src/tui/installations-tree.js +68 -0
  36. package/dist/src/tui/installations-view.js +114 -0
  37. package/dist/src/tui/installations.js +149 -0
  38. package/dist/src/tui/keys.js +31 -0
  39. package/dist/src/tui/manage-actions.js +172 -0
  40. package/dist/src/tui/manage-panel.js +59 -0
  41. package/dist/src/tui/mode-keys.js +61 -0
  42. package/dist/src/tui/panel-parts.js +23 -0
  43. package/dist/src/tui/render.js +8 -0
  44. package/dist/src/tui/sources-mode.js +17 -0
  45. package/dist/src/tui/sources-view.js +276 -0
  46. package/dist/src/tui/theme.js +29 -0
  47. package/dist/src/tui/tree-navigation.js +68 -0
  48. package/dist/src/tui/tree.js +53 -0
  49. package/dist/src/tui/unmanaged-actions.js +178 -0
  50. package/dist/src/tui/updates-flow.js +74 -0
  51. package/dist/src/tui/updates-keys.js +95 -0
  52. package/dist/src/tui/updates-mode.js +1 -0
  53. package/dist/src/tui/updates-model.js +48 -0
  54. package/dist/src/tui/updates-panel.js +42 -0
  55. package/dist/src/tui/updates-view.js +101 -0
  56. package/dist/src/tui/updates.js +159 -0
  57. package/dist/src/tui/view-state.js +42 -0
  58. package/dist/src/tui/window.js +31 -0
  59. package/dist/src/unmanaged-removal.js +57 -0
  60. package/dist/src/update-batch.js +376 -0
  61. package/dist/src/update-flow.js +135 -0
  62. package/dist/src/usage-error.js +7 -0
  63. package/dist/src/user-global-skill-inventory.js +559 -0
  64. package/dist/src/user-method.js +14 -0
  65. package/package.json +4 -1
  66. package/skills/doctor-md-agents/scripts/test_collect_sessions.py +0 -780
  67. package/skills/doctor-md-agents/scripts/test_extract_rules.py +0 -530
  68. package/skills/doctor-md-agents/scripts/test_render_report.py +0 -383
  69. package/skills/doctor-md-agents/scripts/test_scan_corrections.py +0 -343
  70. package/skills/doctor-md-agents/scripts/test_verify_citations.py +0 -236
  71. package/skills/doctor-md-agents/scripts/testdata/old_extract_rules_pre_t1.py.txt +0 -507
  72. package/skills/doctor-md-skill/scripts/test_collect_sessions.py +0 -593
  73. package/skills/doctor-md-skill/scripts/test_detect_machine_sessions.py +0 -181
  74. package/skills/doctor-md-skill/scripts/test_render_report.py +0 -241
  75. package/skills/doctor-md-skill/scripts/test_verify_citations.py +0 -196
@@ -0,0 +1,559 @@
1
+ import { lstat, readdir, readFile, readlink, rename, rm, unlink } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import { pathsOverlap } from "./path-safety.js";
6
+ import { readExistingSkillTree } from "./skill-adoption.js";
7
+ import { parseSkillFrontmatter, skillTreeBaseline } from "./skill-discovery.js";
8
+ const GLOBAL_SKILL_ROOTS = [
9
+ { kind: "agents", relativePath: [".agents", "skills"] },
10
+ { kind: "claude", relativePath: [".claude", "skills"] },
11
+ ];
12
+ const nodeFileSystem = {
13
+ lstat,
14
+ readdir: async (candidatePath) => readdir(candidatePath),
15
+ readFile,
16
+ rename,
17
+ rm,
18
+ };
19
+ /**
20
+ * Scans only the immediate real directories below the two supported global Skill
21
+ * roots. This operation never reads a Skill tree or changes the filesystem.
22
+ *
23
+ * A persisted installation location is the ownership identity. Its normalized
24
+ * absolute path is compared directly, which preserves noncanonical adopted
25
+ * locations instead of guessing ownership from a Skill name alone.
26
+ */
27
+ export async function scanUserGlobalSkillInventory(options = {}) {
28
+ const homeDirectory = path.resolve(options.homeDirectory ?? homedir());
29
+ const selections = options.managedInstallations ?? options.managedSelections ?? [];
30
+ const managedPaths = new Set();
31
+ const managedCanonicalPaths = new Set();
32
+ for (const selection of selections) {
33
+ const installationPath = selection.installation?.path;
34
+ if (installationPath === undefined)
35
+ continue;
36
+ const absolutePath = resolveInstallationPath(homeDirectory, installationPath);
37
+ if (absolutePath === undefined)
38
+ continue;
39
+ managedPaths.add(pathKey(absolutePath));
40
+ // Only a persisted canonical location is authoritative for deduplicating
41
+ // an exposure symlink. A Claude-only adopted directory is deliberately not
42
+ // treated as ownership of a same-name canonical directory: that separate
43
+ // real directory must remain visible as a possible duplicate.
44
+ if (isUnderGlobalRoot(absolutePath, homeDirectory, ".agents")) {
45
+ managedCanonicalPaths.add(pathKey(absolutePath));
46
+ }
47
+ }
48
+ const entries = [];
49
+ const skipped = [];
50
+ const exposures = [];
51
+ const symlinks = [];
52
+ const state = { managedPaths, managedCanonicalPaths, entries, skipped, exposures, symlinks };
53
+ for (const root of GLOBAL_SKILL_ROOTS) {
54
+ const rootPath = path.join(homeDirectory, ...root.relativePath);
55
+ await scanRoot(root, rootPath, homeDirectory, state);
56
+ }
57
+ const managed = entries.filter((entry) => entry.status === "managed");
58
+ const unmanaged = entries.filter((entry) => entry.status === "unmanaged");
59
+ return Object.freeze({
60
+ homeDirectory,
61
+ entries: Object.freeze(entries),
62
+ managed: Object.freeze(managed),
63
+ unmanaged: Object.freeze(unmanaged),
64
+ skipped: Object.freeze(skipped),
65
+ exposures: Object.freeze(exposures),
66
+ symlinks: Object.freeze(symlinks),
67
+ });
68
+ }
69
+ /** Short alias for callers that only need the inventory operation. */
70
+ export const scanUserGlobalSkills = scanUserGlobalSkillInventory;
71
+ async function scanRoot(root, rootPath, homeDirectory, state) {
72
+ const names = await listScannableRoot(root, rootPath, homeDirectory, state.skipped);
73
+ for (const name of names ?? []) {
74
+ await scanRootEntry(root, name, path.join(rootPath, name), homeDirectory, state);
75
+ }
76
+ }
77
+ /** Returns the sorted entry names of a safe root, or undefined when it is missing or reported as skipped. */
78
+ async function listScannableRoot(root, rootPath, homeDirectory, skipped) {
79
+ const ancestorSafety = await inspectGlobalRootAncestors(homeDirectory, rootPath);
80
+ if (ancestorSafety === "missing")
81
+ return undefined;
82
+ if (ancestorSafety !== undefined) {
83
+ skipped.push(skip(rootPath, root.kind, "unsafe-root", ancestorSafety));
84
+ return undefined;
85
+ }
86
+ let rootInformation;
87
+ try {
88
+ rootInformation = await lstat(rootPath);
89
+ }
90
+ catch (error) {
91
+ if (!isMissing(error))
92
+ skipped.push(skip(rootPath, root.kind, "unsafe-root", errorMessage(error)));
93
+ return undefined;
94
+ }
95
+ if (rootInformation.isSymbolicLink() || !rootInformation.isDirectory()) {
96
+ skipped.push(skip(rootPath, root.kind, "unsafe-root", "global Skill root is not a real directory"));
97
+ return undefined;
98
+ }
99
+ try {
100
+ return (await readdir(rootPath)).sort();
101
+ }
102
+ catch (error) {
103
+ skipped.push(skip(rootPath, root.kind, "unsafe-root", errorMessage(error)));
104
+ return undefined;
105
+ }
106
+ }
107
+ async function scanRootEntry(root, name, candidatePath, homeDirectory, state) {
108
+ const { managedPaths, entries, skipped } = state;
109
+ if (isTransientName(name)) {
110
+ skipped.push({ name, path: candidatePath, root: root.kind, reason: "excluded-transient" });
111
+ return;
112
+ }
113
+ let information;
114
+ try {
115
+ // lstat is deliberate: neither an entry nor any ancestor below the root
116
+ // is followed while deciding whether it is eligible for removal.
117
+ information = await lstat(candidatePath);
118
+ }
119
+ catch (error) {
120
+ if (!isMissing(error))
121
+ skipped.push(skip(candidatePath, root.kind, "unsafe-entry", errorMessage(error), name));
122
+ return;
123
+ }
124
+ if (information.isSymbolicLink()) {
125
+ await classifySymlink(candidatePath, name, root.kind, homeDirectory, state);
126
+ return;
127
+ }
128
+ if (!information.isDirectory())
129
+ return;
130
+ const problem = await findSkillDirectoryProblem(candidatePath);
131
+ if (problem === "not-a-skill")
132
+ return;
133
+ if (problem !== undefined) {
134
+ skipped.push(skip(candidatePath, root.kind, problem.reason, problem.detail, name));
135
+ return;
136
+ }
137
+ entries.push(Object.freeze({
138
+ name,
139
+ path: candidatePath,
140
+ root: root.kind,
141
+ host: root.kind === "claude" ? "claude" : "shared",
142
+ status: managedPaths.has(pathKey(candidatePath)) ? "managed" : "unmanaged",
143
+ }));
144
+ }
145
+ /** Why a real directory is not an eligible Skill: silently "not-a-skill" (no SKILL.md) or a reported problem. */
146
+ async function findSkillDirectoryProblem(candidatePath) {
147
+ const skillFile = path.join(candidatePath, "SKILL.md");
148
+ let skillFileInformation;
149
+ try {
150
+ skillFileInformation = await lstat(skillFile);
151
+ }
152
+ catch (error) {
153
+ return isMissing(error) ? "not-a-skill" : { reason: "unsafe-skill-file", detail: errorMessage(error) };
154
+ }
155
+ if (skillFileInformation.isSymbolicLink() || !skillFileInformation.isFile()) {
156
+ return { reason: "unsafe-skill-file", detail: "SKILL.md is not a real file" };
157
+ }
158
+ let skillContent;
159
+ try {
160
+ skillContent = await readFile(skillFile);
161
+ }
162
+ catch (error) {
163
+ return { reason: "unsafe-skill-file", detail: `SKILL.md cannot be read: ${errorMessage(error)}` };
164
+ }
165
+ if (parseSkillFrontmatter(Buffer.from(skillContent).toString("utf8")) === undefined) {
166
+ return { reason: "unsafe-skill-file", detail: "SKILL.md does not contain valid top-level name and description frontmatter" };
167
+ }
168
+ const unsafeTreeEntry = await findUnsafeTreeEntry(candidatePath);
169
+ return unsafeTreeEntry === undefined ? undefined : { reason: "unsafe-entry", detail: unsafeTreeEntry };
170
+ }
171
+ async function classifySymlink(candidatePath, name, root, homeDirectory, state) {
172
+ const { managedCanonicalPaths, managedPaths, entries, skipped, exposures, symlinks } = state;
173
+ let linkTarget;
174
+ try {
175
+ linkTarget = await readlink(candidatePath);
176
+ }
177
+ catch (error) {
178
+ skipped.push(skip(candidatePath, root, "unsafe-entry", errorMessage(error), name));
179
+ return;
180
+ }
181
+ const resolvedTarget = path.resolve(path.dirname(candidatePath), linkTarget);
182
+ // Only an exact same-name link to a direct child of the canonical root can be
183
+ // an exposure created by Agent Depot; anything else stays reported.
184
+ const isSameNameCanonical = root === "claude" &&
185
+ isUnderGlobalRoot(resolvedTarget, homeDirectory, ".agents") &&
186
+ path.basename(resolvedTarget) === name &&
187
+ pathKey(path.dirname(resolvedTarget)) === pathKey(path.join(homeDirectory, ".agents", "skills"));
188
+ const isManagedAlias = isSameNameCanonical &&
189
+ (managedCanonicalPaths.has(pathKey(resolvedTarget)) || managedPaths.has(pathKey(candidatePath)));
190
+ const isListedCanonical = isSameNameCanonical &&
191
+ entries.some((entry) => entry.root === "agents" && pathKey(entry.path) === pathKey(resolvedTarget));
192
+ if (isManagedAlias || isListedCanonical) {
193
+ const targetSafety = await inspectCanonicalTarget(resolvedTarget);
194
+ if (targetSafety === undefined) {
195
+ exposures.push({ name, path: candidatePath, root, target: resolvedTarget });
196
+ return;
197
+ }
198
+ skipped.push(skip(candidatePath, root, "unsafe-entry", targetSafety, name));
199
+ return;
200
+ }
201
+ skipped.push(skip(candidatePath, root, "unsafe-entry", "symbolic-link entries are never removal candidates", name));
202
+ if (!managedPaths.has(pathKey(candidatePath)))
203
+ symlinks.push({ name, path: candidatePath, root, target: resolvedTarget });
204
+ }
205
+ async function findUnsafeTreeEntry(candidatePath, relativeDirectory = "") {
206
+ let names;
207
+ try {
208
+ names = await readdir(candidatePath);
209
+ }
210
+ catch (error) {
211
+ return `Skill tree could not be inspected: ${errorMessage(error)}`;
212
+ }
213
+ for (const name of [...names].sort()) {
214
+ const relativePath = relativeDirectory === "" ? name : `${relativeDirectory}/${name}`;
215
+ const childPath = path.join(candidatePath, name);
216
+ let information;
217
+ try {
218
+ information = await lstat(childPath);
219
+ }
220
+ catch (error) {
221
+ return `Skill tree entry ${relativePath} could not be inspected: ${errorMessage(error)}`;
222
+ }
223
+ if (information.isSymbolicLink())
224
+ return `Skill tree contains a symbolic link: ${relativePath}`;
225
+ if (information.isDirectory()) {
226
+ const nested = await findUnsafeTreeEntry(childPath, relativePath);
227
+ if (nested !== undefined)
228
+ return nested;
229
+ continue;
230
+ }
231
+ if (!information.isFile())
232
+ return `Skill tree contains a non-regular file: ${relativePath}`;
233
+ }
234
+ return undefined;
235
+ }
236
+ async function inspectGlobalRootAncestors(homeDirectory, rootPath) {
237
+ const relative = path.relative(homeDirectory, rootPath);
238
+ if (relative === "" || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
239
+ return "global Skill root has an unsafe ancestor";
240
+ }
241
+ let current = homeDirectory;
242
+ for (const segment of ["", ...relative.split(path.sep)]) {
243
+ if (segment !== "")
244
+ current = path.join(current, segment);
245
+ let information;
246
+ try {
247
+ information = await lstat(current);
248
+ }
249
+ catch (error) {
250
+ if (isMissing(error))
251
+ return "missing";
252
+ return errorMessage(error);
253
+ }
254
+ if (information.isSymbolicLink() || !information.isDirectory()) {
255
+ return `global Skill root ancestor is not a real directory: ${current}`;
256
+ }
257
+ }
258
+ return undefined;
259
+ }
260
+ async function inspectCanonicalTarget(candidatePath) {
261
+ let targetInformation;
262
+ try {
263
+ targetInformation = await lstat(candidatePath);
264
+ }
265
+ catch (error) {
266
+ return `managed canonical target is unavailable: ${errorMessage(error)}`;
267
+ }
268
+ if (targetInformation.isSymbolicLink() || !targetInformation.isDirectory()) {
269
+ return "managed canonical target is not a real directory";
270
+ }
271
+ let skillInformation;
272
+ try {
273
+ skillInformation = await lstat(path.join(candidatePath, "SKILL.md"));
274
+ }
275
+ catch (error) {
276
+ return `managed canonical Skill file is unavailable: ${errorMessage(error)}`;
277
+ }
278
+ if (skillInformation.isSymbolicLink() || !skillInformation.isFile()) {
279
+ return "managed canonical target does not contain a real SKILL.md file";
280
+ }
281
+ return undefined;
282
+ }
283
+ /**
284
+ * Inspects one explicit unmanaged global path without resolving or searching a
285
+ * Source. The complete tree must be real-directory/regular-file content so a
286
+ * later recursive delete cannot follow a link or special file.
287
+ */
288
+ export async function inspectUserGlobalSkillRemoval(candidatePath, options) {
289
+ const homeDirectory = path.resolve(options.homeDirectory);
290
+ const targetPath = validateExplicitGlobalPath(candidatePath, homeDirectory);
291
+ const fileSystem = options.fileSystem ?? nodeFileSystem;
292
+ await assertRealGlobalAncestors(homeDirectory, path.dirname(targetPath), fileSystem);
293
+ const managedPaths = managedProtectionPaths(homeDirectory, options.managedInstallations);
294
+ const managedPath = managedPaths.find((protectedPath) => pathsOverlap(pathKey(targetPath), pathKey(protectedPath)));
295
+ if (managedPath !== undefined) {
296
+ throw new Error(`Cannot remove ${JSON.stringify(targetPath)} because it is managed or aliases a managed installation at ${JSON.stringify(managedPath)}; no path was changed`);
297
+ }
298
+ const information = await realDirectory(targetPath, "selected unmanaged Skill", fileSystem);
299
+ const skillFile = path.join(targetPath, "SKILL.md");
300
+ const skillInformation = await fileSystem.lstat(skillFile);
301
+ if (skillInformation.isSymbolicLink() || !skillInformation.isFile()) {
302
+ throw new Error(`Selected unmanaged Skill must contain a real SKILL.md file: ${targetPath}; no path was changed`);
303
+ }
304
+ const skillContent = await fileSystem.readFile(skillFile);
305
+ if (parseSkillFrontmatter(Buffer.from(skillContent).toString("utf8")) === undefined) {
306
+ throw new Error(`Selected unmanaged Skill must contain valid top-level name and description frontmatter: ${targetPath}; no path was changed`);
307
+ }
308
+ const tree = await readUnmanagedTree(targetPath, path.basename(targetPath), fileSystem);
309
+ return Object.freeze({
310
+ name: path.basename(targetPath),
311
+ path: targetPath,
312
+ identity: fileIdentity(information),
313
+ digest: skillTreeBaseline(tree).digest,
314
+ });
315
+ }
316
+ /** Refuses selected unmanaged paths that overlap physically or through Host aliases. */
317
+ export function assertNoOverlappingUserGlobalSkillRemovals(inspections) {
318
+ for (let leftIndex = 0; leftIndex < inspections.length; leftIndex += 1) {
319
+ for (let rightIndex = leftIndex + 1; rightIndex < inspections.length; rightIndex += 1) {
320
+ const left = inspections[leftIndex];
321
+ const right = inspections[rightIndex];
322
+ const leftAliases = globalPathAliases(left.path);
323
+ const rightAliases = globalPathAliases(right.path);
324
+ if (leftAliases.some((candidate) => rightAliases.some((other) => pathsOverlap(pathKey(candidate), pathKey(other))))) {
325
+ throw new Error(`Cannot remove selected unmanaged Skills because their paths or canonical/Claude aliases overlap: ${JSON.stringify(left.path)} and ${JSON.stringify(right.path)}; no path was changed`);
326
+ }
327
+ }
328
+ }
329
+ }
330
+ /**
331
+ * Rechecks an inspected unmanaged Skill, moves it to same-parent staging, and
332
+ * permanently deletes only after validating the moved tree. A drifted or
333
+ * unverifiable staged tree is retained rather than guessed at.
334
+ */
335
+ export async function removeUserGlobalSkill(inspection, options) {
336
+ const current = await inspectUserGlobalSkillRemoval(inspection.path, options);
337
+ if (current.name !== inspection.name || current.path !== inspection.path || current.identity !== inspection.identity || current.digest !== inspection.digest) {
338
+ throw new Error(`The inspected unmanaged Skill changed before deletion: ${JSON.stringify(inspection.path)}; no path was changed`);
339
+ }
340
+ let removalOptions = options;
341
+ if (options.readManagedInstallations !== undefined) {
342
+ const latestManagedInstallations = await options.readManagedInstallations();
343
+ if (options.expectedManagedInstallations !== undefined && !sameManagedInstallations(options.expectedManagedInstallations, latestManagedInstallations)) {
344
+ throw new Error(`Managed Skill records changed before deleting unmanaged Skill ${JSON.stringify(inspection.path)}; no path was changed`);
345
+ }
346
+ const latest = await inspectUserGlobalSkillRemoval(inspection.path, {
347
+ ...options,
348
+ managedInstallations: latestManagedInstallations,
349
+ });
350
+ if (latest.name !== inspection.name || latest.path !== inspection.path || latest.identity !== inspection.identity || latest.digest !== inspection.digest) {
351
+ throw new Error(`The inspected unmanaged Skill changed before deletion: ${JSON.stringify(inspection.path)}; no path was changed`);
352
+ }
353
+ removalOptions = { ...options, managedInstallations: latestManagedInstallations };
354
+ }
355
+ const fileSystem = removalOptions.fileSystem ?? nodeFileSystem;
356
+ const stagingPath = path.join(path.dirname(inspection.path), `.${inspection.name}.agent-depot-unmanaged-stage-${randomUUID()}`);
357
+ try {
358
+ await fileSystem.rename(inspection.path, stagingPath);
359
+ }
360
+ catch (error) {
361
+ throw new Error(`Could not stage unmanaged Skill ${JSON.stringify(inspection.path)} before deletion: ${errorMessage(error)}; no path was changed`);
362
+ }
363
+ try {
364
+ const staged = await inspectStagedSkill(stagingPath, inspection.name, fileSystem);
365
+ if (staged.identity !== inspection.identity || staged.digest !== inspection.digest) {
366
+ throw new Error("the staged tree's identity or content changed");
367
+ }
368
+ await fileSystem.rm(stagingPath, { recursive: true, force: false });
369
+ }
370
+ catch (error) {
371
+ const restored = await restoreStagedSkill(stagingPath, inspection.path, inspection, fileSystem);
372
+ if (restored) {
373
+ throw new Error(`Unmanaged Skill deletion stopped for ${JSON.stringify(inspection.path)}; the original was restored and no permanent deletion occurred: ${errorMessage(error)}`);
374
+ }
375
+ throw new Error(`Unmanaged Skill deletion status is uncertain for ${JSON.stringify(inspection.path)}; staged path retained at ${JSON.stringify(stagingPath)} and recovery is not guaranteed: ${errorMessage(error)}`);
376
+ }
377
+ }
378
+ /**
379
+ * Inspects one explicit symlink below a supported global root. Only the link is
380
+ * ever removed; the path it points to is never read, followed or changed.
381
+ */
382
+ export async function inspectUserGlobalSymlinkRemoval(candidatePath, options) {
383
+ const homeDirectory = path.resolve(options.homeDirectory);
384
+ const linkPath = validateExplicitGlobalPath(candidatePath, homeDirectory);
385
+ const fileSystem = options.fileSystem ?? nodeFileSystem;
386
+ await assertRealGlobalAncestors(homeDirectory, path.dirname(linkPath), fileSystem);
387
+ const managedPath = managedProtectionPaths(homeDirectory, options.managedInstallations)
388
+ .find((protectedPath) => pathsOverlap(pathKey(linkPath), protectedPath));
389
+ if (managedPath !== undefined) {
390
+ throw new Error(`Cannot remove ${JSON.stringify(linkPath)} because it is managed or aliases a managed installation at ${JSON.stringify(managedPath)}; no path was changed`);
391
+ }
392
+ const information = await fileSystem.lstat(linkPath);
393
+ if (!information.isSymbolicLink()) {
394
+ throw new Error(`Selected unmanaged link must be a symbolic link: ${linkPath}; no path was changed`);
395
+ }
396
+ return Object.freeze({
397
+ name: path.basename(linkPath),
398
+ path: linkPath,
399
+ target: await readlink(linkPath),
400
+ identity: fileIdentity(information),
401
+ });
402
+ }
403
+ /** Rechecks the inspected link (and the managed records when given), then unlinks only the link. */
404
+ export async function removeUserGlobalSymlink(inspection, options) {
405
+ const unchanged = (current) => current.name === inspection.name && current.path === inspection.path &&
406
+ current.target === inspection.target && current.identity === inspection.identity;
407
+ const changed = () => new Error(`The inspected unmanaged link changed before deletion: ${JSON.stringify(inspection.path)}; no path was changed`);
408
+ if (!unchanged(await inspectUserGlobalSymlinkRemoval(inspection.path, options)))
409
+ throw changed();
410
+ if (options.readManagedInstallations !== undefined) {
411
+ const latest = await options.readManagedInstallations();
412
+ if (options.expectedManagedInstallations !== undefined && !sameManagedInstallations(options.expectedManagedInstallations, latest)) {
413
+ throw new Error(`Managed Skill records changed before deleting unmanaged link ${JSON.stringify(inspection.path)}; no path was changed`);
414
+ }
415
+ if (!unchanged(await inspectUserGlobalSymlinkRemoval(inspection.path, { ...options, managedInstallations: latest })))
416
+ throw changed();
417
+ }
418
+ // A swap for a real directory is caught by the last recheck above or refused by unlink (EISDIR/EPERM).
419
+ // A remaining window: the link replaced by another symbolic link between that recheck and this unlink
420
+ // is still removed, because unlink cannot verify identity atomically. Only a link is ever unlinked.
421
+ await unlink(inspection.path);
422
+ }
423
+ async function inspectStagedSkill(candidatePath, name, fileSystem) {
424
+ const information = await realDirectory(candidatePath, "staged unmanaged Skill", fileSystem);
425
+ const tree = await readUnmanagedTree(candidatePath, name, fileSystem);
426
+ return {
427
+ name,
428
+ path: candidatePath,
429
+ identity: fileIdentity(information),
430
+ digest: skillTreeBaseline(tree).digest,
431
+ };
432
+ }
433
+ async function restoreStagedSkill(stagingPath, targetPath, expected, fileSystem) {
434
+ try {
435
+ const staged = await inspectStagedSkill(stagingPath, expected.name, fileSystem);
436
+ if (staged.identity !== expected.identity || staged.digest !== expected.digest)
437
+ return false;
438
+ await fileSystem.rename(stagingPath, targetPath);
439
+ return true;
440
+ }
441
+ catch {
442
+ return false;
443
+ }
444
+ }
445
+ async function readUnmanagedTree(candidatePath, skillName, fileSystem) {
446
+ return readExistingSkillTree(candidatePath, skillName, fileSystem);
447
+ }
448
+ async function realDirectory(candidatePath, label, fileSystem = nodeFileSystem) {
449
+ const information = await fileSystem.lstat(candidatePath);
450
+ if (information.isSymbolicLink() || !information.isDirectory()) {
451
+ throw new Error(`${label} must be a real directory: ${candidatePath}; no path was changed`);
452
+ }
453
+ return information;
454
+ }
455
+ async function assertRealGlobalAncestors(homeDirectory, skillRoot, fileSystem) {
456
+ const relative = path.relative(homeDirectory, skillRoot);
457
+ if (relative === "" || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
458
+ throw new Error(`Selected unmanaged Skill has an unsafe global ancestor: ${skillRoot}; no path was changed`);
459
+ }
460
+ await realDirectory(homeDirectory, "global home", fileSystem);
461
+ let current = homeDirectory;
462
+ for (const segment of relative.split(path.sep)) {
463
+ current = path.join(current, segment);
464
+ await realDirectory(current, "global Skill ancestor", fileSystem);
465
+ }
466
+ }
467
+ function validateExplicitGlobalPath(candidatePath, homeDirectory) {
468
+ if (!path.isAbsolute(candidatePath) || candidatePath !== path.normalize(candidatePath)) {
469
+ throw new Error(`Unmanaged Skill removal requires an exact normalized absolute global path: ${JSON.stringify(candidatePath)}; no path was changed`);
470
+ }
471
+ const root = GLOBAL_SKILL_ROOTS.find((candidate) => isUnderGlobalRoot(candidatePath, homeDirectory, candidate.kind === "agents" ? ".agents" : ".claude"));
472
+ if (root === undefined) {
473
+ throw new Error(`Unmanaged Skill path must be an immediate child under ${path.join(homeDirectory, ".agents", "skills")} or ${path.join(homeDirectory, ".claude", "skills")}: ${candidatePath}; no path was changed`);
474
+ }
475
+ if (isTransientName(path.basename(candidatePath))) {
476
+ throw new Error(`Backup or transient paths are not unmanaged Skill removal candidates: ${candidatePath}; no path was changed`);
477
+ }
478
+ return candidatePath;
479
+ }
480
+ function managedProtectionPaths(homeDirectory, selections) {
481
+ const protectedPaths = new Set();
482
+ for (const selection of selections) {
483
+ const installationPath = selection.installation?.path;
484
+ if (installationPath === undefined)
485
+ continue;
486
+ const absolutePath = resolveInstallationPath(homeDirectory, installationPath);
487
+ if (absolutePath === undefined)
488
+ continue;
489
+ for (const alias of globalPathAliases(absolutePath))
490
+ protectedPaths.add(pathKey(alias));
491
+ }
492
+ return [...protectedPaths];
493
+ }
494
+ function globalPathAliases(candidatePath) {
495
+ const resolved = path.resolve(candidatePath);
496
+ const aliases = [resolved];
497
+ if (isGlobalLocation(resolved, ".agents") || isGlobalLocation(resolved, ".claude")) {
498
+ const rootName = isGlobalLocation(resolved, ".agents") ? ".agents" : ".claude";
499
+ const otherRoot = rootName === ".agents" ? ".claude" : ".agents";
500
+ aliases.push(path.join(path.dirname(path.dirname(path.dirname(resolved))), otherRoot, "skills", path.basename(resolved)));
501
+ }
502
+ return aliases;
503
+ }
504
+ function isGlobalLocation(candidatePath, rootName) {
505
+ const skillsPath = path.dirname(candidatePath);
506
+ const globalRootPath = path.dirname(skillsPath);
507
+ const expectedRootPath = path.join(path.dirname(globalRootPath), rootName);
508
+ return pathKey(skillsPath) === pathKey(path.join(globalRootPath, "skills")) &&
509
+ pathKey(globalRootPath) === pathKey(expectedRootPath);
510
+ }
511
+ function fileIdentity(information) {
512
+ const native = `${information.dev}:${information.ino}`;
513
+ if (!information.isSymbolicLink()) {
514
+ // A directory's content digest covers a replacement that reuses an inode.
515
+ return native;
516
+ }
517
+ // A link has no content to digest and inode numbers are recycled, so add the
518
+ // birth time and the link size (mirrors pathIdentityKey in project-installation).
519
+ const birth = Number.isFinite(information.birthtimeMs) && information.birthtimeMs > 0 ? information.birthtimeMs : 0;
520
+ return `${native}:${information.size}:${birth}`;
521
+ }
522
+ function skip(candidatePath, root, reason, detail, name) {
523
+ return { ...(name === undefined ? {} : { name }), path: candidatePath, root, reason, detail };
524
+ }
525
+ function resolveInstallationPath(homeDirectory, installationPath) {
526
+ const normalized = installationPath.replaceAll("\\", "/");
527
+ if (path.posix.isAbsolute(normalized))
528
+ return undefined;
529
+ const absolutePath = path.resolve(homeDirectory, ...normalized.split("/"));
530
+ const relative = path.relative(homeDirectory, absolutePath);
531
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
532
+ return undefined;
533
+ return absolutePath;
534
+ }
535
+ function isUnderGlobalRoot(candidatePath, homeDirectory, directoryName) {
536
+ const rootPath = pathKey(path.join(homeDirectory, directoryName, "skills"));
537
+ const relative = path.relative(rootPath, pathKey(candidatePath));
538
+ return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) &&
539
+ !relative.includes(path.sep);
540
+ }
541
+ function pathKey(candidatePath) {
542
+ return path.normalize(path.resolve(candidatePath)).toLowerCase();
543
+ }
544
+ function isTransientName(name) {
545
+ const normalized = name.toLowerCase();
546
+ return /^\.agent-depot-(?:backup|update|overwrite|stage|staging|temp|tmp)(?:-|$)/u.test(normalized) ||
547
+ /\.agent-depot-unmanaged-stage-(?:[0-9a-f-]+)$/u.test(normalized) ||
548
+ normalized.endsWith(".tmp");
549
+ }
550
+ function sameManagedInstallations(left, right) {
551
+ return left.map((selection) => JSON.stringify(selection)).sort().join("\n") ===
552
+ right.map((selection) => JSON.stringify(selection)).sort().join("\n");
553
+ }
554
+ function isMissing(error) {
555
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
556
+ }
557
+ function errorMessage(error) {
558
+ return error instanceof Error ? error.message : String(error);
559
+ }
@@ -0,0 +1,14 @@
1
+ import path from "node:path";
2
+ /**
3
+ * Produces a terminal-independent command preview. This function has no
4
+ * process or filesystem side effects and is safe to call during assessment.
5
+ */
6
+ export function previewUserMethod(options) {
7
+ return Object.freeze({
8
+ argv: Object.freeze([...options.method.argv]),
9
+ cwd: path.resolve(options.projectRoot, options.method.cwd ?? "."),
10
+ target: options.target,
11
+ declaredChanges: Object.freeze([...options.declaredChanges]),
12
+ warning: "The external method may have effects Agent Depot cannot verify or roll back.",
13
+ });
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iseoane/agent-depot",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "A cross-platform catalog for reusable coding-agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "iseoane",
@@ -25,7 +25,10 @@
25
25
  },
26
26
  "files": [
27
27
  "dist/src",
28
+ "!dist/src/**/*.map",
28
29
  "skills",
30
+ "!skills/**/test_*.py",
31
+ "!skills/**/testdata",
29
32
  "README.md",
30
33
  "LICENSE",
31
34
  "CHANGELOG.md"