@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,467 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { lstat, readdir, readFile } from "node:fs/promises";
4
+ import { assertNoSymlinkPath } from "./path-safety.js";
5
+ import { fileURLToPath } from "node:url";
6
+ import path from "node:path";
7
+ import { defaultGitSourceCachePath, GitSourceSnapshotAccess, SKILL_TREE_LIMITS, validateSkillDirectoryPath, } from "./git-source.js";
8
+ import { AGENT_DEPOT_PACKAGE_VERSION, } from "./project-manifest.js";
9
+ export const EXCLUDED_LIFECYCLE_SEGMENTS = Object.freeze([
10
+ "in-progress",
11
+ "beta",
12
+ "deprecated",
13
+ "retired",
14
+ ]);
15
+ const excludedLifecycleSegments = new Set(EXCLUDED_LIFECYCLE_SEGMENTS);
16
+ /**
17
+ * The package-owned root used for built-in skills. A missing root is an empty
18
+ * built-in Source, which lets packages ship no built-in skills yet.
19
+ */
20
+ export function defaultBuiltInSkillsRoot() {
21
+ return path.join(findPackageRoot(path.dirname(fileURLToPath(import.meta.url))), "skills");
22
+ }
23
+ /** Walks up from a module directory (src/ or dist/src/) to the nearest package.json. */
24
+ function findPackageRoot(startDirectory) {
25
+ let current = startDirectory;
26
+ for (;;) {
27
+ if (existsSync(path.join(current, "package.json"))) {
28
+ return current;
29
+ }
30
+ const parent = path.dirname(current);
31
+ if (parent === current) {
32
+ return path.resolve(startDirectory, "..");
33
+ }
34
+ current = parent;
35
+ }
36
+ }
37
+ /** Default read-only Source access used by the application operation. */
38
+ export class NodeSourceContentAccess {
39
+ builtInRoot;
40
+ readFile;
41
+ readBinaryFile;
42
+ gitSnapshots;
43
+ constructor(options = {}) {
44
+ this.builtInRoot = options.builtInRoot ?? defaultBuiltInSkillsRoot();
45
+ this.readFile = options.readFile ?? ((filePath) => readFile(filePath, "utf8"));
46
+ this.readBinaryFile = options.readBinaryFile ?? ((filePath) => readFile(filePath));
47
+ this.gitSnapshots = new GitSourceSnapshotAccess({
48
+ cachePath: options.gitCachePath ?? defaultGitSourceCachePath(),
49
+ });
50
+ }
51
+ async readSnapshot(source) {
52
+ if (source.kind === "builtin") {
53
+ return readDirectorySnapshot(this.builtInRoot, this.readFile);
54
+ }
55
+ return this.gitSnapshots.readSnapshot(source);
56
+ }
57
+ async readSkillTree(source, skillPath) {
58
+ return (await this.readSkillTreeSnapshot(source, skillPath)).files;
59
+ }
60
+ async readSkillTreeSnapshot(source, skillPath) {
61
+ const selectedPath = validateSkillDirectoryPath(skillPath);
62
+ if (source.kind === "builtin") {
63
+ return Object.freeze({
64
+ files: await readSelectedSkillDirectory(this.builtInRoot, selectedPath, this.readBinaryFile),
65
+ ...(AGENT_DEPOT_PACKAGE_VERSION === undefined
66
+ ? {}
67
+ : { resolvedVersion: Object.freeze({ kind: "builtin-package", version: AGENT_DEPOT_PACKAGE_VERSION }) }),
68
+ });
69
+ }
70
+ const snapshot = await this.gitSnapshots.readSkillTreeSnapshot(source, selectedPath);
71
+ return Object.freeze({
72
+ files: snapshot.files,
73
+ resolvedVersion: Object.freeze({ kind: "git-commit", commit: snapshot.resolvedVersion }),
74
+ });
75
+ }
76
+ async readResolvedVersion(source) {
77
+ if (source.kind === "builtin") {
78
+ return AGENT_DEPOT_PACKAGE_VERSION === undefined
79
+ ? undefined
80
+ : Object.freeze({ kind: "builtin-package", version: AGENT_DEPOT_PACKAGE_VERSION });
81
+ }
82
+ return Object.freeze({
83
+ kind: "git-commit",
84
+ commit: await this.gitSnapshots.readResolvedCommit(source),
85
+ });
86
+ }
87
+ }
88
+ export function createSourceContentAccess(options = {}) {
89
+ return new NodeSourceContentAccess(options);
90
+ }
91
+ /**
92
+ * Creates a deterministic content baseline for a safely captured Skill tree.
93
+ * Paths and bytes are included; executable mode is intentionally excluded so
94
+ * adoption and update checks use the same content identity across platforms.
95
+ */
96
+ export function skillTreeBaseline(files) {
97
+ const sorted = [...files].sort((left, right) => Buffer.compare(Buffer.from(left.path, "utf8"), Buffer.from(right.path, "utf8")));
98
+ const seen = new Set();
99
+ const hash = createHash("sha256");
100
+ hash.update("agent-depot-skill-tree-v1\0", "utf8");
101
+ for (const file of sorted) {
102
+ if (seen.has(file.path)) {
103
+ throw new Error(`Skill tree contains a duplicate path: ${file.path}`);
104
+ }
105
+ seen.add(file.path);
106
+ const pathBytes = Buffer.from(file.path, "utf8");
107
+ hash.update(`${pathBytes.byteLength}:`, "utf8");
108
+ hash.update(pathBytes);
109
+ hash.update(`${file.content.byteLength}:`, "utf8");
110
+ hash.update(file.content);
111
+ hash.update("\0", "utf8");
112
+ }
113
+ return Object.freeze({ algorithm: "sha256", digest: hash.digest("hex") });
114
+ }
115
+ /** Discovers candidates from explicitly selected Sources in selection order. */
116
+ export async function discoverSkillsFromSources(sources, contentAccess) {
117
+ const candidates = [];
118
+ for (const source of sources) {
119
+ const files = [...await contentAccess.readSnapshot(source)].sort((left, right) => left.path.localeCompare(right.path));
120
+ for (const file of files) {
121
+ const normalizedPath = normalizeSourcePath(file.path);
122
+ if (!normalizedPath || path.posix.basename(normalizedPath) !== "SKILL.md") {
123
+ continue;
124
+ }
125
+ const directoryPath = path.posix.dirname(normalizedPath);
126
+ const segments = directoryPath === "." ? [] : directoryPath.split("/");
127
+ if (segments.some((segment) => excludedLifecycleSegments.has(segment))) {
128
+ continue;
129
+ }
130
+ const metadata = parseSkillFrontmatter(file.content);
131
+ if (!metadata) {
132
+ continue;
133
+ }
134
+ candidates.push({
135
+ sourceId: source.id,
136
+ path: directoryPath,
137
+ name: metadata.name,
138
+ description: metadata.description,
139
+ });
140
+ }
141
+ }
142
+ return Object.freeze(candidates);
143
+ }
144
+ /** Collects scalar frontmatter values up to the closing delimiter; undefined when malformed or unclosed. */
145
+ function readFrontmatterValues(lines) {
146
+ const values = new Map();
147
+ let index = 1;
148
+ while (index < lines.length) {
149
+ const line = lines[index];
150
+ if (/^(?:---|\.\.\.)\s*$/u.test(line)) {
151
+ return values;
152
+ }
153
+ if (line.trim() === "" || /^\s*#/u.test(line)) {
154
+ index += 1;
155
+ continue;
156
+ }
157
+ const next = readFrontmatterEntry(lines, index, values);
158
+ if (next === undefined) {
159
+ return undefined;
160
+ }
161
+ index = next;
162
+ }
163
+ return undefined;
164
+ }
165
+ /** Reads one top-level entry starting at index and returns the next index, or undefined when malformed. */
166
+ function readFrontmatterEntry(lines, index, values) {
167
+ const line = lines[index];
168
+ const match = /^(?<key>[A-Za-z][A-Za-z0-9_-]*)\s*:\s*(?<value>.*)$/u.exec(line);
169
+ if (!match || line.startsWith(" ") || line.startsWith("\t")) {
170
+ return undefined;
171
+ }
172
+ const key = match.groups?.key;
173
+ const rawValue = match.groups?.value ?? "";
174
+ if (!key) {
175
+ return undefined;
176
+ }
177
+ if (key === "metadata" && rawValue.trim() === "") {
178
+ return consumeNestedMapping(lines, index + 1);
179
+ }
180
+ if (/^[|>][+-]?\d*\s*$/u.test(rawValue)) {
181
+ const block = [];
182
+ let next = index + 1;
183
+ while (next < lines.length) {
184
+ const blockLine = lines[next];
185
+ if (blockLine.trim() !== "" && !/^[ \t]/u.test(blockLine)) {
186
+ break;
187
+ }
188
+ block.push(blockLine);
189
+ next += 1;
190
+ }
191
+ values.set(key, normalizeBlockScalar(block, rawValue.startsWith(">")));
192
+ return next;
193
+ }
194
+ const scalar = parseScalar(rawValue);
195
+ if (scalar === undefined) {
196
+ return undefined;
197
+ }
198
+ values.set(key, scalar);
199
+ return index + 1;
200
+ }
201
+ /** Parses the required YAML frontmatter fields without evaluating arbitrary YAML. */
202
+ export function parseSkillFrontmatter(content) {
203
+ const lines = content.replace(/^\uFEFF/u, "").replace(/\r\n?/gu, "\n").split("\n");
204
+ if (!/^---\s*$/u.test(lines[0] ?? "")) {
205
+ return undefined;
206
+ }
207
+ const values = readFrontmatterValues(lines);
208
+ if (!values) {
209
+ return undefined;
210
+ }
211
+ const nameValue = values.get("name");
212
+ const descriptionValue = values.get("description");
213
+ const name = typeof nameValue === "string" ? nameValue.trim() : undefined;
214
+ const description = typeof descriptionValue === "string" ? descriptionValue.trim() : undefined;
215
+ if (!name || !description) {
216
+ return undefined;
217
+ }
218
+ return { name, description };
219
+ }
220
+ async function readSelectedSkillDirectory(root, selectedPath, readBinaryFile) {
221
+ const rootKind = await safePathKind(root);
222
+ if (rootKind !== "directory") {
223
+ throw new Error(`Built-in Source root is not a directory: ${root}`);
224
+ }
225
+ await assertNoSymlinkPath(root, "Selected Skill path");
226
+ const selectedDirectory = path.join(root, ...selectedPath.split("/"));
227
+ if ((await safePathKind(selectedDirectory)) !== "directory") {
228
+ throw new Error(`Selected Skill directory is not available: ${selectedPath}`);
229
+ }
230
+ await assertNoSymlinkPath(selectedDirectory, "Selected Skill path");
231
+ const files = [];
232
+ let totalBytes = 0;
233
+ let hasSkillManifest = false;
234
+ await walkSelectedSkillDirectory(root, selectedDirectory, selectedPath, files, readBinaryFile, (byteLength, relativePath) => {
235
+ if (byteLength > SKILL_TREE_LIMITS.maxFileBytes) {
236
+ throw new Error(`Selected Skill file exceeds the safe size limit: ${relativePath}`);
237
+ }
238
+ }, (byteLength) => {
239
+ totalBytes += byteLength;
240
+ if (totalBytes > SKILL_TREE_LIMITS.maxTotalBytes) {
241
+ throw new Error("Selected Skill tree exceeds the safe total size limit");
242
+ }
243
+ }, () => {
244
+ if (files.length >= SKILL_TREE_LIMITS.maxFiles) {
245
+ throw new Error("Selected Skill tree contains too many files");
246
+ }
247
+ }, (relativePath) => {
248
+ if (relativePath === `${selectedPath}/SKILL.md`) {
249
+ hasSkillManifest = true;
250
+ }
251
+ });
252
+ if (!hasSkillManifest) {
253
+ throw new Error(`Selected Skill directory does not contain ${selectedPath}/SKILL.md`);
254
+ }
255
+ files.sort((left, right) => left.path.localeCompare(right.path));
256
+ return Object.freeze(files);
257
+ }
258
+ async function walkSelectedSkillDirectory(root, absoluteDirectory, relativeDirectory, files, readBinaryFile, assertFileSize, assertTotalBytes, assertFileCount, observeFile) {
259
+ await assertNoSymlinkPath(absoluteDirectory, "Selected Skill path");
260
+ const entries = await readdir(absoluteDirectory, { withFileTypes: true });
261
+ entries.sort((left, right) => left.name.localeCompare(right.name));
262
+ for (const entry of entries) {
263
+ const relativePath = `${relativeDirectory}/${entry.name}`;
264
+ try {
265
+ validateSkillDirectoryPath(relativePath);
266
+ }
267
+ catch {
268
+ throw new Error(`Selected Skill tree contains an unsafe path: ${relativePath}`);
269
+ }
270
+ const absolutePath = path.join(root, ...relativePath.split("/"));
271
+ if (entry.isSymbolicLink()) {
272
+ throw new Error(`Selected Skill tree contains a symbolic link: ${relativePath}`);
273
+ }
274
+ if (entry.isDirectory()) {
275
+ if ((await safePathKind(absolutePath)) !== "directory") {
276
+ throw new Error(`Selected Skill tree entry changed while reading: ${relativePath}`);
277
+ }
278
+ await walkSelectedSkillDirectory(root, absolutePath, relativePath, files, readBinaryFile, assertFileSize, assertTotalBytes, assertFileCount, observeFile);
279
+ continue;
280
+ }
281
+ if (!entry.isFile()) {
282
+ throw new Error(`Selected Skill tree contains an unsupported file: ${relativePath}`);
283
+ }
284
+ assertFileCount();
285
+ await assertNoSymlinkPath(absolutePath, "Selected Skill path");
286
+ const information = await lstat(absolutePath);
287
+ if (!information.isFile()) {
288
+ throw new Error(`Selected Skill tree entry changed while reading: ${relativePath}`);
289
+ }
290
+ assertFileSize(information.size, relativePath);
291
+ const content = await readBinaryFile(absolutePath);
292
+ const byteLength = content.byteLength;
293
+ assertFileSize(byteLength, relativePath);
294
+ assertTotalBytes(byteLength);
295
+ observeFile(relativePath);
296
+ files.push({ path: relativePath, content, executable: (information.mode & 0o111) !== 0 });
297
+ }
298
+ }
299
+ async function readDirectorySnapshot(root, readTextFile) {
300
+ const rootKind = await safePathKind(root);
301
+ if (rootKind === "missing") {
302
+ return Object.freeze([]);
303
+ }
304
+ if (rootKind !== "directory") {
305
+ throw new Error(`Built-in Source root is not a directory: ${root}`);
306
+ }
307
+ const files = [];
308
+ await walkDirectory(root, "", files, readTextFile);
309
+ return Object.freeze(files);
310
+ }
311
+ async function walkDirectory(root, relativeDirectory, files, readTextFile) {
312
+ const absoluteDirectory = relativeDirectory ? path.join(root, relativeDirectory) : root;
313
+ const entries = await readdir(absoluteDirectory, { withFileTypes: true });
314
+ entries.sort((left, right) => left.name.localeCompare(right.name));
315
+ for (const entry of entries) {
316
+ const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name;
317
+ const absolutePath = path.join(root, relativePath);
318
+ if (entry.isSymbolicLink()) {
319
+ continue;
320
+ }
321
+ if (entry.isDirectory()) {
322
+ if (excludedLifecycleSegments.has(entry.name)) {
323
+ continue;
324
+ }
325
+ await walkDirectory(root, relativePath, files, readTextFile);
326
+ continue;
327
+ }
328
+ if (!entry.isFile() || entry.name !== "SKILL.md" || (await safePathKind(absolutePath)) !== "file") {
329
+ continue;
330
+ }
331
+ files.push({
332
+ path: relativePath.split(path.sep).join("/"),
333
+ content: await readTextFile(absolutePath),
334
+ });
335
+ }
336
+ }
337
+ async function safePathKind(candidate) {
338
+ try {
339
+ const information = await lstat(candidate);
340
+ if (information.isSymbolicLink()) {
341
+ return "symlink";
342
+ }
343
+ if (information.isDirectory()) {
344
+ return "directory";
345
+ }
346
+ return "file";
347
+ }
348
+ catch (error) {
349
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
350
+ return "missing";
351
+ }
352
+ throw error;
353
+ }
354
+ }
355
+ function normalizeSourcePath(candidate) {
356
+ if (typeof candidate !== "string" || candidate.length === 0 || candidate.includes("\\")) {
357
+ return undefined;
358
+ }
359
+ const normalized = path.posix.normalize(candidate);
360
+ if (normalized === "." || normalized.startsWith("../") || normalized.startsWith("/")) {
361
+ return undefined;
362
+ }
363
+ return normalized;
364
+ }
365
+ const MAX_NESTED_MAPPING_DEPTH = 32;
366
+ function consumeNestedMapping(lines, start, depth = 0) {
367
+ if (depth > MAX_NESTED_MAPPING_DEPTH) {
368
+ return undefined;
369
+ }
370
+ let index = start;
371
+ let indentation;
372
+ let hasEntry = false;
373
+ while (index < lines.length) {
374
+ const line = lines[index];
375
+ if (line.trim() === "" || /^\s*#/u.test(line)) {
376
+ index += 1;
377
+ continue;
378
+ }
379
+ if (/^(?:---|\.\.\.)\s*$/u.test(line) || !/^\s/u.test(line)) {
380
+ break;
381
+ }
382
+ if (/^\t/u.test(line)) {
383
+ return undefined;
384
+ }
385
+ const lineIndentation = line.match(/^ */u)?.[0].length ?? 0;
386
+ indentation ??= lineIndentation;
387
+ if (lineIndentation !== indentation) {
388
+ return undefined;
389
+ }
390
+ const next = consumeNestedEntry(lines, index, indentation, depth);
391
+ if (next === undefined) {
392
+ return undefined;
393
+ }
394
+ index = next;
395
+ hasEntry = true;
396
+ }
397
+ return hasEntry ? index : undefined;
398
+ }
399
+ /** Consumes one nested mapping entry (scalar, block scalar or child mapping); undefined when malformed. */
400
+ function consumeNestedEntry(lines, index, indentation, depth) {
401
+ const match = /^ +(?<key>[A-Za-z][A-Za-z0-9_-]*)\s*:\s*(?<value>.*)$/u.exec(lines[index]);
402
+ if (!match) {
403
+ return undefined;
404
+ }
405
+ const rawValue = match.groups?.value ?? "";
406
+ if (rawValue === "") {
407
+ return consumeNestedMapping(lines, index + 1, depth + 1);
408
+ }
409
+ if (/^[|>][+-]?\d*\s*$/u.test(rawValue)) {
410
+ return consumeNestedBlockScalar(lines, index + 1, indentation);
411
+ }
412
+ return parseScalar(rawValue) === undefined ? undefined : index + 1;
413
+ }
414
+ function consumeNestedBlockScalar(lines, start, indentation) {
415
+ let index = start;
416
+ while (index < lines.length) {
417
+ const blockLine = lines[index];
418
+ if (blockLine.trim() !== "" && (!/^\s/u.test(blockLine) || (blockLine.match(/^ */u)?.[0].length ?? 0) <= indentation)) {
419
+ break;
420
+ }
421
+ if (/^\t/u.test(blockLine)) {
422
+ return undefined;
423
+ }
424
+ index += 1;
425
+ }
426
+ return index;
427
+ }
428
+ function parseScalar(rawValue) {
429
+ const value = rawValue.trim();
430
+ if (value.startsWith("\"")) {
431
+ if (!value.endsWith("\"") || value.length < 2) {
432
+ return undefined;
433
+ }
434
+ try {
435
+ const parsed = JSON.parse(value);
436
+ return typeof parsed === "string" ? parsed : undefined;
437
+ }
438
+ catch {
439
+ return undefined;
440
+ }
441
+ }
442
+ if (value.startsWith("'")) {
443
+ if (!value.endsWith("'") || value.length < 2) {
444
+ return undefined;
445
+ }
446
+ return value.slice(1, -1).replace(/''/gu, "'");
447
+ }
448
+ const plain = value.replace(/\s+#.*$/u, "").trim();
449
+ if (!plain || /^(?:null|~|\[|\{|\]|\})$/iu.test(plain)) {
450
+ return undefined;
451
+ }
452
+ if (/^(?:true|false)$/iu.test(plain)) {
453
+ return plain.toLowerCase() === "true";
454
+ }
455
+ return plain;
456
+ }
457
+ function normalizeBlockScalar(lines, folded) {
458
+ const nonEmpty = lines.filter((line) => line.trim() !== "");
459
+ const indentation = nonEmpty.length === 0
460
+ ? 0
461
+ : Math.min(...nonEmpty.map((line) => line.match(/^[ \t]*/u)?.[0].length ?? 0));
462
+ const normalized = lines.map((line) => line.slice(indentation));
463
+ if (!folded) {
464
+ return normalized.join("\n").trim();
465
+ }
466
+ return normalized.join("\n").replace(/\n(?=\S)/gu, " ").trim();
467
+ }
@@ -0,0 +1,103 @@
1
+ import path from "node:path";
2
+ import { addProjectSkillHosts, inspectProjectSkillHostAddition, inspectProjectSkillRemoval, removeProjectSkill, } from "./project-installation.js";
3
+ import { assertManagedInstallationRecordsUnchanged, assertRemovalInspectionUnchanged, executeSkillRemoval, planSkillRemoval, SkillSelectionError, } from "./skill-removal.js";
4
+ function describe(error) {
5
+ return error instanceof Error ? error.message : String(error);
6
+ }
7
+ function uniqueHosts(hosts, verb) {
8
+ const unique = [...new Set(hosts)];
9
+ if (unique.length === 0)
10
+ throw new SkillSelectionError(`Name at least one Host to ${verb}`);
11
+ return unique;
12
+ }
13
+ /** Plan step: validates the Hosts, inspects the installation and previews the new locations. */
14
+ export async function planHostAddition(selection, hosts, options) {
15
+ const added = uniqueHosts(hosts, "add");
16
+ const present = added.filter((host) => selection.hosts.includes(host));
17
+ if (present.length > 0) {
18
+ throw new SkillSelectionError(`Skill ${JSON.stringify(selection.path)} is already exposed to ${present.join(", ")}`);
19
+ }
20
+ const inspection = await inspectProjectSkillHostAddition(selection, added, options);
21
+ const preview = [` add hosts ${added.join(", ")} to ${JSON.stringify(selection.path)} (installed content and version are unchanged)`];
22
+ for (const location of inspection.createPaths) {
23
+ preview.push(` create ${location} as a symlink to ${inspection.canonicalPath}`);
24
+ }
25
+ if (inspection.createPaths.length === 0) {
26
+ preview.push(` no files change: ${inspection.canonicalPath} already serves these hosts`);
27
+ }
28
+ preview.push(" ADDITIONAL HOST EXPOSURE: this exposes the installed Skill to more hosts and needs explicit confirmation");
29
+ const updated = { ...selection, hosts: [...selection.hosts, ...added] };
30
+ return { selection, hosts: added, updated, inspection, preview };
31
+ }
32
+ /** Execute step: needs the additional-host confirmation, creates the locations, then updates the record. */
33
+ export async function executeHostAddition(plan, operations, options, initialManaged, confirmAdditionalHostExposure) {
34
+ if (!confirmAdditionalHostExposure) {
35
+ throw new Error("Adding a Host is an additional Host exposure and needs explicit confirmation; no path was changed");
36
+ }
37
+ const { listUserGlobalInstallations: list, updateUserGlobalInstallation: update } = operations;
38
+ if (!list || !update)
39
+ throw new Error("Configured Source operations cannot update user-global Skills");
40
+ assertManagedInstallationRecordsUnchanged(initialManaged, await list.call(operations));
41
+ const transaction = await addProjectSkillHosts(plan.selection, plan.hosts, plan.inspection, options);
42
+ try {
43
+ await update.call(operations, plan.updated);
44
+ }
45
+ catch (error) {
46
+ try {
47
+ await transaction.rollback();
48
+ }
49
+ catch (rollbackError) {
50
+ throw new AggregateError([error, rollbackError], `Updating the installation record failed (${describe(error)}) and rolling back the new Host locations also failed (${describe(rollbackError)}); check the Claude location by hand`);
51
+ }
52
+ throw error;
53
+ }
54
+ }
55
+ /** Plan step: removing every recorded Host is a full removal; otherwise only the removed Hosts' own locations go. */
56
+ export async function planHostRemoval(selection, hosts, options) {
57
+ const removed = uniqueHosts(hosts, "remove");
58
+ const unknown = removed.filter((host) => !selection.hosts.includes(host));
59
+ if (unknown.length > 0) {
60
+ throw new SkillSelectionError(`Host ${unknown.join(", ")} is not recorded for this Skill; recorded hosts: ${selection.hosts.join(", ")}`);
61
+ }
62
+ const remaining = selection.hosts.filter((host) => !removed.includes(host));
63
+ if (remaining.length === 0) {
64
+ return { kind: "full", plan: await planSkillRemoval([selection], options) };
65
+ }
66
+ const inspection = await inspectProjectSkillRemoval(selection, options);
67
+ const claudePath = path.join(path.resolve(options.projectRoot), ".claude", "skills", inspection.skillName);
68
+ const canonicalPath = path.join(path.resolve(options.projectRoot), ".agents", "skills", inspection.skillName);
69
+ if (removed.includes("claude") && inspection.paths[0] === claudePath) {
70
+ throw new SkillSelectionError(`Cannot remove Host claude: ${claudePath} is the only copy of this Skill and the remaining hosts (${remaining.join(", ")}) need it; remove the whole Skill instead`);
71
+ }
72
+ const deletePaths = removed.includes("claude") && inspection.paths.includes(claudePath) ? [claudePath] : [];
73
+ const preview = [` remove hosts ${removed.join(", ")} from ${JSON.stringify(selection.path)}; remaining hosts: ${remaining.join(", ")}`];
74
+ if (deletePaths.length > 0) {
75
+ preview.push(` delete ${claudePath} (managed Claude symlink)`, ` keep ${canonicalPath} (serves the remaining hosts)`);
76
+ }
77
+ else {
78
+ preview.push(` no files are deleted: ${inspection.paths[0]} is kept for the remaining hosts`);
79
+ }
80
+ return { kind: "hosts", selection, removed, remaining, inspection, deletePaths, preview };
81
+ }
82
+ /** Execute step: rechecks, deletes only the removed Hosts' locations, then updates the record. */
83
+ export async function executeHostRemoval(plan, operations, options, initialManaged) {
84
+ if (plan.kind === "full") {
85
+ await executeSkillRemoval(plan.plan, operations, options, initialManaged);
86
+ return;
87
+ }
88
+ const { listUserGlobalInstallations: list, updateUserGlobalInstallation: update } = operations;
89
+ if (!list || !update)
90
+ throw new Error("Configured Source operations cannot update user-global Skills");
91
+ assertRemovalInspectionUnchanged(plan.inspection, await inspectProjectSkillRemoval(plan.selection, options));
92
+ assertManagedInstallationRecordsUnchanged(initialManaged, await list.call(operations));
93
+ await removeProjectSkill(plan.selection, plan.inspection, options, plan.deletePaths);
94
+ try {
95
+ await update.call(operations, { ...plan.selection, hosts: plan.remaining });
96
+ }
97
+ catch (error) {
98
+ if (plan.deletePaths.length === 0)
99
+ throw error;
100
+ throw new Error(`Deleted ${plan.deletePaths.join(", ")} but the installation record was not updated (${describe(error)}); the record still lists ${plan.removed.join(", ")}. ` +
101
+ `Rerun \`agent-depot skill remove ${JSON.stringify(plan.selection.path)} --host ${plan.removed.join(" --host ")}\` to reconcile the record; the missing location is tolerated.`, { cause: error });
102
+ }
103
+ }