@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,511 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
4
+ import path from "node:path";
5
+ import { canonicalizeGitSourceUrl, validateGitRef } from "./git-source.js";
6
+ export const PROJECT_MANIFEST_VERSION = 1;
7
+ export const PROJECT_MANIFEST_FILENAME = "agent-depot.json";
8
+ /** The package release that supplies the built-in Source, when it can be read. */
9
+ export const AGENT_DEPOT_PACKAGE_VERSION = readAgentDepotPackageVersion();
10
+ /** The published package name, read from package.json so it is never hard-coded twice. */
11
+ export const AGENT_DEPOT_PACKAGE_NAME = readAgentDepotPackageField("name");
12
+ export const PROJECT_HOSTS = Object.freeze(["pi", "claude", "codex", "opencode"]);
13
+ const EMPTY_PROJECT_MANIFEST = Object.freeze({
14
+ version: PROJECT_MANIFEST_VERSION,
15
+ skills: Object.freeze([]),
16
+ });
17
+ export class ProjectManifestError extends Error {
18
+ manifestPath;
19
+ constructor(manifestPath, reason) {
20
+ super(`Invalid project manifest ${JSON.stringify(manifestPath)}: ${reason}`);
21
+ this.name = "ProjectManifestError";
22
+ this.manifestPath = manifestPath;
23
+ }
24
+ }
25
+ function isRecord(value) {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
+ }
28
+ function containsControlCharacter(value) {
29
+ return Array.from(value).some((character) => {
30
+ const code = character.codePointAt(0) ?? 0;
31
+ return code <= 0x1f || code === 0x7f;
32
+ });
33
+ }
34
+ function assertKeys(value, allowed, label, manifestPath) {
35
+ const allowedKeys = new Set(allowed);
36
+ const unsupported = Object.keys(value).find((key) => !allowedKeys.has(key));
37
+ if (unsupported) {
38
+ throw new ProjectManifestError(manifestPath, `${label} contains unsupported field ${JSON.stringify(unsupported)}`);
39
+ }
40
+ }
41
+ function nonEmptyString(value, label, manifestPath) {
42
+ if (typeof value !== "string" || value.length === 0 || value.trim() !== value) {
43
+ throw new ProjectManifestError(manifestPath, `${label} must be a non-empty string without surrounding whitespace`);
44
+ }
45
+ if (/\s/u.test(value) || containsControlCharacter(value)) {
46
+ throw new ProjectManifestError(manifestPath, `${label} contains unsafe whitespace or control characters`);
47
+ }
48
+ return value;
49
+ }
50
+ function normalizeRelativePath(value, label, manifestPath, boundary = "its Source") {
51
+ const candidate = nonEmptyString(value, label, manifestPath);
52
+ if (candidate.includes("\\")) {
53
+ throw new ProjectManifestError(manifestPath, `${label} must use POSIX separators`);
54
+ }
55
+ const normalized = path.posix.normalize(candidate);
56
+ if (normalized.startsWith("/") || normalized === ".." || normalized.startsWith("../")) {
57
+ throw new ProjectManifestError(manifestPath, `${label} must stay within ${boundary}`);
58
+ }
59
+ return normalized;
60
+ }
61
+ function normalizeSkillPath(value, label, manifestPath) {
62
+ const normalized = normalizeRelativePath(value, label, manifestPath);
63
+ if (normalized === ".") {
64
+ throw new ProjectManifestError(manifestPath, `${label} must identify a skill below its Source root`);
65
+ }
66
+ return normalized;
67
+ }
68
+ /**
69
+ * Validates the shared command shape used by Source metadata and user configuration.
70
+ * The caller supplies the error type so Source adapters and manifests can report
71
+ * failures in their own domain without maintaining divergent safety rules.
72
+ */
73
+ export function parsePortableInstallationMethod(value, options = {}) {
74
+ const label = options.label ?? "installation method";
75
+ const fail = (reason) => {
76
+ throw (options.error ?? ((message) => new Error(message)))(`${label} ${reason}`);
77
+ };
78
+ if (!isRecord(value)) {
79
+ return fail("must be an object");
80
+ }
81
+ assertKeysForMethod(value, ["kind", "argv", "cwd"], label, options);
82
+ if (value.kind !== "command" || !Array.isArray(value.argv) || value.argv.length === 0 ||
83
+ value.argv.some((part) => typeof part !== "string" || part.length === 0 || containsControlCharacter(part))) {
84
+ return fail("must define a non-empty argv array without control characters");
85
+ }
86
+ const argv = value.argv;
87
+ for (const rule of ARGV_RULES) {
88
+ const reason = rule(argv);
89
+ if (reason !== undefined) {
90
+ return fail(reason);
91
+ }
92
+ }
93
+ if (value.cwd !== undefined && isUnsafeMethodCwd(value.cwd)) {
94
+ return fail("cwd is unsafe");
95
+ }
96
+ return Object.freeze({
97
+ kind: "command",
98
+ argv: Object.freeze([...argv]),
99
+ ...(value.cwd === undefined ? {} : { cwd: value.cwd }),
100
+ });
101
+ }
102
+ const CREDENTIAL_FLAG = /^(?:--?)(?:access[-_]?key|access[-_]?token|api[-_]?key|auth(?:orization)?|auth[-_]?token|client[-_]?secret|credential|oauth[-_]?token|pass(?:word|wd)?|private[-_]?key|refresh[-_]?token|secret|secret[-_]?key|token)(?:$|[=:])/iu;
103
+ /** Ordered rejection rules for a validated argv; the first rule that returns a reason names the failure. */
104
+ const ARGV_RULES = [
105
+ ([executable]) => !/^[A-Za-z0-9._+-]+$/u.test(executable) || isShellInterpreter(executable) ||
106
+ /\.(?:bat|cmd|com|exe|ps1)$/iu.test(executable)
107
+ ? "executable is not a safe portable no-shell command name"
108
+ : undefined,
109
+ (argv) => argv.some((part) => /[;&|<>$`]/u.test(part)) ? "contains shell syntax" : undefined,
110
+ // Evaluated per argument, in order, so the first offending argument decides the reason.
111
+ (argv) => argv.map((part, index) => {
112
+ if (containsObviousCredential(part))
113
+ return "contains an obvious credential or environment assignment";
114
+ return CREDENTIAL_FLAG.test(part) || (index > 0 && CREDENTIAL_FLAG.test(argv[index - 1] ?? ""))
115
+ ? "contains a credential-style argument"
116
+ : undefined;
117
+ }).find((reason) => reason !== undefined),
118
+ ];
119
+ function isUnsafeMethodCwd(cwd) {
120
+ return typeof cwd !== "string" || cwd.length === 0 || containsControlCharacter(cwd) ||
121
+ path.posix.isAbsolute(cwd) || /^[A-Za-z]:/u.test(cwd) || cwd === ".." || cwd.startsWith("../") ||
122
+ cwd.includes("\\") || containsObviousCredential(cwd);
123
+ }
124
+ function assertKeysForMethod(value, allowed, label, options) {
125
+ const unsupported = Object.keys(value).find((key) => !allowed.includes(key));
126
+ if (unsupported) {
127
+ throw (options.error ?? ((message) => new Error(message)))(`${label} contains unsupported field ${JSON.stringify(unsupported)}`);
128
+ }
129
+ }
130
+ function isShellInterpreter(executable) {
131
+ const normalized = executable.toLowerCase();
132
+ return new Set([
133
+ "sh", "sh.exe", "bash", "bash.exe", "zsh", "zsh.exe", "fish", "fish.exe",
134
+ "dash", "ksh", "ksh93", "csh", "tcsh", "ash",
135
+ "cmd", "cmd.exe", "command.com", "powershell", "powershell.exe", "pwsh", "pwsh.exe",
136
+ ]).has(normalized);
137
+ }
138
+ function containsObviousCredential(value) {
139
+ return /^[A-Za-z_][A-Za-z0-9_]*=.+$/u.test(value) ||
140
+ /(?:^|[=:])[A-Za-z_][A-Za-z0-9_]*=.+/u.test(value) ||
141
+ /(?:^|[/\\])(?:access[-_]?key|api[-_]?key|auth(?:orization)?|client[-_]?secret|credential|pass(?:word|wd)?|private[-_]?key|refresh[-_]?token|secret|token)[=_-][^/\\\s]+/iu.test(value) ||
142
+ /^(?:[A-Za-z][A-Za-z0-9+.-]*):\/\/[^/\s:@]+:[^/\s@]+@/u.test(value) ||
143
+ /^(?:authorization|proxy-authorization)\s*:/iu.test(value) ||
144
+ /^(?:basic|bearer)\s+\S+/iu.test(value) ||
145
+ /(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}|sk-[A-Za-z0-9]{20,}|AIza[A-Za-z0-9_-]{20,}|AKIA[A-Z0-9]{16})/u.test(value);
146
+ }
147
+ function parseSource(value, manifestPath, index) {
148
+ const label = `skills[${index}].source`;
149
+ if (!isRecord(value)) {
150
+ throw new ProjectManifestError(manifestPath, `${label} must be an object`);
151
+ }
152
+ if (value.kind === "builtin") {
153
+ assertKeys(value, ["kind", "id"], label, manifestPath);
154
+ if (value.id !== "builtin:agent-depot") {
155
+ throw new ProjectManifestError(manifestPath, `${label}.id must be "builtin:agent-depot"`);
156
+ }
157
+ return Object.freeze({ kind: "builtin", id: "builtin:agent-depot" });
158
+ }
159
+ if (value.kind !== "external") {
160
+ throw new ProjectManifestError(manifestPath, `${label}.kind must be "builtin" or "external"`);
161
+ }
162
+ if (typeof value.url === "string") {
163
+ assertKeys(value, ["kind", "url", "ref"], label, manifestPath);
164
+ let url;
165
+ try {
166
+ url = canonicalizeGitSourceUrl(value.url);
167
+ }
168
+ catch (error) {
169
+ const reason = error instanceof Error ? error.message : "the URL is invalid";
170
+ throw new ProjectManifestError(manifestPath, `${label}.url is invalid (${reason})`);
171
+ }
172
+ let ref;
173
+ if (value.ref !== undefined) {
174
+ ref = nonEmptyString(value.ref, `${label}.ref`, manifestPath);
175
+ try {
176
+ validateGitRef(ref);
177
+ }
178
+ catch (error) {
179
+ const reason = error instanceof Error ? error.message : "the ref is unsafe";
180
+ throw new ProjectManifestError(manifestPath, `${label}.ref is invalid (${reason})`);
181
+ }
182
+ }
183
+ const source = ref === undefined
184
+ ? { kind: "external", url }
185
+ : { kind: "external", url, ref };
186
+ return Object.freeze(source);
187
+ }
188
+ if (typeof value.path === "string") {
189
+ throw new ProjectManifestError(manifestPath, `${label}.path is not supported in portable manifests; use an external Git URL so the Source identity is resolvable on another machine`);
190
+ }
191
+ throw new ProjectManifestError(manifestPath, `${label} must define exactly one external url or path`);
192
+ }
193
+ /**
194
+ * Why a built-in Skill with a fixed policy cannot be reproduced by the running
195
+ * Agent Depot, or undefined when it can. The built-in tree always matches the
196
+ * running package, so only an equal fixed version is reproducible.
197
+ */
198
+ export function builtinPinMismatch(selection, running = AGENT_DEPOT_PACKAGE_VERSION) {
199
+ if (selection.source.kind !== "builtin" || selection.version.policy !== "fixed") {
200
+ return undefined;
201
+ }
202
+ if (running === undefined || selection.version.version === running) {
203
+ return undefined;
204
+ }
205
+ return `pinned to Agent Depot ${selection.version.version}; running ${running}`;
206
+ }
207
+ function parseVersionPolicy(value, source, manifestPath, index) {
208
+ const label = `skills[${index}].version`;
209
+ if (!isRecord(value)) {
210
+ throw new ProjectManifestError(manifestPath, `${label} must be an object`);
211
+ }
212
+ if (value.policy === "latest") {
213
+ assertKeys(value, ["policy"], label, manifestPath);
214
+ return Object.freeze({ policy: "latest" });
215
+ }
216
+ if (value.policy === "fixed") {
217
+ assertKeys(value, ["policy", "version"], label, manifestPath);
218
+ const version = nonEmptyString(value.version, `${label}.version`, manifestPath);
219
+ if (source.kind === "builtin") {
220
+ if (AGENT_DEPOT_PACKAGE_VERSION === undefined) {
221
+ throw new ProjectManifestError(manifestPath, `${label}.version cannot be fixed because the current Agent Depot package version is unavailable`);
222
+ }
223
+ // A different version is not a manifest error: a manifest written by another
224
+ // Agent Depot stays loadable and each consumer handles that Skill on its own
225
+ // (see builtinPinMismatch).
226
+ }
227
+ else if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(version)) {
228
+ throw new ProjectManifestError(manifestPath, `${label}.version must be a 40- or 64-character commit ID for a fixed policy`);
229
+ }
230
+ return Object.freeze({ policy: "fixed", version });
231
+ }
232
+ throw new ProjectManifestError(manifestPath, `${label}.policy must be "fixed" or "latest"`);
233
+ }
234
+ function parseHosts(value, manifestPath, index) {
235
+ const label = `skills[${index}].hosts`;
236
+ if (!Array.isArray(value) || value.length === 0) {
237
+ throw new ProjectManifestError(manifestPath, `${label} must be a non-empty array`);
238
+ }
239
+ const hosts = [];
240
+ const seen = new Set();
241
+ for (const [hostIndex, host] of value.entries()) {
242
+ if (typeof host !== "string" || !PROJECT_HOSTS.includes(host)) {
243
+ throw new ProjectManifestError(manifestPath, `${label}[${hostIndex}] is not a supported Host`);
244
+ }
245
+ if (seen.has(host)) {
246
+ throw new ProjectManifestError(manifestPath, `${label} contains duplicate Host ${JSON.stringify(host)}`);
247
+ }
248
+ seen.add(host);
249
+ hosts.push(host);
250
+ }
251
+ return Object.freeze(hosts);
252
+ }
253
+ function parseResolvedVersion(value, source, label, manifestPath) {
254
+ if (!isRecord(value)) {
255
+ throw new ProjectManifestError(manifestPath, `${label} must be an object`);
256
+ }
257
+ if (value.kind === "builtin-package") {
258
+ assertKeys(value, ["kind", "version"], label, manifestPath);
259
+ if (source.kind !== "builtin") {
260
+ throw new ProjectManifestError(manifestPath, `${label} is only valid for the built-in Source`);
261
+ }
262
+ return Object.freeze({
263
+ kind: "builtin-package",
264
+ version: nonEmptyString(value.version, `${label}.version`, manifestPath),
265
+ });
266
+ }
267
+ if (value.kind === "git-commit") {
268
+ assertKeys(value, ["kind", "commit"], label, manifestPath);
269
+ if (source.kind !== "external" || !("url" in source)) {
270
+ throw new ProjectManifestError(manifestPath, `${label} is only valid for a Git Source`);
271
+ }
272
+ const commit = nonEmptyString(value.commit, `${label}.commit`, manifestPath);
273
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(commit)) {
274
+ throw new ProjectManifestError(manifestPath, `${label}.commit must be a 40- or 64-character commit ID`);
275
+ }
276
+ return Object.freeze({ kind: "git-commit", commit });
277
+ }
278
+ throw new ProjectManifestError(manifestPath, `${label}.kind is unsupported`);
279
+ }
280
+ function parseInstallationBaseline(value, label, manifestPath) {
281
+ if (!isRecord(value)) {
282
+ throw new ProjectManifestError(manifestPath, `${label} must be an object`);
283
+ }
284
+ assertKeys(value, ["algorithm", "digest"], label, manifestPath);
285
+ if (value.algorithm !== "sha256") {
286
+ throw new ProjectManifestError(manifestPath, `${label}.algorithm must be "sha256"`);
287
+ }
288
+ const digest = nonEmptyString(value.digest, `${label}.digest`, manifestPath);
289
+ if (!/^[0-9a-f]{64}$/u.test(digest)) {
290
+ throw new ProjectManifestError(manifestPath, `${label}.digest must be a 64-character lowercase SHA-256 digest`);
291
+ }
292
+ return Object.freeze({ algorithm: "sha256", digest });
293
+ }
294
+ function parseInstallation(value, source, manifestPath, index) {
295
+ const label = `skills[${index}].installation`;
296
+ if (!isRecord(value)) {
297
+ throw new ProjectManifestError(manifestPath, `${label} must be an object`);
298
+ }
299
+ assertKeys(value, ["path", "adopted", "resolvedVersion", "baseline"], label, manifestPath);
300
+ if (typeof value.adopted !== "boolean") {
301
+ throw new ProjectManifestError(manifestPath, `${label}.adopted must be a boolean`);
302
+ }
303
+ const pathValue = normalizeRelativePath(value.path, `${label}.path`, manifestPath, "the project");
304
+ if (pathValue === ".") {
305
+ throw new ProjectManifestError(manifestPath, `${label}.path must identify a location within the project`);
306
+ }
307
+ const resolvedVersion = value.resolvedVersion === undefined
308
+ ? undefined
309
+ : parseResolvedVersion(value.resolvedVersion, source, `${label}.resolvedVersion`, manifestPath);
310
+ const baseline = value.baseline === undefined
311
+ ? undefined
312
+ : parseInstallationBaseline(value.baseline, `${label}.baseline`, manifestPath);
313
+ return Object.freeze({
314
+ path: pathValue,
315
+ adopted: value.adopted,
316
+ ...(resolvedVersion === undefined ? {} : { resolvedVersion }),
317
+ ...(baseline === undefined ? {} : { baseline }),
318
+ });
319
+ }
320
+ function parseMethods(value, manifestPath, index) {
321
+ const label = `skills[${index}].methods`;
322
+ if (!isRecord(value)) {
323
+ throw new ProjectManifestError(manifestPath, `${label} must be an object`);
324
+ }
325
+ assertKeys(value, ["install", "update"], label, manifestPath);
326
+ const parse = (method, name) => {
327
+ if (method === undefined) {
328
+ return undefined;
329
+ }
330
+ return parsePortableInstallationMethod(method, {
331
+ label: `${label}.${name}`,
332
+ error: (message) => new ProjectManifestError(manifestPath, message),
333
+ });
334
+ };
335
+ const install = parse(value.install, "install");
336
+ const update = parse(value.update, "update");
337
+ return Object.freeze({
338
+ ...(install === undefined ? {} : { install }),
339
+ ...(update === undefined ? {} : { update }),
340
+ });
341
+ }
342
+ function rejectFirstViolation(manifestPath, checks) {
343
+ const violation = checks.find(([violated]) => violated);
344
+ if (violation) {
345
+ throw new ProjectManifestError(manifestPath, violation[1]);
346
+ }
347
+ }
348
+ function parseSelection(value, manifestPath, index) {
349
+ const label = `skills[${index}]`;
350
+ if (!isRecord(value)) {
351
+ throw new ProjectManifestError(manifestPath, `${label} must be an object`);
352
+ }
353
+ assertKeys(value, ["source", "path", "version", "hosts", "installation", "methods"], label, manifestPath);
354
+ const source = parseSource(value.source, manifestPath, index);
355
+ const version = parseVersionPolicy(value.version, source, manifestPath, index);
356
+ const pinnedRef = source.kind === "external" && "url" in source ? source.ref : undefined;
357
+ rejectFirstViolation(manifestPath, [
358
+ [
359
+ version.policy === "latest" && pinnedRef !== undefined && isCommitId(pinnedRef),
360
+ `${label}.version cannot use latest with an immutable commit Source ref; use a fixed policy`,
361
+ ],
362
+ [
363
+ version.policy === "fixed" && source.kind === "external" && "url" in source && pinnedRef !== version.version,
364
+ `${label}.version must match source.ref so a fixed policy pins the selected commit`,
365
+ ],
366
+ ]);
367
+ const installation = value.installation === undefined
368
+ ? undefined
369
+ : parseInstallation(value.installation, source, manifestPath, index);
370
+ const methods = value.methods === undefined
371
+ ? undefined
372
+ : parseMethods(value.methods, manifestPath, index);
373
+ const resolved = installation?.resolvedVersion;
374
+ rejectFirstViolation(manifestPath, [
375
+ [
376
+ resolved?.kind === "git-commit" && pinnedRef !== undefined && isCommitId(pinnedRef) && resolved.commit !== pinnedRef,
377
+ `${label}.installation.resolvedVersion.commit must match the immutable Source ref`,
378
+ ],
379
+ [
380
+ resolved !== undefined && version.policy === "fixed" &&
381
+ (resolved.kind === "git-commit" ? resolved.commit : resolved.version) !== version.version,
382
+ `${label}.installation.resolvedVersion must match the fixed version policy`,
383
+ ],
384
+ ]);
385
+ return Object.freeze({
386
+ source,
387
+ path: normalizeSkillPath(value.path, `${label}.path`, manifestPath),
388
+ version,
389
+ hosts: parseHosts(value.hosts, manifestPath, index),
390
+ ...(installation === undefined ? {} : { installation }),
391
+ ...(methods === undefined ? {} : { methods }),
392
+ });
393
+ }
394
+ /** Validates and normalizes an in-memory project manifest without global Source lookup. */
395
+ export function parseProjectManifest(value, manifestPath = PROJECT_MANIFEST_FILENAME) {
396
+ if (!isRecord(value)) {
397
+ throw new ProjectManifestError(manifestPath, "the root value must be an object");
398
+ }
399
+ assertKeys(value, ["version", "skills"], "the root", manifestPath);
400
+ if (value.version !== PROJECT_MANIFEST_VERSION) {
401
+ throw new ProjectManifestError(manifestPath, "the manifest version is unsupported");
402
+ }
403
+ if (!Array.isArray(value.skills)) {
404
+ throw new ProjectManifestError(manifestPath, "skills must be an array");
405
+ }
406
+ const skills = value.skills.map((selection, index) => parseSelection(selection, manifestPath, index));
407
+ const identities = new Set();
408
+ for (const [index, skill] of skills.entries()) {
409
+ const identity = JSON.stringify([skill.source, skill.path]);
410
+ if (identities.has(identity)) {
411
+ throw new ProjectManifestError(manifestPath, `skills[${index}] duplicates an earlier Source and path selection`);
412
+ }
413
+ identities.add(identity);
414
+ }
415
+ return Object.freeze({ version: PROJECT_MANIFEST_VERSION, skills: Object.freeze(skills) });
416
+ }
417
+ export function defaultProjectManifestPath(projectRoot = process.cwd()) {
418
+ return path.join(projectRoot, PROJECT_MANIFEST_FILENAME);
419
+ }
420
+ export async function readProjectManifest(manifestPath = defaultProjectManifestPath()) {
421
+ let contents;
422
+ try {
423
+ contents = await readFile(manifestPath, "utf8");
424
+ }
425
+ catch (error) {
426
+ if (isMissingFile(error)) {
427
+ return EMPTY_PROJECT_MANIFEST;
428
+ }
429
+ throw error;
430
+ }
431
+ let value;
432
+ try {
433
+ value = JSON.parse(contents);
434
+ }
435
+ catch (error) {
436
+ const reason = error instanceof Error ? error.message : "the JSON could not be parsed";
437
+ throw new ProjectManifestError(manifestPath, `malformed JSON (${reason})`);
438
+ }
439
+ return parseProjectManifest(value, manifestPath);
440
+ }
441
+ export async function writeProjectManifest(manifestPath, manifest) {
442
+ const normalized = parseProjectManifest(manifest, manifestPath);
443
+ const contents = `${JSON.stringify(normalized, null, 2)}\n`;
444
+ const temporaryPath = `${manifestPath}.${process.pid}.${randomUUID()}.tmp`;
445
+ let committed = false;
446
+ try {
447
+ await writeFile(temporaryPath, contents, { encoding: "utf8", flag: "wx" });
448
+ try {
449
+ await rename(temporaryPath, manifestPath);
450
+ }
451
+ catch (error) {
452
+ // Windows may refuse an atomic replacement of an existing regular file.
453
+ // Never unlink a symlink or non-file as a fallback: that could redirect
454
+ // the replacement outside the project.
455
+ if (process.platform !== "win32" || !isWindowsRenameConflict(error)) {
456
+ throw error;
457
+ }
458
+ const existing = await lstat(manifestPath);
459
+ if (existing.isSymbolicLink() || !existing.isFile()) {
460
+ throw new ProjectManifestError(manifestPath, "refusing unsafe Windows manifest replacement target");
461
+ }
462
+ await unlink(manifestPath);
463
+ await rename(temporaryPath, manifestPath);
464
+ }
465
+ committed = true;
466
+ }
467
+ finally {
468
+ await unlink(temporaryPath).catch((error) => {
469
+ if (!isMissingFile(error) && !committed) {
470
+ throw error;
471
+ }
472
+ });
473
+ }
474
+ }
475
+ export class ProjectManifestStore {
476
+ path;
477
+ constructor(manifestPath = defaultProjectManifestPath()) {
478
+ this.path = manifestPath;
479
+ }
480
+ load() {
481
+ return readProjectManifest(this.path);
482
+ }
483
+ save(manifest) {
484
+ return writeProjectManifest(this.path, manifest);
485
+ }
486
+ }
487
+ function readAgentDepotPackageVersion() {
488
+ return readAgentDepotPackageField("version");
489
+ }
490
+ function readAgentDepotPackageField(field) {
491
+ try {
492
+ const packageJson = createRequire(import.meta.url)("../../package.json");
493
+ if (!isRecord(packageJson))
494
+ return undefined;
495
+ const value = packageJson[field];
496
+ return typeof value === "string" && value.length > 0 ? value : undefined;
497
+ }
498
+ catch {
499
+ return undefined;
500
+ }
501
+ }
502
+ function isCommitId(value) {
503
+ return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(value);
504
+ }
505
+ function isMissingFile(error) {
506
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
507
+ }
508
+ const WINDOWS_RENAME_CONFLICT_CODES = new Set(["EEXIST", "EPERM", "ENOTEMPTY"]);
509
+ function isWindowsRenameConflict(error) {
510
+ return typeof error === "object" && error !== null && "code" in error && WINDOWS_RENAME_CONFLICT_CODES.has(error.code);
511
+ }
@@ -0,0 +1,77 @@
1
+ import path from "node:path";
2
+ /**
3
+ * Compares immutable tree entries without decoding or normalizing their bytes.
4
+ * Executable metadata is intentionally not part of content identity: adoption
5
+ * must recognize the same files even when a checkout chose different modes.
6
+ */
7
+ export function compareSkillTrees(expected, actual) {
8
+ const expectedByPath = new Map(expected.map((file) => [file.path, file]));
9
+ const actualByPath = new Map(actual.map((file) => [file.path, file]));
10
+ for (const file of expected) {
11
+ const candidate = actualByPath.get(file.path);
12
+ if (!candidate)
13
+ return { identical: false, reason: "missing-file" };
14
+ if (!sameBytes(file.content, candidate.content)) {
15
+ return { identical: false, reason: "content-mismatch" };
16
+ }
17
+ }
18
+ for (const file of actual) {
19
+ if (!expectedByPath.has(file.path))
20
+ return { identical: false, reason: "extra-file" };
21
+ }
22
+ return { identical: true };
23
+ }
24
+ /** Reads one existing Skill tree while refusing to follow any symlink. */
25
+ export async function readExistingSkillTree(skillRoot, sourceSkillPath, fileSystem) {
26
+ const rootInformation = await fileSystem.lstat(skillRoot);
27
+ assertRealDirectory(skillRoot, rootInformation);
28
+ const files = [];
29
+ await walkExistingTree(skillRoot, sourceSkillPath, "", files, fileSystem);
30
+ files.sort((left, right) => left.path.localeCompare(right.path));
31
+ return Object.freeze(files);
32
+ }
33
+ async function walkExistingTree(skillRoot, sourceSkillPath, relativeDirectory, files, fileSystem) {
34
+ const current = relativeDirectory === "" ? skillRoot : path.join(skillRoot, ...relativeDirectory.split("/"));
35
+ const entries = [...await fileSystem.readdir(current)].sort((left, right) => left.localeCompare(right));
36
+ for (const entry of entries) {
37
+ if (!isSafeEntry(entry)) {
38
+ throw new Error(`Existing Skill contains an unsafe path entry: ${entry}`);
39
+ }
40
+ const relativePath = relativeDirectory === "" ? entry : `${relativeDirectory}/${entry}`;
41
+ const candidate = path.join(skillRoot, ...relativePath.split("/"));
42
+ const information = await fileSystem.lstat(candidate);
43
+ if (information.isSymbolicLink()) {
44
+ throw new Error(`Existing Skill contains a symbolic link: ${relativePath}`);
45
+ }
46
+ if (information.isDirectory()) {
47
+ await walkExistingTree(skillRoot, sourceSkillPath, relativePath, files, fileSystem);
48
+ continue;
49
+ }
50
+ if (!information.isFile()) {
51
+ throw new Error(`Existing Skill contains a non-regular file: ${relativePath}`);
52
+ }
53
+ files.push({
54
+ path: `${sourceSkillPath}/${relativePath}`,
55
+ content: Uint8Array.from(await fileSystem.readFile(candidate)),
56
+ executable: (information.mode & 0o111) !== 0,
57
+ });
58
+ }
59
+ }
60
+ function assertRealDirectory(candidate, information) {
61
+ if (information.isSymbolicLink() || !information.isDirectory()) {
62
+ throw new Error(`Existing Skill is not a real directory: ${candidate}`);
63
+ }
64
+ }
65
+ function isSafeEntry(candidate) {
66
+ return candidate.length > 0 && candidate !== "." && candidate !== ".." &&
67
+ !candidate.includes("/") && !candidate.includes("\\") && !candidate.includes("\u0000");
68
+ }
69
+ function sameBytes(left, right) {
70
+ if (left.byteLength !== right.byteLength)
71
+ return false;
72
+ for (let index = 0; index < left.byteLength; index += 1) {
73
+ if (left[index] !== right[index])
74
+ return false;
75
+ }
76
+ return true;
77
+ }