@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,374 @@
1
+ import path from "node:path";
2
+ import { assertSkillNotAppOwned } from "./app-owned-skills.js";
3
+ import { pathsOverlap } from "./path-safety.js";
4
+ import { inspectProjectSkillInstallation, installProjectSkillTransaction, validateProjectSkillInstallationPreview, } from "./project-installation.js";
5
+ import { AGENT_DEPOT_PACKAGE_VERSION, parseProjectManifest, } from "./project-manifest.js";
6
+ import { createSourceContentAccess } from "./skill-discovery.js";
7
+ import { relativeProjectPath, selectionWithInstallation } from "./skill-update.js";
8
+ import { executeSourceInstallationMethod, parseSourceInstallationMethod, resolveProjectSource, SourceNotFoundError, } from "./sources.js";
9
+ import { CliUsageError } from "./usage-error.js";
10
+ /**
11
+ * Shared install flow for one Skill selection (project or user-global):
12
+ * resolve, inspect, preview, then execute. Used by the CLI and the TUI.
13
+ */
14
+ const INSTALL_USAGE = "Usage: agent-depot install --scope <project|user-global> --source <id> --skill <path> --host <host>... --version <latest|version> [--ref <git-ref>] [--method <json>] --portable-v1 [--overwrite --yes] [--confirm-additional-host]";
15
+ export async function selectInstallSource(options, operations) {
16
+ const source = await findSource(operations, options.sourceId);
17
+ const projectSource = projectSourceFromSource(source, options.ref, options.version);
18
+ if (projectSource.kind === "builtin" && options.version.policy === "fixed" &&
19
+ AGENT_DEPOT_PACKAGE_VERSION !== undefined && options.version.version !== AGENT_DEPOT_PACKAGE_VERSION) {
20
+ throw new CliUsageError(`--version fixed:${options.version.version} must equal the current Agent Depot package version ${JSON.stringify(AGENT_DEPOT_PACKAGE_VERSION)}; ${JSON.stringify(options.version.version)} cannot be reproduced by this package`);
21
+ }
22
+ const selection = parseProjectManifest({
23
+ version: 1,
24
+ skills: [{
25
+ source: projectSource,
26
+ path: options.skillPath,
27
+ version: options.version,
28
+ hosts: options.hosts,
29
+ ...(options.method === undefined ? {} : { methods: { install: options.method } }),
30
+ }],
31
+ }).skills[0];
32
+ if (!selection) {
33
+ throw new CliUsageError(INSTALL_USAGE);
34
+ }
35
+ if (!options.portableV1) {
36
+ throw new CliUsageError("Compatibility is not known; rerun with --portable-v1 after reviewing the Skill");
37
+ }
38
+ return { source, projectSource, selection };
39
+ }
40
+ export async function resolveInstallInspection(selection, source, projectSource, installationRoot, options, operations, dependencies, sourceAccess) {
41
+ const initialResolvedSource = projectSource.kind === "builtin"
42
+ ? source
43
+ : operations.resolveProjectSource
44
+ ? await operations.resolveProjectSource(projectSource)
45
+ : resolveProjectSource(projectSource);
46
+ const resolvedSource = await refreshResolvedSource(operations, projectSource, initialResolvedSource, options.confirmed);
47
+ const resolved = await resolveSelection(selection, resolvedSource, operations, sourceAccess);
48
+ const installationInspection = await inspectProjectSkillInstallation({
49
+ selection,
50
+ source: resolvedSource,
51
+ previewTree: resolved.files,
52
+ portableV1: options.portableV1,
53
+ confirmAdditionalHostExposure: options.confirmAdditionalHostExposure,
54
+ resolvedVersion: resolved.resolvedVersion,
55
+ }, {
56
+ projectRoot: installationRoot,
57
+ sourceAccess,
58
+ ...dependencies.installationOptions,
59
+ });
60
+ return { resolvedSource, resolved, installationInspection };
61
+ }
62
+ export async function resolveSelection(selection, source, operations, sourceAccess) {
63
+ const preview = await readPreviewTree(sourceAccess, source, selection);
64
+ await validateProjectSkillInstallationPreview({
65
+ selection,
66
+ source,
67
+ previewTree: preview.files,
68
+ portableV1: true,
69
+ }, sourceAccess);
70
+ // An explicitly configured method is authoritative. Avoid reading source metadata
71
+ // when it is present so an invalid fallback cannot override user intent.
72
+ const method = selection.methods?.install ?? await readStructuredMethod(operations, source, selection.path, preview.files);
73
+ // The default Source access returns this evidence with the immutable tree. The
74
+ // resolver fallback preserves compatibility for embedders with the older seam.
75
+ const resolvedVersion = preview.resolvedVersion
76
+ ?? await operations.resolveSourceVersion?.(source)
77
+ ?? (source.kind === "builtin" && AGENT_DEPOT_PACKAGE_VERSION !== undefined
78
+ ? Object.freeze({ kind: "builtin-package", version: AGENT_DEPOT_PACKAGE_VERSION })
79
+ : undefined);
80
+ return Object.freeze({ selection, source, files: preview.files, resolvedVersion, method });
81
+ }
82
+ export async function refreshResolvedSource(operations, projectSource, source, confirmed) {
83
+ if (!confirmed || source.kind === "builtin" || !operations.refreshProjectSource) {
84
+ return source;
85
+ }
86
+ const refreshed = await operations.refreshProjectSource(projectSource);
87
+ if (refreshed.kind !== source.kind || refreshed.id !== source.id ||
88
+ (refreshed.kind === "git" && source.kind === "git" && refreshed.url !== source.url)) {
89
+ throw new Error(`Refreshed Source identity changed from ${JSON.stringify(source.id)} to ${JSON.stringify(refreshed.id)}; no installation was attempted`);
90
+ }
91
+ return refreshed;
92
+ }
93
+ export async function readPreviewTree(sourceAccess, source, selection) {
94
+ const captured = sourceAccess.readSkillTreeSnapshot
95
+ ? await sourceAccess.readSkillTreeSnapshot(source, selection.path)
96
+ : { files: await sourceAccess.readSkillTree(source, selection.path) };
97
+ const prefix = `${selection.path}/`;
98
+ const files = captured.files;
99
+ if (!files.some((file) => file.path === `${selection.path}/SKILL.md`)) {
100
+ throw new Error(`Selected Skill ${JSON.stringify(selection.path)} does not contain SKILL.md`);
101
+ }
102
+ const seen = new Set();
103
+ const snapshot = files.map((file) => {
104
+ const relativePath = file.path.slice(prefix.length);
105
+ if (!file.path.startsWith(prefix) || !isSafePreviewPath(relativePath) || seen.has(relativePath)) {
106
+ throw new Error(`Selected Skill returned an unsafe or duplicate file path: ${JSON.stringify(file.path)}`);
107
+ }
108
+ seen.add(relativePath);
109
+ return Object.freeze({
110
+ path: file.path,
111
+ content: Uint8Array.from(file.content),
112
+ executable: file.executable,
113
+ });
114
+ });
115
+ return Object.freeze({ files: Object.freeze(snapshot), resolvedVersion: captured.resolvedVersion });
116
+ }
117
+ export function isSafePreviewPath(value) {
118
+ if (!value || value.includes("\\") || path.posix.isAbsolute(value)) {
119
+ return false;
120
+ }
121
+ const normalized = path.posix.normalize(value);
122
+ return normalized === value && normalized !== "." && normalized !== ".." && !normalized.startsWith("../") &&
123
+ normalized.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
124
+ }
125
+ export async function readStructuredMethod(operations, source, skillPath, previewTree) {
126
+ if (!operations.readInstallationMethod) {
127
+ return undefined;
128
+ }
129
+ const metadata = await operations.readInstallationMethod(source, skillPath, previewTree);
130
+ return metadata === undefined ? undefined : parseSourceInstallationMethod(metadata);
131
+ }
132
+ export function outputInstallPreview(selection, source, projectRoot, output, files, method, scope, inspection, overwrite, confirmed) {
133
+ const skillName = path.posix.basename(selection.path);
134
+ const canonicalPath = path.join(projectRoot, ".agents", "skills", skillName);
135
+ const claudePath = path.join(projectRoot, ".claude", "skills", skillName);
136
+ output(`Preview: reconcile Skill ${JSON.stringify(skillName)} from ${source.id} (destination determined by safe inspection)`);
137
+ output(` scope: ${scope}; hosts: ${selection.hosts.join(",")}; version policy: ${formatVersionPolicy(selection.version)}`);
138
+ if (inspection) {
139
+ output(` target: ${inspection.targetPath}`);
140
+ if (inspection.collision) {
141
+ output(` COLLISION: ${inspection.collision.path} (${inspection.collision.reason})`);
142
+ if (overwrite && confirmed) {
143
+ output(" collision handling: target will be replaced because --overwrite --yes was supplied");
144
+ output(` overwrite backup: old Skill will be moved to a retained backup at ${backupPreviewPath(inspection.targetPath)} (generated at transaction time; never pruned automatically)`);
145
+ }
146
+ else if (overwrite) {
147
+ output(" collision handling: fail-closed; no path will be changed without --yes; rerun with --overwrite --yes to replace the target");
148
+ output(` if confirmed: old Skill would be moved to a retained backup at ${backupPreviewPath(inspection.targetPath)}`);
149
+ }
150
+ else {
151
+ output(" collision handling: fail-closed; no path will be changed; explicit overwrite requires --overwrite --yes");
152
+ }
153
+ }
154
+ else if (inspection.locations.some((location) => location.status === "identical")) {
155
+ output(" collision status: existing content is identical and can be adopted without replacement");
156
+ }
157
+ else {
158
+ output(" collision status: target is absent and will be created");
159
+ }
160
+ }
161
+ output(confirmed
162
+ ? " confirmation: --yes supplied; changes remain subject to final safety checks"
163
+ : " confirmation: no --yes supplied; zero files or installation state will be changed");
164
+ output(" intended filesystem changes:");
165
+ if (inspection?.collision && overwrite) {
166
+ output(` replace the colliding target at ${inspection.targetPath}, retain a backup of the existing Skill at ${backupPreviewPath(inspection.targetPath)}, and install the selected source files`);
167
+ }
168
+ else {
169
+ output(" reconcile the selected source files below: adopt identical files, create missing files, and refuse conflicts");
170
+ }
171
+ output(" selected source files:");
172
+ for (const file of files) {
173
+ const action = inspection?.collision && overwrite ? "install into the replacement target" : "adopt if identical, create if missing";
174
+ output(` ${file.path} (${file.content.byteLength} bytes${file.executable ? ", executable" : ""}; ${action})`);
175
+ }
176
+ output(` possible ${scope} locations:`);
177
+ output(` ${canonicalPath} (canonical Host location; safe inspection determines whether this is used)`);
178
+ if (selection.hosts.includes("claude")) {
179
+ output(` ${claudePath} (Claude Host location; fresh install exposes the canonical location by symlink with --yes)`);
180
+ }
181
+ if (method) {
182
+ const cwd = path.resolve(projectRoot, method.cwd ?? ".");
183
+ output(` run external method: argv=${JSON.stringify(method.argv)} executable=${JSON.stringify(method.argv[0])} args=${JSON.stringify(method.argv.slice(1))} cwd=${JSON.stringify(cwd)}`);
184
+ }
185
+ }
186
+ export function backupPreviewPath(targetPath) {
187
+ return path.join(path.dirname(targetPath), ".agent-depot-backup-<generated-suffix>");
188
+ }
189
+ export function assertNoManagedInstallationOverlap(managed, selection, inspection, projectRoot, overwrite) {
190
+ const protectedPaths = managed
191
+ .filter((candidate) => !sameSelectionIdentity(candidate, selection))
192
+ .flatMap((candidate) => candidate.installation?.path === undefined
193
+ ? []
194
+ : [path.resolve(projectRoot, ...candidate.installation.path.split("/"))]);
195
+ if (!overwrite)
196
+ return Object.freeze(protectedPaths);
197
+ const conflicting = protectedPaths.find((candidate) => pathsOverlap(path.resolve(candidate), path.resolve(inspection.targetPath)));
198
+ if (conflicting !== undefined) {
199
+ throw new CliUsageError(`Cannot overwrite ${JSON.stringify(inspection.targetPath)} because it overlaps the managed installation ${JSON.stringify(conflicting)} belonging to another Source/Skill selection; no path was changed.`);
200
+ }
201
+ return Object.freeze(protectedPaths);
202
+ }
203
+ export function sameSelectionIdentity(left, right) {
204
+ return JSON.stringify([left.source, left.path]) === JSON.stringify([right.source, right.path]);
205
+ }
206
+ export function rejectInstallationCollision(inspection, overwrite) {
207
+ if (!inspection.collision)
208
+ return;
209
+ const detail = `Cannot install because ${JSON.stringify(inspection.collision.path)} has a ${inspection.collision.reason} collision; no path was changed.`;
210
+ if (inspection.collision.reason !== "missing-file" && inspection.collision.reason !== "extra-file" && inspection.collision.reason !== "content-mismatch") {
211
+ throw new CliUsageError(`${detail} Unsafe targets cannot be overwritten.`);
212
+ }
213
+ if (overwrite) {
214
+ return;
215
+ }
216
+ throw new CliUsageError(`${detail} Explicit overwrite requires --overwrite --yes.`);
217
+ }
218
+ export function formatVersionPolicy(version) {
219
+ return version.policy === "latest" ? "latest" : `fixed:${version.version}`;
220
+ }
221
+ export function outputInstallationResult(result, projectRoot, output) {
222
+ const locations = result.adopted
223
+ ? result.adoptedPaths
224
+ : [result.canonicalPath];
225
+ const rendered = locations.map((location) => relativeProjectPath(projectRoot, location)).join(", ");
226
+ output(`${result.adopted ? "Adopted" : "Installed"} Skill ${JSON.stringify(result.skillName)} at ${rendered}`);
227
+ if (result.backupPath !== undefined) {
228
+ output(` Persistent backup retained at ${result.backupPath}`);
229
+ }
230
+ }
231
+ export async function installOne(selection, source, sourceAccess, projectRoot, dependencies, method, portableV1, files, resolvedVersion, confirmAdditionalHostExposure, overwrite, inspection, protectedPaths = []) {
232
+ return installProjectSkillTransaction({
233
+ selection,
234
+ source,
235
+ previewTree: files,
236
+ portableV1,
237
+ resolvedVersion,
238
+ confirmAdditionalHostExposure,
239
+ overwrite,
240
+ ...(inspection === undefined ? {} : { inspection }),
241
+ protectedPaths,
242
+ }, {
243
+ projectRoot,
244
+ sourceAccess,
245
+ ...dependencies.installationOptions,
246
+ });
247
+ }
248
+ export async function executeOrRejectMethod(item, operations, projectRoot) {
249
+ if (!item.method) {
250
+ return;
251
+ }
252
+ const context = { source: item.source, skillPath: item.selection.path, projectRoot };
253
+ if (operations.executeInstallationMethod)
254
+ await operations.executeInstallationMethod(item.method, context);
255
+ else
256
+ await executeSourceInstallationMethod(item.method, context);
257
+ }
258
+ export function externalMethodFailure(error) {
259
+ const detail = error instanceof Error ? error.message : String(error);
260
+ return new Error(`External installation method failed after installation and manifest persistence; ` +
261
+ `installed files were retained because method side effects cannot be rolled back: ${detail}`);
262
+ }
263
+ export async function rollbackTransactions(transactions, original) {
264
+ const rollbackErrors = [];
265
+ for (const transaction of [...transactions].reverse()) {
266
+ try {
267
+ await transaction.rollback();
268
+ }
269
+ catch (error) {
270
+ rollbackErrors.push(error instanceof Error ? error.message : String(error));
271
+ }
272
+ }
273
+ if (rollbackErrors.length > 0) {
274
+ const message = original instanceof Error ? original.message : String(original);
275
+ throw new Error(`${message}; batch rollback failed: ${rollbackErrors.join("; ")}`);
276
+ }
277
+ throw original;
278
+ }
279
+ export function defaultProjectSkillTreeAccess() {
280
+ const access = createSourceContentAccess();
281
+ if (!access.readSkillTree) {
282
+ throw new Error("Configured Source content access cannot read complete Skill trees");
283
+ }
284
+ return {
285
+ readSkillTree: (source, skillPath) => access.readSkillTree(source, skillPath),
286
+ ...(access.readSkillTreeSnapshot === undefined
287
+ ? {}
288
+ : { readSkillTreeSnapshot: (source, skillPath) => access.readSkillTreeSnapshot(source, skillPath) }),
289
+ };
290
+ }
291
+ export function projectSourceFromSource(source, ref, version) {
292
+ if (source.kind === "builtin") {
293
+ if (ref !== undefined) {
294
+ throw new CliUsageError("The built-in Source does not support --ref; its version follows the Agent Depot package");
295
+ }
296
+ return { kind: "builtin", id: source.id };
297
+ }
298
+ const selectedRef = ref ?? (version.policy === "fixed" ? version.version : source.ref);
299
+ return { kind: "external", url: source.url, ...(selectedRef === undefined ? {} : { ref: selectedRef }) };
300
+ }
301
+ export async function findSource(operations, sourceId) {
302
+ const source = (await operations.listSources()).find((candidate) => candidate.id === sourceId);
303
+ if (!source) {
304
+ throw new SourceNotFoundError(sourceId);
305
+ }
306
+ return source;
307
+ }
308
+ /** Plan step: select, reject duplicates, resolve and inspect. Changes nothing. */
309
+ export async function planSingleInstall(request, context) {
310
+ const { source, projectSource, selection } = await selectInstallSource(request, context.operations);
311
+ if (context.existing.some((candidate) => JSON.stringify([candidate.source, candidate.path]) === JSON.stringify([selection.source, selection.path]))) {
312
+ throw new CliUsageError(`Skill selection ${JSON.stringify(selection.path)} is already recorded in ${context.recordedIn}; already managed Skills must be changed with update apply`);
313
+ }
314
+ const { resolvedSource, resolved, installationInspection } = await resolveInstallInspection(selection, source, projectSource, context.root, request, context.operations, context.environment, context.sourceAccess);
315
+ await assertInstallNotAppOwned(request, context, installationInspection);
316
+ return { request, context, selection, source, resolvedSource, resolved, inspection: installationInspection };
317
+ }
318
+ /** Prints the preview the user confirms before {@link executeSingleInstall}. */
319
+ export function outputSingleInstallPreview(plan, output) {
320
+ const { request, context, selection, source, resolved, inspection } = plan;
321
+ outputInstallPreview(selection, source, context.root, output, resolved.files, resolved.method, request.scope, inspection, request.overwrite, request.confirmed);
322
+ }
323
+ /**
324
+ * Execute step: collision checks, install, persist the record (rolling the
325
+ * files back if persisting fails), then run the external method if any.
326
+ */
327
+ export async function executeSingleInstall(plan, persist, output) {
328
+ const { request, context, selection, resolvedSource, resolved, inspection } = plan;
329
+ await assertInstallNotAppOwned(request, context, inspection);
330
+ const protectedPaths = assertNoManagedInstallationOverlap(context.existing, selection, inspection, context.root, request.overwrite);
331
+ rejectInstallationCollision(inspection, request.overwrite);
332
+ const transaction = await installOne(selection, resolvedSource, context.sourceAccess, context.root, context.environment, resolved.method, request.portableV1, resolved.files, resolved.resolvedVersion, request.confirmAdditionalHostExposure, request.overwrite, inspection, protectedPaths);
333
+ try {
334
+ await persist(selectionWithInstallation(selection, transaction.result, context.root));
335
+ }
336
+ catch (error) {
337
+ await rollbackTransactions([transaction], error);
338
+ }
339
+ outputInstallationResult(transaction.result, context.root, output);
340
+ try {
341
+ await executeOrRejectMethod(resolved, context.operations, context.root);
342
+ }
343
+ catch (error) {
344
+ throw externalMethodFailure(error);
345
+ }
346
+ }
347
+ /** A fixed version must be non-empty with no whitespace or control characters. */
348
+ export function isValidFixedVersion(version) {
349
+ return version.length > 0 && !/\s/u.test(version) && !Array.from(version).some((character) => {
350
+ const code = character.codePointAt(0) ?? 0;
351
+ return code <= 0x1f || code === 0x7f;
352
+ });
353
+ }
354
+ /**
355
+ * True when the install would adopt an identical existing Skill but a selected
356
+ * Host location is missing, which the CLI only allows with --confirm-additional-host.
357
+ */
358
+ export function needsAdditionalHostExposure(inspection, hosts) {
359
+ const identical = inspection.locations.filter((location) => location.status === "identical").map((location) => location.path);
360
+ if (identical.length === 0)
361
+ return false;
362
+ const missingClaude = hosts.includes("claude") && !identical.includes(inspection.claudePath);
363
+ const missingCanonical = hosts.some((host) => host !== "claude") && !identical.includes(inspection.canonicalPath);
364
+ return missingClaude || missingCanonical;
365
+ }
366
+ async function assertInstallNotAppOwned(request, context, inspection) {
367
+ if (request.scope !== "user-global")
368
+ return;
369
+ for (const location of inspection.locations) {
370
+ if (location.status === "missing")
371
+ continue;
372
+ await assertSkillNotAppOwned(path.basename(location.path), { ...context.environment, homeDirectory: context.root });
373
+ }
374
+ }
@@ -0,0 +1,119 @@
1
+ import { assertNoOverlappingProjectSkillRemovalTargets, inspectProjectSkillRemoval, removeProjectSkill, } from "./project-installation.js";
2
+ /** Result of resolving a user-supplied `<id|path>` against managed installations. */
3
+ export class SkillSelectionError extends Error {
4
+ }
5
+ /** Resolves ids (exact path or unique final path segment) to managed installations. */
6
+ export function selectUserGlobalSkills(installations, requested) {
7
+ const selected = [];
8
+ for (const value of requested) {
9
+ const exact = installations.filter((selection) => selection.path === value);
10
+ const named = installations.filter((selection) => selection.path.split("/").at(-1) === value);
11
+ const matches = exact.length > 0 ? exact : named;
12
+ if (matches.length > 1) {
13
+ throw new SkillSelectionError(`Ambiguous user-global Skill ${JSON.stringify(value)}; use one of: ${matches.map((selection) => selection.path).join(", ")}`);
14
+ }
15
+ const candidate = matches[0];
16
+ if (!candidate) {
17
+ throw new SkillSelectionError(`Unknown user-global Skill ${JSON.stringify(value)}`);
18
+ }
19
+ if (!selected.includes(candidate))
20
+ selected.push(candidate);
21
+ }
22
+ return Object.freeze(selected);
23
+ }
24
+ /** Builds the options used to inspect and remove user-global Skills under a home directory. */
25
+ export function userGlobalRemovalOptions(homeDirectory, overrides) {
26
+ return {
27
+ projectRoot: homeDirectory,
28
+ sourceAccess: { readSkillTree: async () => [] },
29
+ installationTrust: "user-global-state",
30
+ ...overrides,
31
+ };
32
+ }
33
+ /** Preview lines for one inspected removal, including adopted/modified warnings. */
34
+ export function describeSkillRemoval(selection, inspection, label) {
35
+ const lines = [` remove ${label}${JSON.stringify(selection.path)} at ${inspection.paths.join(", ")}`];
36
+ if (inspection.adopted) {
37
+ lines.push(" WARNING: this Skill was adopted; removal deletes content that Agent Depot did not create");
38
+ }
39
+ if (inspection.modified) {
40
+ lines.push(" WARNING: this Skill is locally modified or has no trusted baseline; removal deletes those changes");
41
+ }
42
+ return lines;
43
+ }
44
+ /**
45
+ * Inspects every selection in order. `onInspected` lets callers stream preview
46
+ * lines while inspecting, so an inspection failure keeps earlier output.
47
+ */
48
+ export async function inspectSkillRemovals(selections, options, onInspected) {
49
+ const inspections = [];
50
+ for (const selection of selections) {
51
+ const inspection = await inspectProjectSkillRemoval(selection, options);
52
+ inspections.push(inspection);
53
+ onInspected?.(selection, inspection);
54
+ }
55
+ return { selections, inspections };
56
+ }
57
+ /** Plan step: inspect, reject overlapping targets, and return preview lines. */
58
+ export async function planSkillRemoval(selections, options, label = "") {
59
+ const preview = [];
60
+ const plan = await inspectSkillRemovals(selections, options, (selection, inspection) => {
61
+ preview.push(...describeSkillRemoval(selection, inspection, label));
62
+ });
63
+ assertNoOverlappingProjectSkillRemovalTargets(plan.inspections);
64
+ return { ...plan, preview };
65
+ }
66
+ /** Re-inspects every target before the first deletion so a batch is never partially removed. */
67
+ export async function recheckSkillRemoval(plan, options) {
68
+ const rechecked = await inspectSkillRemovals(plan.selections, options);
69
+ assertNoOverlappingProjectSkillRemovalTargets(rechecked.inspections);
70
+ return rechecked;
71
+ }
72
+ export function assertRemovalInspectionUnchanged(before, after) {
73
+ const unchanged = before.digest === after.digest && before.adopted === after.adopted && before.modified === after.modified &&
74
+ before.skillName === after.skillName && before.paths.length === after.paths.length &&
75
+ before.paths.every((candidate, pathIndex) => candidate === after.paths[pathIndex]);
76
+ if (!unchanged) {
77
+ throw new Error("The inspected Skill removal target changed before deletion; no path was removed");
78
+ }
79
+ }
80
+ export function assertManagedInstallationRecordsUnchanged(expected, actual) {
81
+ const expectedKey = expected.map((selection) => JSON.stringify(selection)).sort().join("\n");
82
+ const actualKey = actual.map((selection) => JSON.stringify(selection)).sort().join("\n");
83
+ if (expectedKey !== actualKey) {
84
+ throw new Error("Managed Skill installation records changed before deletion; no path was removed");
85
+ }
86
+ }
87
+ /** Deletes the files of every rechecked selection; records are left to the caller. */
88
+ export async function removeSkillFiles(plan, rechecked, options) {
89
+ for (const [index, selection] of plan.selections.entries()) {
90
+ assertRemovalInspectionUnchanged(plan.inspections[index], rechecked.inspections[index]);
91
+ await removeProjectSkill(selection, rechecked.inspections[index], options);
92
+ }
93
+ }
94
+ /**
95
+ * Removes each Skill's files, then its installation record, verifying the
96
+ * managed records are unchanged before each deletion. Returns the reconciled
97
+ * records so callers can continue with further checks.
98
+ */
99
+ export async function removeSkillsAndRecords(plan, rechecked, operations, options, initialManaged) {
100
+ const list = operations.listUserGlobalInstallations;
101
+ const reconcile = operations.removeUserGlobalInstallations;
102
+ if (!list || !reconcile) {
103
+ throw new Error("Configured Source operations cannot reconcile removed user-global Skills");
104
+ }
105
+ let expected = initialManaged;
106
+ for (const [index, selection] of plan.selections.entries()) {
107
+ assertRemovalInspectionUnchanged(plan.inspections[index], rechecked.inspections[index]);
108
+ assertManagedInstallationRecordsUnchanged(expected, await list.call(operations));
109
+ await removeProjectSkill(selection, rechecked.inspections[index], options);
110
+ await reconcile.call(operations, [selection]);
111
+ expected = await list.call(operations);
112
+ }
113
+ return expected;
114
+ }
115
+ /** Execute step: recheck, then remove files and records. */
116
+ export async function executeSkillRemoval(plan, operations, options, initialManaged) {
117
+ const rechecked = await recheckSkillRemoval(plan, options);
118
+ return removeSkillsAndRecords(plan, rechecked, operations, options, initialManaged);
119
+ }