@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,565 @@
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 { assertSkillNotAppOwned, loadAppOwnedSkillFilter } from "./app-owned-skills.js";
6
+ import { pathsOverlap } from "./path-safety.js";
7
+ import { readExistingSkillTree } from "./skill-adoption.js";
8
+ import { parseSkillFrontmatter, skillTreeBaseline } from "./skill-discovery.js";
9
+ const GLOBAL_SKILL_ROOTS = [
10
+ { kind: "agents", relativePath: [".agents", "skills"] },
11
+ { kind: "claude", relativePath: [".claude", "skills"] },
12
+ ];
13
+ const nodeFileSystem = {
14
+ lstat,
15
+ readdir: async (candidatePath) => readdir(candidatePath),
16
+ readFile,
17
+ rename,
18
+ rm,
19
+ };
20
+ /**
21
+ * Scans only the immediate real directories below the two supported global Skill
22
+ * roots. This operation never reads a Skill tree or changes the filesystem.
23
+ *
24
+ * A persisted installation location is the ownership identity. Its normalized
25
+ * absolute path is compared directly, which preserves noncanonical adopted
26
+ * locations instead of guessing ownership from a Skill name alone.
27
+ */
28
+ export async function scanUserGlobalSkillInventory(options = {}) {
29
+ const homeDirectory = path.resolve(options.homeDirectory ?? homedir());
30
+ const selections = options.managedInstallations ?? options.managedSelections ?? [];
31
+ const managedPaths = new Set();
32
+ const managedCanonicalPaths = new Set();
33
+ for (const selection of selections) {
34
+ const installationPath = selection.installation?.path;
35
+ if (installationPath === undefined)
36
+ continue;
37
+ const absolutePath = resolveInstallationPath(homeDirectory, installationPath);
38
+ if (absolutePath === undefined)
39
+ continue;
40
+ managedPaths.add(pathKey(absolutePath));
41
+ // Only a persisted canonical location is authoritative for deduplicating
42
+ // an exposure symlink. A Claude-only adopted directory is deliberately not
43
+ // treated as ownership of a same-name canonical directory: that separate
44
+ // real directory must remain visible as a possible duplicate.
45
+ if (isUnderGlobalRoot(absolutePath, homeDirectory, ".agents")) {
46
+ managedCanonicalPaths.add(pathKey(absolutePath));
47
+ }
48
+ }
49
+ const entries = [];
50
+ const skipped = [];
51
+ const exposures = [];
52
+ const symlinks = [];
53
+ const isAppOwned = await loadAppOwnedSkillFilter({ ...options, homeDirectory });
54
+ const state = { isAppOwned, managedPaths, managedCanonicalPaths, entries, skipped, exposures, symlinks };
55
+ for (const root of GLOBAL_SKILL_ROOTS) {
56
+ const rootPath = path.join(homeDirectory, ...root.relativePath);
57
+ await scanRoot(root, rootPath, homeDirectory, state);
58
+ }
59
+ const managed = entries.filter((entry) => entry.status === "managed");
60
+ const unmanaged = entries.filter((entry) => entry.status === "unmanaged");
61
+ return Object.freeze({
62
+ homeDirectory,
63
+ entries: Object.freeze(entries),
64
+ managed: Object.freeze(managed),
65
+ unmanaged: Object.freeze(unmanaged),
66
+ skipped: Object.freeze(skipped),
67
+ exposures: Object.freeze(exposures),
68
+ symlinks: Object.freeze(symlinks),
69
+ });
70
+ }
71
+ /** Short alias for callers that only need the inventory operation. */
72
+ export const scanUserGlobalSkills = scanUserGlobalSkillInventory;
73
+ async function scanRoot(root, rootPath, homeDirectory, state) {
74
+ const names = await listScannableRoot(root, rootPath, homeDirectory, state.skipped);
75
+ for (const name of names ?? []) {
76
+ await scanRootEntry(root, name, path.join(rootPath, name), homeDirectory, state);
77
+ }
78
+ }
79
+ /** Returns the sorted entry names of a safe root, or undefined when it is missing or reported as skipped. */
80
+ async function listScannableRoot(root, rootPath, homeDirectory, skipped) {
81
+ const ancestorSafety = await inspectGlobalRootAncestors(homeDirectory, rootPath);
82
+ if (ancestorSafety === "missing")
83
+ return undefined;
84
+ if (ancestorSafety !== undefined) {
85
+ skipped.push(skip(rootPath, root.kind, "unsafe-root", ancestorSafety));
86
+ return undefined;
87
+ }
88
+ let rootInformation;
89
+ try {
90
+ rootInformation = await lstat(rootPath);
91
+ }
92
+ catch (error) {
93
+ if (!isMissing(error))
94
+ skipped.push(skip(rootPath, root.kind, "unsafe-root", errorMessage(error)));
95
+ return undefined;
96
+ }
97
+ if (rootInformation.isSymbolicLink() || !rootInformation.isDirectory()) {
98
+ skipped.push(skip(rootPath, root.kind, "unsafe-root", "global Skill root is not a real directory"));
99
+ return undefined;
100
+ }
101
+ try {
102
+ return (await readdir(rootPath)).sort();
103
+ }
104
+ catch (error) {
105
+ skipped.push(skip(rootPath, root.kind, "unsafe-root", errorMessage(error)));
106
+ return undefined;
107
+ }
108
+ }
109
+ async function scanRootEntry(root, name, candidatePath, homeDirectory, state) {
110
+ const { managedPaths, entries, skipped } = state;
111
+ if (!managedPaths.has(pathKey(candidatePath)) && state.isAppOwned(name))
112
+ return;
113
+ if (isTransientName(name)) {
114
+ skipped.push({ name, path: candidatePath, root: root.kind, reason: "excluded-transient" });
115
+ return;
116
+ }
117
+ let information;
118
+ try {
119
+ // lstat is deliberate: neither an entry nor any ancestor below the root
120
+ // is followed while deciding whether it is eligible for removal.
121
+ information = await lstat(candidatePath);
122
+ }
123
+ catch (error) {
124
+ if (!isMissing(error))
125
+ skipped.push(skip(candidatePath, root.kind, "unsafe-entry", errorMessage(error), name));
126
+ return;
127
+ }
128
+ if (information.isSymbolicLink()) {
129
+ await classifySymlink(candidatePath, name, root.kind, homeDirectory, state);
130
+ return;
131
+ }
132
+ if (!information.isDirectory())
133
+ return;
134
+ const problem = await findSkillDirectoryProblem(candidatePath);
135
+ if (problem === "not-a-skill")
136
+ return;
137
+ if (problem !== undefined) {
138
+ skipped.push(skip(candidatePath, root.kind, problem.reason, problem.detail, name));
139
+ return;
140
+ }
141
+ entries.push(Object.freeze({
142
+ name,
143
+ path: candidatePath,
144
+ root: root.kind,
145
+ host: root.kind === "claude" ? "claude" : "shared",
146
+ status: managedPaths.has(pathKey(candidatePath)) ? "managed" : "unmanaged",
147
+ }));
148
+ }
149
+ /** Why a real directory is not an eligible Skill: silently "not-a-skill" (no SKILL.md) or a reported problem. */
150
+ async function findSkillDirectoryProblem(candidatePath) {
151
+ const skillFile = path.join(candidatePath, "SKILL.md");
152
+ let skillFileInformation;
153
+ try {
154
+ skillFileInformation = await lstat(skillFile);
155
+ }
156
+ catch (error) {
157
+ return isMissing(error) ? "not-a-skill" : { reason: "unsafe-skill-file", detail: errorMessage(error) };
158
+ }
159
+ if (skillFileInformation.isSymbolicLink() || !skillFileInformation.isFile()) {
160
+ return { reason: "unsafe-skill-file", detail: "SKILL.md is not a real file" };
161
+ }
162
+ let skillContent;
163
+ try {
164
+ skillContent = await readFile(skillFile);
165
+ }
166
+ catch (error) {
167
+ return { reason: "unsafe-skill-file", detail: `SKILL.md cannot be read: ${errorMessage(error)}` };
168
+ }
169
+ if (parseSkillFrontmatter(Buffer.from(skillContent).toString("utf8")) === undefined) {
170
+ return { reason: "unsafe-skill-file", detail: "SKILL.md does not contain valid top-level name and description frontmatter" };
171
+ }
172
+ const unsafeTreeEntry = await findUnsafeTreeEntry(candidatePath);
173
+ return unsafeTreeEntry === undefined ? undefined : { reason: "unsafe-entry", detail: unsafeTreeEntry };
174
+ }
175
+ async function classifySymlink(candidatePath, name, root, homeDirectory, state) {
176
+ const { managedCanonicalPaths, managedPaths, entries, skipped, exposures, symlinks } = state;
177
+ let linkTarget;
178
+ try {
179
+ linkTarget = await readlink(candidatePath);
180
+ }
181
+ catch (error) {
182
+ skipped.push(skip(candidatePath, root, "unsafe-entry", errorMessage(error), name));
183
+ return;
184
+ }
185
+ const resolvedTarget = path.resolve(path.dirname(candidatePath), linkTarget);
186
+ // Only an exact same-name link to a direct child of the canonical root can be
187
+ // an exposure created by Agent Depot; anything else stays reported.
188
+ const isSameNameCanonical = root === "claude" &&
189
+ isUnderGlobalRoot(resolvedTarget, homeDirectory, ".agents") &&
190
+ path.basename(resolvedTarget) === name &&
191
+ pathKey(path.dirname(resolvedTarget)) === pathKey(path.join(homeDirectory, ".agents", "skills"));
192
+ const isManagedAlias = isSameNameCanonical &&
193
+ (managedCanonicalPaths.has(pathKey(resolvedTarget)) || managedPaths.has(pathKey(candidatePath)));
194
+ const isListedCanonical = isSameNameCanonical &&
195
+ entries.some((entry) => entry.root === "agents" && pathKey(entry.path) === pathKey(resolvedTarget));
196
+ if (isManagedAlias || isListedCanonical) {
197
+ const targetSafety = await inspectCanonicalTarget(resolvedTarget);
198
+ if (targetSafety === undefined) {
199
+ exposures.push({ name, path: candidatePath, root, target: resolvedTarget });
200
+ return;
201
+ }
202
+ skipped.push(skip(candidatePath, root, "unsafe-entry", targetSafety, name));
203
+ return;
204
+ }
205
+ skipped.push(skip(candidatePath, root, "unsafe-entry", "symbolic-link entries are never removal candidates", name));
206
+ if (!managedPaths.has(pathKey(candidatePath)))
207
+ symlinks.push({ name, path: candidatePath, root, target: resolvedTarget });
208
+ }
209
+ async function findUnsafeTreeEntry(candidatePath, relativeDirectory = "") {
210
+ let names;
211
+ try {
212
+ names = await readdir(candidatePath);
213
+ }
214
+ catch (error) {
215
+ return `Skill tree could not be inspected: ${errorMessage(error)}`;
216
+ }
217
+ for (const name of [...names].sort()) {
218
+ const relativePath = relativeDirectory === "" ? name : `${relativeDirectory}/${name}`;
219
+ const childPath = path.join(candidatePath, name);
220
+ let information;
221
+ try {
222
+ information = await lstat(childPath);
223
+ }
224
+ catch (error) {
225
+ return `Skill tree entry ${relativePath} could not be inspected: ${errorMessage(error)}`;
226
+ }
227
+ if (information.isSymbolicLink())
228
+ return `Skill tree contains a symbolic link: ${relativePath}`;
229
+ if (information.isDirectory()) {
230
+ const nested = await findUnsafeTreeEntry(childPath, relativePath);
231
+ if (nested !== undefined)
232
+ return nested;
233
+ continue;
234
+ }
235
+ if (!information.isFile())
236
+ return `Skill tree contains a non-regular file: ${relativePath}`;
237
+ }
238
+ return undefined;
239
+ }
240
+ async function inspectGlobalRootAncestors(homeDirectory, rootPath) {
241
+ const relative = path.relative(homeDirectory, rootPath);
242
+ if (relative === "" || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
243
+ return "global Skill root has an unsafe ancestor";
244
+ }
245
+ let current = homeDirectory;
246
+ for (const segment of ["", ...relative.split(path.sep)]) {
247
+ if (segment !== "")
248
+ current = path.join(current, segment);
249
+ let information;
250
+ try {
251
+ information = await lstat(current);
252
+ }
253
+ catch (error) {
254
+ if (isMissing(error))
255
+ return "missing";
256
+ return errorMessage(error);
257
+ }
258
+ if (information.isSymbolicLink() || !information.isDirectory()) {
259
+ return `global Skill root ancestor is not a real directory: ${current}`;
260
+ }
261
+ }
262
+ return undefined;
263
+ }
264
+ async function inspectCanonicalTarget(candidatePath) {
265
+ let targetInformation;
266
+ try {
267
+ targetInformation = await lstat(candidatePath);
268
+ }
269
+ catch (error) {
270
+ return `managed canonical target is unavailable: ${errorMessage(error)}`;
271
+ }
272
+ if (targetInformation.isSymbolicLink() || !targetInformation.isDirectory()) {
273
+ return "managed canonical target is not a real directory";
274
+ }
275
+ let skillInformation;
276
+ try {
277
+ skillInformation = await lstat(path.join(candidatePath, "SKILL.md"));
278
+ }
279
+ catch (error) {
280
+ return `managed canonical Skill file is unavailable: ${errorMessage(error)}`;
281
+ }
282
+ if (skillInformation.isSymbolicLink() || !skillInformation.isFile()) {
283
+ return "managed canonical target does not contain a real SKILL.md file";
284
+ }
285
+ return undefined;
286
+ }
287
+ /**
288
+ * Inspects one explicit unmanaged global path without resolving or searching a
289
+ * Source. The complete tree must be real-directory/regular-file content so a
290
+ * later recursive delete cannot follow a link or special file.
291
+ */
292
+ export async function inspectUserGlobalSkillRemoval(candidatePath, options) {
293
+ const homeDirectory = path.resolve(options.homeDirectory);
294
+ const targetPath = validateExplicitGlobalPath(candidatePath, homeDirectory);
295
+ await assertSkillNotAppOwned(path.basename(candidatePath), options);
296
+ const fileSystem = options.fileSystem ?? nodeFileSystem;
297
+ await assertRealGlobalAncestors(homeDirectory, path.dirname(targetPath), fileSystem);
298
+ const managedPaths = managedProtectionPaths(homeDirectory, options.managedInstallations);
299
+ const managedPath = managedPaths.find((protectedPath) => pathsOverlap(pathKey(targetPath), pathKey(protectedPath)));
300
+ if (managedPath !== undefined) {
301
+ 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`);
302
+ }
303
+ const information = await realDirectory(targetPath, "selected unmanaged Skill", fileSystem);
304
+ const skillFile = path.join(targetPath, "SKILL.md");
305
+ const skillInformation = await fileSystem.lstat(skillFile);
306
+ if (skillInformation.isSymbolicLink() || !skillInformation.isFile()) {
307
+ throw new Error(`Selected unmanaged Skill must contain a real SKILL.md file: ${targetPath}; no path was changed`);
308
+ }
309
+ const skillContent = await fileSystem.readFile(skillFile);
310
+ if (parseSkillFrontmatter(Buffer.from(skillContent).toString("utf8")) === undefined) {
311
+ throw new Error(`Selected unmanaged Skill must contain valid top-level name and description frontmatter: ${targetPath}; no path was changed`);
312
+ }
313
+ const tree = await readUnmanagedTree(targetPath, path.basename(targetPath), fileSystem);
314
+ return Object.freeze({
315
+ name: path.basename(targetPath),
316
+ path: targetPath,
317
+ identity: fileIdentity(information),
318
+ digest: skillTreeBaseline(tree).digest,
319
+ });
320
+ }
321
+ /** Refuses selected unmanaged paths that overlap physically or through Host aliases. */
322
+ export function assertNoOverlappingUserGlobalSkillRemovals(inspections) {
323
+ for (let leftIndex = 0; leftIndex < inspections.length; leftIndex += 1) {
324
+ for (let rightIndex = leftIndex + 1; rightIndex < inspections.length; rightIndex += 1) {
325
+ const left = inspections[leftIndex];
326
+ const right = inspections[rightIndex];
327
+ const leftAliases = globalPathAliases(left.path);
328
+ const rightAliases = globalPathAliases(right.path);
329
+ if (leftAliases.some((candidate) => rightAliases.some((other) => pathsOverlap(pathKey(candidate), pathKey(other))))) {
330
+ 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`);
331
+ }
332
+ }
333
+ }
334
+ }
335
+ /**
336
+ * Rechecks an inspected unmanaged Skill, moves it to same-parent staging, and
337
+ * permanently deletes only after validating the moved tree. A drifted or
338
+ * unverifiable staged tree is retained rather than guessed at.
339
+ */
340
+ export async function removeUserGlobalSkill(inspection, options) {
341
+ const current = await inspectUserGlobalSkillRemoval(inspection.path, options);
342
+ if (current.name !== inspection.name || current.path !== inspection.path || current.identity !== inspection.identity || current.digest !== inspection.digest) {
343
+ throw new Error(`The inspected unmanaged Skill changed before deletion: ${JSON.stringify(inspection.path)}; no path was changed`);
344
+ }
345
+ let removalOptions = options;
346
+ if (options.readManagedInstallations !== undefined) {
347
+ const latestManagedInstallations = await options.readManagedInstallations();
348
+ if (options.expectedManagedInstallations !== undefined && !sameManagedInstallations(options.expectedManagedInstallations, latestManagedInstallations)) {
349
+ throw new Error(`Managed Skill records changed before deleting unmanaged Skill ${JSON.stringify(inspection.path)}; no path was changed`);
350
+ }
351
+ const latest = await inspectUserGlobalSkillRemoval(inspection.path, {
352
+ ...options,
353
+ managedInstallations: latestManagedInstallations,
354
+ });
355
+ if (latest.name !== inspection.name || latest.path !== inspection.path || latest.identity !== inspection.identity || latest.digest !== inspection.digest) {
356
+ throw new Error(`The inspected unmanaged Skill changed before deletion: ${JSON.stringify(inspection.path)}; no path was changed`);
357
+ }
358
+ removalOptions = { ...options, managedInstallations: latestManagedInstallations };
359
+ }
360
+ const fileSystem = removalOptions.fileSystem ?? nodeFileSystem;
361
+ const stagingPath = path.join(path.dirname(inspection.path), `.${inspection.name}.agent-depot-unmanaged-stage-${randomUUID()}`);
362
+ try {
363
+ await fileSystem.rename(inspection.path, stagingPath);
364
+ }
365
+ catch (error) {
366
+ throw new Error(`Could not stage unmanaged Skill ${JSON.stringify(inspection.path)} before deletion: ${errorMessage(error)}; no path was changed`);
367
+ }
368
+ try {
369
+ const staged = await inspectStagedSkill(stagingPath, inspection.name, fileSystem);
370
+ if (staged.identity !== inspection.identity || staged.digest !== inspection.digest) {
371
+ throw new Error("the staged tree's identity or content changed");
372
+ }
373
+ await fileSystem.rm(stagingPath, { recursive: true, force: false });
374
+ }
375
+ catch (error) {
376
+ const restored = await restoreStagedSkill(stagingPath, inspection.path, inspection, fileSystem);
377
+ if (restored) {
378
+ throw new Error(`Unmanaged Skill deletion stopped for ${JSON.stringify(inspection.path)}; the original was restored and no permanent deletion occurred: ${errorMessage(error)}`);
379
+ }
380
+ 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)}`);
381
+ }
382
+ }
383
+ /**
384
+ * Inspects one explicit symlink below a supported global root. Only the link is
385
+ * ever removed; the path it points to is never read, followed or changed.
386
+ */
387
+ export async function inspectUserGlobalSymlinkRemoval(candidatePath, options) {
388
+ const homeDirectory = path.resolve(options.homeDirectory);
389
+ const linkPath = validateExplicitGlobalPath(candidatePath, homeDirectory);
390
+ await assertSkillNotAppOwned(path.basename(candidatePath), options);
391
+ const fileSystem = options.fileSystem ?? nodeFileSystem;
392
+ await assertRealGlobalAncestors(homeDirectory, path.dirname(linkPath), fileSystem);
393
+ const managedPath = managedProtectionPaths(homeDirectory, options.managedInstallations)
394
+ .find((protectedPath) => pathsOverlap(pathKey(linkPath), protectedPath));
395
+ if (managedPath !== undefined) {
396
+ 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`);
397
+ }
398
+ const information = await fileSystem.lstat(linkPath);
399
+ if (!information.isSymbolicLink()) {
400
+ throw new Error(`Selected unmanaged link must be a symbolic link: ${linkPath}; no path was changed`);
401
+ }
402
+ return Object.freeze({
403
+ name: path.basename(linkPath),
404
+ path: linkPath,
405
+ target: await readlink(linkPath),
406
+ identity: fileIdentity(information),
407
+ });
408
+ }
409
+ /** Rechecks the inspected link (and the managed records when given), then unlinks only the link. */
410
+ export async function removeUserGlobalSymlink(inspection, options) {
411
+ const unchanged = (current) => current.name === inspection.name && current.path === inspection.path &&
412
+ current.target === inspection.target && current.identity === inspection.identity;
413
+ const changed = () => new Error(`The inspected unmanaged link changed before deletion: ${JSON.stringify(inspection.path)}; no path was changed`);
414
+ if (!unchanged(await inspectUserGlobalSymlinkRemoval(inspection.path, options)))
415
+ throw changed();
416
+ if (options.readManagedInstallations !== undefined) {
417
+ const latest = await options.readManagedInstallations();
418
+ if (options.expectedManagedInstallations !== undefined && !sameManagedInstallations(options.expectedManagedInstallations, latest)) {
419
+ throw new Error(`Managed Skill records changed before deleting unmanaged link ${JSON.stringify(inspection.path)}; no path was changed`);
420
+ }
421
+ if (!unchanged(await inspectUserGlobalSymlinkRemoval(inspection.path, { ...options, managedInstallations: latest })))
422
+ throw changed();
423
+ }
424
+ // A swap for a real directory is caught by the last recheck above or refused by unlink (EISDIR/EPERM).
425
+ // A remaining window: the link replaced by another symbolic link between that recheck and this unlink
426
+ // is still removed, because unlink cannot verify identity atomically. Only a link is ever unlinked.
427
+ await unlink(inspection.path);
428
+ }
429
+ async function inspectStagedSkill(candidatePath, name, fileSystem) {
430
+ const information = await realDirectory(candidatePath, "staged unmanaged Skill", fileSystem);
431
+ const tree = await readUnmanagedTree(candidatePath, name, fileSystem);
432
+ return {
433
+ name,
434
+ path: candidatePath,
435
+ identity: fileIdentity(information),
436
+ digest: skillTreeBaseline(tree).digest,
437
+ };
438
+ }
439
+ async function restoreStagedSkill(stagingPath, targetPath, expected, fileSystem) {
440
+ try {
441
+ const staged = await inspectStagedSkill(stagingPath, expected.name, fileSystem);
442
+ if (staged.identity !== expected.identity || staged.digest !== expected.digest)
443
+ return false;
444
+ await fileSystem.rename(stagingPath, targetPath);
445
+ return true;
446
+ }
447
+ catch {
448
+ return false;
449
+ }
450
+ }
451
+ async function readUnmanagedTree(candidatePath, skillName, fileSystem) {
452
+ return readExistingSkillTree(candidatePath, skillName, fileSystem);
453
+ }
454
+ async function realDirectory(candidatePath, label, fileSystem = nodeFileSystem) {
455
+ const information = await fileSystem.lstat(candidatePath);
456
+ if (information.isSymbolicLink() || !information.isDirectory()) {
457
+ throw new Error(`${label} must be a real directory: ${candidatePath}; no path was changed`);
458
+ }
459
+ return information;
460
+ }
461
+ async function assertRealGlobalAncestors(homeDirectory, skillRoot, fileSystem) {
462
+ const relative = path.relative(homeDirectory, skillRoot);
463
+ if (relative === "" || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
464
+ throw new Error(`Selected unmanaged Skill has an unsafe global ancestor: ${skillRoot}; no path was changed`);
465
+ }
466
+ await realDirectory(homeDirectory, "global home", fileSystem);
467
+ let current = homeDirectory;
468
+ for (const segment of relative.split(path.sep)) {
469
+ current = path.join(current, segment);
470
+ await realDirectory(current, "global Skill ancestor", fileSystem);
471
+ }
472
+ }
473
+ function validateExplicitGlobalPath(candidatePath, homeDirectory) {
474
+ if (!path.isAbsolute(candidatePath) || candidatePath !== path.normalize(candidatePath)) {
475
+ throw new Error(`Unmanaged Skill removal requires an exact normalized absolute global path: ${JSON.stringify(candidatePath)}; no path was changed`);
476
+ }
477
+ const root = GLOBAL_SKILL_ROOTS.find((candidate) => isUnderGlobalRoot(candidatePath, homeDirectory, candidate.kind === "agents" ? ".agents" : ".claude"));
478
+ if (root === undefined) {
479
+ 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`);
480
+ }
481
+ if (isTransientName(path.basename(candidatePath))) {
482
+ throw new Error(`Backup or transient paths are not unmanaged Skill removal candidates: ${candidatePath}; no path was changed`);
483
+ }
484
+ return candidatePath;
485
+ }
486
+ function managedProtectionPaths(homeDirectory, selections) {
487
+ const protectedPaths = new Set();
488
+ for (const selection of selections) {
489
+ const installationPath = selection.installation?.path;
490
+ if (installationPath === undefined)
491
+ continue;
492
+ const absolutePath = resolveInstallationPath(homeDirectory, installationPath);
493
+ if (absolutePath === undefined)
494
+ continue;
495
+ for (const alias of globalPathAliases(absolutePath))
496
+ protectedPaths.add(pathKey(alias));
497
+ }
498
+ return [...protectedPaths];
499
+ }
500
+ function globalPathAliases(candidatePath) {
501
+ const resolved = path.resolve(candidatePath);
502
+ const aliases = [resolved];
503
+ if (isGlobalLocation(resolved, ".agents") || isGlobalLocation(resolved, ".claude")) {
504
+ const rootName = isGlobalLocation(resolved, ".agents") ? ".agents" : ".claude";
505
+ const otherRoot = rootName === ".agents" ? ".claude" : ".agents";
506
+ aliases.push(path.join(path.dirname(path.dirname(path.dirname(resolved))), otherRoot, "skills", path.basename(resolved)));
507
+ }
508
+ return aliases;
509
+ }
510
+ function isGlobalLocation(candidatePath, rootName) {
511
+ const skillsPath = path.dirname(candidatePath);
512
+ const globalRootPath = path.dirname(skillsPath);
513
+ const expectedRootPath = path.join(path.dirname(globalRootPath), rootName);
514
+ return pathKey(skillsPath) === pathKey(path.join(globalRootPath, "skills")) &&
515
+ pathKey(globalRootPath) === pathKey(expectedRootPath);
516
+ }
517
+ function fileIdentity(information) {
518
+ const native = `${information.dev}:${information.ino}`;
519
+ if (!information.isSymbolicLink()) {
520
+ // A directory's content digest covers a replacement that reuses an inode.
521
+ return native;
522
+ }
523
+ // A link has no content to digest and inode numbers are recycled, so add the
524
+ // birth time and the link size (mirrors pathIdentityKey in project-installation).
525
+ const birth = Number.isFinite(information.birthtimeMs) && information.birthtimeMs > 0 ? information.birthtimeMs : 0;
526
+ return `${native}:${information.size}:${birth}`;
527
+ }
528
+ function skip(candidatePath, root, reason, detail, name) {
529
+ return { ...(name === undefined ? {} : { name }), path: candidatePath, root, reason, detail };
530
+ }
531
+ function resolveInstallationPath(homeDirectory, installationPath) {
532
+ const normalized = installationPath.replaceAll("\\", "/");
533
+ if (path.posix.isAbsolute(normalized))
534
+ return undefined;
535
+ const absolutePath = path.resolve(homeDirectory, ...normalized.split("/"));
536
+ const relative = path.relative(homeDirectory, absolutePath);
537
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
538
+ return undefined;
539
+ return absolutePath;
540
+ }
541
+ function isUnderGlobalRoot(candidatePath, homeDirectory, directoryName) {
542
+ const rootPath = pathKey(path.join(homeDirectory, directoryName, "skills"));
543
+ const relative = path.relative(rootPath, pathKey(candidatePath));
544
+ return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) &&
545
+ !relative.includes(path.sep);
546
+ }
547
+ function pathKey(candidatePath) {
548
+ return path.normalize(path.resolve(candidatePath)).toLowerCase();
549
+ }
550
+ function isTransientName(name) {
551
+ const normalized = name.toLowerCase();
552
+ return /^\.agent-depot-(?:backup|update|overwrite|stage|staging|temp|tmp)(?:-|$)/u.test(normalized) ||
553
+ /\.agent-depot-unmanaged-stage-(?:[0-9a-f-]+)$/u.test(normalized) ||
554
+ normalized.endsWith(".tmp");
555
+ }
556
+ function sameManagedInstallations(left, right) {
557
+ return left.map((selection) => JSON.stringify(selection)).sort().join("\n") ===
558
+ right.map((selection) => JSON.stringify(selection)).sort().join("\n");
559
+ }
560
+ function isMissing(error) {
561
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
562
+ }
563
+ function errorMessage(error) {
564
+ return error instanceof Error ? error.message : String(error);
565
+ }
@@ -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.3.0",
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"
@@ -41,9 +44,9 @@
41
44
  "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
42
45
  "typecheck": "tsc --noEmit",
43
46
  "lint": "eslint \"src/**/*.{ts,tsx}\" \"tests/**/*.{ts,tsx}\"",
44
- "test": "pnpm build && node --test \"dist/tests/*.test.js\"",
45
- "test:single": "pnpm build && node -e \"const { spawnSync } = require('node:child_process'); const input = process.argv[1] || 'tests/version.test.ts'; const file = input.replace(/^tests[\\\\/]/, 'dist/tests/').replace(/\\\\.ts$/i, '.js'); process.exit(spawnSync(process.execPath, ['--test', file], { stdio: 'inherit' }).status ?? 1);\"",
46
- "test:coverage": "node -e \"require('node:fs').rmSync('coverage',{recursive:true,force:true})\" && tsc -p tsconfig.coverage.json && node -e \"const { spawnSync } = require('node:child_process'); process.exit(spawnSync(process.execPath, ['--enable-source-maps','--test','--test-reporter=dot','dist/tests/*.test.js'], { stdio: 'inherit', env: { ...process.env, NODE_V8_COVERAGE: 'coverage/v8' } }).status ?? 1);\"",
47
+ "test": "pnpm build && node --test --import ./dist/tests/isolated-env.js \"dist/tests/*.test.js\"",
48
+ "test:single": "pnpm build && node -e \"const { spawnSync } = require('node:child_process'); const input = process.argv[1] || 'tests/version.test.ts'; const file = input.replace(/^tests[\\\\/]/, 'dist/tests/').replace(/\\\\.ts$/i, '.js'); process.exit(spawnSync(process.execPath, ['--import', './dist/tests/isolated-env.js', '--test', file], { stdio: 'inherit' }).status ?? 1);\"",
49
+ "test:coverage": "node -e \"require('node:fs').rmSync('coverage',{recursive:true,force:true})\" && tsc -p tsconfig.coverage.json && node -e \"const { spawnSync } = require('node:child_process'); process.exit(spawnSync(process.execPath, ['--enable-source-maps','--import','./dist/tests/isolated-env.js','--test','--test-reporter=dot','dist/tests/*.test.js'], { stdio: 'inherit', env: { ...process.env, NODE_V8_COVERAGE: 'coverage/v8' } }).status ?? 1);\"",
47
50
  "audit:dead-code": "npx --yes fallow@3.30.0 dead-code --fail-on-issues",
48
51
  "audit:dupes": "npx --yes fallow@3.30.0 dupes",
49
52
  "audit:health": "pnpm test:coverage && npx --yes fallow@3.30.0 health --coverage coverage/v8 --min-score 85",
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: "agent-depot-apprecipe"
3
+ description: "Trigger: App recipe creation or repair. Research an App's actual install channel and write platform-aware Agent Depot recipes."
4
+ license: MIT
5
+ metadata:
6
+ author: "iseoane"
7
+ version: "1.0"
8
+ ---
9
+
10
+ ## Activation Contract
11
+
12
+ Use for creating or repairing user-global App recipes, not portable Skills.
13
+
14
+ ## Hard Rules
15
+
16
+ - Read `agent-depot app schema` first: it is the format source of truth. Research upstream README, install docs and releases through the web or GitHub API; never clone the App repository.
17
+ - Write user-owned JSON in the environment-local `apps/` beside `sources.json`. Preserve existing files unless the user authorizes replacement. Keep Apps out of Sources and project manifests.
18
+ - Never approve a recipe on the user's behalf. Approval belongs to the user via `agent-depot app approve <name> --yes`; changed bytes need renewed approval. Validation is not approval.
19
+ - Use bare executable-plus-arguments arrays, without shells, shell syntax, credentials or environment assignments. Script-only routes (`curl | sh`, PowerShell scripts, `.cmd`) belong in `manual` text, never pipe-to-shell argv. Agent Depot displays manual text without executing it.
20
+ - Steps run from the user's home with stdin EOF and no timeout. Use non-interactive flags; terminal-dependent installers belong in manual steps.
21
+
22
+ ## Decision Gates
23
+
24
+ | Evidence | Recipe decision |
25
+ |---|---|
26
+ | Identical steps on all supported platforms | Omit `platform`. |
27
+ | Steps differ by OS or architecture | Use separate files with the same App `name` and explicit `linux`, `windows` or `darwin`; ask about architecture where needed. Never create duplicate applicable names. |
28
+ | Unsupported platform | Report it; invent no steps. WSL is Linux, not Windows. |
29
+ | Several installs or uncertain channel | List every path/channel and ask the user to choose before writing lifecycle steps. |
30
+ | brew / npm / pipx | Derive update and uninstall from that channel: upgrade/uninstall, global install/uninstall, or upgrade/uninstall respectively. |
31
+ | Go install / release binary | Use the documented update route and a manual binary-removal step. |
32
+ | App has its own uninstall | Prefer it after verifying scope and non-interactive flags; explain remaining channel cleanup. |
33
+
34
+ ## Execution Steps
35
+
36
+ 1. Record upstream evidence and platform support. Inspect all PATH matches (`which -a`/`whence -a`, or Windows `Get-Command -All`), resolved paths, and channel inventories such as `brew list`, `npm ls -g` or `pipx list`. List Windows shims seen from WSL separately: `/mnt/<drive>/` executables cannot serve a Linux recipe. Finish only when the actual channel is established or the user has chosen one.
37
+ 2. Write required `name`, `install`, `update`, `uninstall`, and `version`. Each lifecycle step uses `argv`, `manual`, or both; manual fallback applies only to spawn failure, never non-zero exit. `version` needs read-only argv and a JavaScript regex with exactly one capture group. Choose optional `latest` as `github: owner/repo`, `npm: package`, or read-only `argv` plus `pattern`; omit it when unknown. Consult [the illustrative example](assets/example.md), not as upstream command evidence.
38
+ 3. Put user-global Host wiring in optional `setup`/`teardown` maps keyed by `pi`, `claude`, `codex`, or `opencode`. Keep install separate; uninstall never implicitly runs teardown. Delegate wiring to App commands or manual user instructions, not direct agent edits of Host configuration. Exclude project initialization.
39
+ 4. Declare optional `skills` only for documented App-owned directory/link names. Match whole names case-sensitively; only `*` is special, while `?` and brackets are literal. Reject empty/whitespace-only, path separators, NUL and star-only patterns. These declarations filter unmanaged adoption/removal even before approval/install; they verify no artifacts or provenance.
40
+ 5. Run `agent-depot app validate <file>...` for current-platform recipes. Fix structural errors; report unapproved recipes as needs approval, not runtime-verified. Version commands run only for approved applicable content. Mark other platforms unverified. After user approval, the user can inspect `app list` and separately preview `app install|update|uninstall <name>` or `app setup|teardown <name> --host <host>`.
41
+
42
+ ## Output Contract
43
+
44
+ Return file paths, platform/channel evidence, validation results and runtime-verification limits; unsupported platforms; omitted optional fields and why; license, telemetry/update-check behavior (or unknown); and conflicting duplicate install routes, including plugin-marketplace versus setup routes. Explain that version alone determines installed state, updates require a changed version, and unknown latest is not an available update. End with the user's approval command, without running it.