@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,391 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { runProcess } from "./process-runner.js";
4
+ import { canonicalizeGitSourceUrl, GitSourceAccessAdapter, sourceIdForUrl, } from "./git-source.js";
5
+ import { defaultSourceStatePath, SourceStateStore } from "./source-state.js";
6
+ import { createSourceContentAccess, discoverSkillsFromSources, } from "./skill-discovery.js";
7
+ import { AGENT_DEPOT_PACKAGE_VERSION, parsePortableInstallationMethod, } from "./project-manifest.js";
8
+ export const BUILT_IN_SOURCE = Object.freeze({
9
+ id: "builtin:agent-depot",
10
+ kind: "builtin",
11
+ name: "Agent Depot",
12
+ packageOwned: true,
13
+ });
14
+ export class SourceNotFoundError extends Error {
15
+ constructor(sourceId) {
16
+ super(`Source is not registered: ${sourceId}`);
17
+ this.name = "SourceNotFoundError";
18
+ }
19
+ }
20
+ export class BuiltInSourceError extends Error {
21
+ constructor() {
22
+ super("The package-owned built-in Source cannot be refreshed or changed");
23
+ this.name = "BuiltInSourceError";
24
+ }
25
+ }
26
+ export class SourceSelectionError extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = "SourceSelectionError";
30
+ }
31
+ }
32
+ export class SourceMetadataError extends Error {
33
+ constructor(message) {
34
+ super(message);
35
+ this.name = "SourceMetadataError";
36
+ }
37
+ }
38
+ const SOURCE_METADATA_FILENAMES = Object.freeze([
39
+ ".agent-depot.json",
40
+ "agent-depot-method.json",
41
+ "installation-method.json",
42
+ ]);
43
+ const SOURCE_METADATA_MAX_BYTES = 64 * 1024;
44
+ export function createSourceOperations(options = {}) {
45
+ const stateStore = options.stateStore ?? new SourceStateStore(options.statePath ?? defaultSourceStatePath(process.env, process.platform, options.homeDirectory));
46
+ const gitAccess = options.gitAccess ?? new GitSourceAccessAdapter();
47
+ const sourceContentAccess = options.sourceContentAccess ?? createSourceContentAccess({
48
+ builtInRoot: options.builtInRoot,
49
+ gitCachePath: options.gitCachePath ?? (gitAccess instanceof GitSourceAccessAdapter ? gitAccess.cachePath : undefined),
50
+ });
51
+ return {
52
+ async addGitSource(url) {
53
+ const canonicalUrl = canonicalizeGitSourceUrl(url);
54
+ const id = sourceIdForUrl(canonicalUrl);
55
+ return stateStore.update((state) => {
56
+ const existing = state.gitSources.find((source) => source.id === id);
57
+ if (existing) {
58
+ return { state, result: existing };
59
+ }
60
+ const source = Object.freeze({ id, kind: "git", url: canonicalUrl });
61
+ return {
62
+ state: {
63
+ version: 1,
64
+ gitSources: [...state.gitSources, source],
65
+ userGlobalInstallations: state.userGlobalInstallations,
66
+ },
67
+ result: source,
68
+ };
69
+ });
70
+ },
71
+ async listSources() {
72
+ const state = await stateStore.load();
73
+ return Object.freeze([BUILT_IN_SOURCE, ...state.gitSources]);
74
+ },
75
+ async refreshSource(sourceId) {
76
+ if (sourceId === BUILT_IN_SOURCE.id) {
77
+ throw new BuiltInSourceError();
78
+ }
79
+ const state = await stateStore.load();
80
+ const source = state.gitSources.find((candidate) => candidate.id === sourceId);
81
+ if (!source) {
82
+ throw new SourceNotFoundError(sourceId);
83
+ }
84
+ await gitAccess.refresh(source);
85
+ return source;
86
+ },
87
+ async removeGitSource(sourceId, installations = []) {
88
+ if (sourceId === BUILT_IN_SOURCE.id) {
89
+ throw new BuiltInSourceError();
90
+ }
91
+ await stateStore.update((state) => {
92
+ const source = state.gitSources.find((candidate) => candidate.id === sourceId);
93
+ if (!source) {
94
+ throw new SourceNotFoundError(sourceId);
95
+ }
96
+ const requested = new Set(installations.map(userGlobalInstallationIdentity));
97
+ for (const installation of installations) {
98
+ if (sourceIdForUserGlobalInstallation(installation) !== sourceId ||
99
+ !state.userGlobalInstallations.some((candidate) => userGlobalInstallationIdentity(candidate) === userGlobalInstallationIdentity(installation))) {
100
+ throw new SourceSelectionError(`User-global Skill ${JSON.stringify(installation.path)} is not a dependent installation of Source ${JSON.stringify(sourceId)}`);
101
+ }
102
+ }
103
+ return {
104
+ state: {
105
+ version: 1,
106
+ gitSources: state.gitSources.filter((candidate) => candidate.id !== sourceId),
107
+ userGlobalInstallations: state.userGlobalInstallations.filter((candidate) => !requested.has(userGlobalInstallationIdentity(candidate))),
108
+ },
109
+ result: undefined,
110
+ };
111
+ });
112
+ },
113
+ async migrateUserGlobalInstallations(oldSourceId, newSourceId, migrations) {
114
+ if (oldSourceId === newSourceId) {
115
+ throw new SourceSelectionError("Source migration requires two different registered Git Sources");
116
+ }
117
+ await stateStore.update((state) => {
118
+ const oldSource = state.gitSources.find((source) => source.id === oldSourceId);
119
+ const newSource = state.gitSources.find((source) => source.id === newSourceId);
120
+ if (!oldSource)
121
+ throw new SourceNotFoundError(oldSourceId);
122
+ if (!newSource)
123
+ throw new SourceNotFoundError(newSourceId);
124
+ const sourceIds = (selection) => sourceIdForUserGlobalInstallation(selection);
125
+ const existingIdentities = new Set(state.userGlobalInstallations.map(userGlobalInstallationIdentity));
126
+ const selectedIdentities = new Set();
127
+ const replacementIdentities = new Set();
128
+ for (const migration of migrations) {
129
+ const fromIdentity = userGlobalInstallationIdentity(migration.from);
130
+ const toIdentity = userGlobalInstallationIdentity(migration.to);
131
+ if (selectedIdentities.has(fromIdentity)) {
132
+ throw new SourceSelectionError(`User-global Skill ${JSON.stringify(migration.from.path)} was selected more than once`);
133
+ }
134
+ selectedIdentities.add(fromIdentity);
135
+ if (sourceIds(migration.from) !== oldSourceId || !existingIdentities.has(fromIdentity)) {
136
+ throw new SourceSelectionError(`User-global Skill ${JSON.stringify(migration.from.path)} is not a dependent installation of Source ${JSON.stringify(oldSourceId)}`);
137
+ }
138
+ if (sourceIds(migration.to) !== newSourceId) {
139
+ throw new SourceSelectionError(`User-global Skill ${JSON.stringify(migration.to.path)} does not belong to replacement Source ${JSON.stringify(newSourceId)}`);
140
+ }
141
+ if (replacementIdentities.has(toIdentity)) {
142
+ throw new SourceSelectionError(`Replacement identity ${JSON.stringify(migration.to.path)} was selected more than once`);
143
+ }
144
+ replacementIdentities.add(toIdentity);
145
+ const collision = existingIdentities.has(toIdentity) && !selectedIdentities.has(toIdentity);
146
+ if (collision) {
147
+ throw new SourceSelectionError(`Cannot migrate Skill ${JSON.stringify(migration.from.path)}: replacement identity ${JSON.stringify(migration.to.path)} already exists`);
148
+ }
149
+ }
150
+ const replacements = new Map(migrations.map((migration) => [userGlobalInstallationIdentity(migration.from), migration.to]));
151
+ return {
152
+ state: {
153
+ version: 1,
154
+ gitSources: state.gitSources,
155
+ userGlobalInstallations: state.userGlobalInstallations.map((installation) => replacements.get(userGlobalInstallationIdentity(installation)) ?? installation),
156
+ },
157
+ result: undefined,
158
+ };
159
+ });
160
+ },
161
+ async selectSources(sourceIds) {
162
+ if (sourceIds.length === 0) {
163
+ throw new SourceSelectionError("At least one Source must be selected explicitly");
164
+ }
165
+ const sources = await this.listSources();
166
+ const byId = new Map(sources.map((source) => [source.id, source]));
167
+ const selected = [];
168
+ const seen = new Set();
169
+ for (const sourceId of sourceIds) {
170
+ if (typeof sourceId !== "string" || sourceId.length === 0 || seen.has(sourceId)) {
171
+ continue;
172
+ }
173
+ const source = byId.get(sourceId);
174
+ if (!source) {
175
+ throw new SourceNotFoundError(sourceId);
176
+ }
177
+ seen.add(sourceId);
178
+ selected.push(source);
179
+ }
180
+ if (selected.length === 0) {
181
+ throw new SourceSelectionError("At least one valid Source must be selected explicitly");
182
+ }
183
+ return Object.freeze(selected);
184
+ },
185
+ async resolveProjectSource(projectSource) {
186
+ return resolveProjectSource(projectSource);
187
+ },
188
+ async refreshProjectSource(projectSource) {
189
+ const source = resolveProjectSource(projectSource);
190
+ if (source.kind === "builtin") {
191
+ throw new BuiltInSourceError();
192
+ }
193
+ await gitAccess.refresh(source);
194
+ return source;
195
+ },
196
+ async readInstallationMethod(source, skillPath, previewTree) {
197
+ return previewTree === undefined
198
+ ? readSourceInstallationMethod(sourceContentAccess, source, skillPath)
199
+ : readInstallationMethodFromSkillTree(previewTree, skillPath);
200
+ },
201
+ async executeInstallationMethod(method, context) {
202
+ await executeSourceInstallationMethod(method, context);
203
+ },
204
+ async resolveSourceVersion(source) {
205
+ if (sourceContentAccess.readResolvedVersion) {
206
+ return sourceContentAccess.readResolvedVersion(source);
207
+ }
208
+ return source.kind === "builtin" && AGENT_DEPOT_PACKAGE_VERSION !== undefined
209
+ ? Object.freeze({ kind: "builtin-package", version: AGENT_DEPOT_PACKAGE_VERSION })
210
+ : undefined;
211
+ },
212
+ ...(sourceContentAccess.readSkillTreeSnapshot === undefined
213
+ ? {}
214
+ : {
215
+ readSkillTreeSnapshot: (source, skillPath) => sourceContentAccess.readSkillTreeSnapshot(source, skillPath),
216
+ }),
217
+ async discoverSkills(sourceIds) {
218
+ const sources = await this.selectSources(sourceIds);
219
+ return discoverSkillsFromSources(sources, sourceContentAccess);
220
+ },
221
+ async listUserGlobalInstallations() {
222
+ return (await stateStore.load()).userGlobalInstallations;
223
+ },
224
+ async addUserGlobalInstallation(selection) {
225
+ requireInstallationLocation(selection);
226
+ await stateStore.update((state) => {
227
+ const identity = JSON.stringify([selection.source, selection.path]);
228
+ if (state.userGlobalInstallations.some((candidate) => JSON.stringify([candidate.source, candidate.path]) === identity)) {
229
+ throw new SourceSelectionError(`User-global Skill ${JSON.stringify(selection.path)} is already recorded`);
230
+ }
231
+ return {
232
+ state: {
233
+ version: 1,
234
+ gitSources: state.gitSources,
235
+ userGlobalInstallations: [...state.userGlobalInstallations, selection],
236
+ },
237
+ result: undefined,
238
+ };
239
+ });
240
+ },
241
+ async updateUserGlobalInstallation(selection) {
242
+ requireInstallationLocation(selection);
243
+ await stateStore.update((state) => {
244
+ const identity = JSON.stringify([selection.source, selection.path]);
245
+ const index = state.userGlobalInstallations.findIndex((candidate) => JSON.stringify([candidate.source, candidate.path]) === identity);
246
+ if (index < 0) {
247
+ throw new SourceSelectionError(`User-global Skill ${JSON.stringify(selection.path)} is not recorded`);
248
+ }
249
+ const installations = [...state.userGlobalInstallations];
250
+ installations[index] = selection;
251
+ return {
252
+ state: {
253
+ version: 1,
254
+ gitSources: state.gitSources,
255
+ userGlobalInstallations: installations,
256
+ },
257
+ result: undefined,
258
+ };
259
+ });
260
+ },
261
+ async removeUserGlobalInstallations(selections) {
262
+ await stateStore.update((state) => {
263
+ const selectedIdentities = new Set();
264
+ for (const selection of selections) {
265
+ const identity = userGlobalInstallationIdentity(selection);
266
+ if (selectedIdentities.has(identity)) {
267
+ throw new SourceSelectionError(`User-global Skill ${JSON.stringify(selection.path)} was selected more than once`);
268
+ }
269
+ selectedIdentities.add(identity);
270
+ if (!state.userGlobalInstallations.some((candidate) => userGlobalInstallationIdentity(candidate) === identity)) {
271
+ throw new SourceSelectionError(`User-global Skill ${JSON.stringify(selection.path)} is not recorded`);
272
+ }
273
+ }
274
+ return {
275
+ state: {
276
+ version: 1,
277
+ gitSources: state.gitSources,
278
+ userGlobalInstallations: state.userGlobalInstallations.filter((installation) => !selectedIdentities.has(userGlobalInstallationIdentity(installation))),
279
+ },
280
+ result: undefined,
281
+ };
282
+ });
283
+ },
284
+ async removeCatalogData() {
285
+ await stateStore.remove();
286
+ },
287
+ };
288
+ }
289
+ /** Converts persisted project identity into a read-only Source identity. */
290
+ export function resolveProjectSource(projectSource) {
291
+ if (projectSource.kind === "builtin") {
292
+ return BUILT_IN_SOURCE;
293
+ }
294
+ if ("url" in projectSource) {
295
+ const id = sourceIdForUrl(projectSource.url);
296
+ return Object.freeze({
297
+ id,
298
+ kind: "git",
299
+ url: projectSource.url,
300
+ ...(projectSource.ref === undefined ? {} : { ref: projectSource.ref }),
301
+ });
302
+ }
303
+ throw new SourceMetadataError("Project Source paths are not independently resolvable by the Git-only V1 adapter; use a project-relative Source path with a supported adapter");
304
+ }
305
+ /**
306
+ * Validates the only external-method shape the CLI may accept. Unknown fields,
307
+ * shell strings, empty argv, and control characters all fail closed.
308
+ */
309
+ export function parseSourceInstallationMethod(value) {
310
+ return parsePortableInstallationMethod(value, {
311
+ label: "upstream installation metadata",
312
+ error: (message) => new SourceMetadataError(message),
313
+ });
314
+ }
315
+ /** Resolves and validates a method cwd before any managed tree is replaced. */
316
+ export async function resolveSourceInstallationMethodCwd(method, projectRootInput) {
317
+ const projectRoot = await realpath(path.resolve(projectRootInput)).catch((error) => {
318
+ throw new SourceMetadataError(`cannot resolve the project root: ${errorMessage(error)}`);
319
+ });
320
+ const requestedCwd = path.resolve(projectRoot, method.cwd ?? ".");
321
+ const cwd = await realpath(requestedCwd).catch((error) => {
322
+ throw new SourceMetadataError(`cannot resolve the installation method cwd: ${errorMessage(error)}`);
323
+ });
324
+ const relative = path.relative(projectRoot, cwd);
325
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
326
+ throw new SourceMetadataError("upstream installation method cwd escapes the project root after realpath resolution");
327
+ }
328
+ return cwd;
329
+ }
330
+ /** Executes a validated method with an argument vector and no shell. */
331
+ export async function executeSourceInstallationMethod(method, context) {
332
+ const cwd = await resolveSourceInstallationMethodCwd(method, context.projectRoot);
333
+ const result = await runProcess(method.argv[0], method.argv.slice(1), { cwd });
334
+ if (result.code === 0) {
335
+ return;
336
+ }
337
+ const { code, signal, stderr } = result;
338
+ throw new SourceMetadataError(`upstream installation method failed${signal ? ` (${signal})` : ` (exit ${code ?? "unknown"})`}${stderr.trim() ? `: ${stderr.trim()}` : ""}`);
339
+ }
340
+ function errorMessage(error) {
341
+ return error instanceof Error ? error.message : String(error);
342
+ }
343
+ function userGlobalInstallationIdentity(selection) {
344
+ return JSON.stringify([selection.source, selection.path]);
345
+ }
346
+ function sourceIdForUserGlobalInstallation(selection) {
347
+ return selection.source.kind === "builtin"
348
+ ? selection.source.id
349
+ : "url" in selection.source
350
+ ? sourceIdForUrl(selection.source.url)
351
+ : "";
352
+ }
353
+ async function readSourceInstallationMethod(sourceContentAccess, source, skillPath) {
354
+ if (!sourceContentAccess.readSkillTree) {
355
+ return undefined;
356
+ }
357
+ return readInstallationMethodFromSkillTree(await sourceContentAccess.readSkillTree(source, skillPath), skillPath);
358
+ }
359
+ /** Reads method metadata from one already captured Skill tree snapshot. */
360
+ export function readInstallationMethodFromSkillTree(files, skillPath) {
361
+ const prefix = `${skillPath}/`;
362
+ const metadataFiles = files.filter((file) => file.path.startsWith(prefix) &&
363
+ SOURCE_METADATA_FILENAMES.includes(file.path.slice(prefix.length)));
364
+ if (metadataFiles.length > 1) {
365
+ throw new SourceMetadataError("selected Skill contains more than one installation metadata file");
366
+ }
367
+ const metadataFile = metadataFiles[0];
368
+ if (!metadataFile) {
369
+ return undefined;
370
+ }
371
+ if (metadataFile.content.byteLength > SOURCE_METADATA_MAX_BYTES) {
372
+ throw new SourceMetadataError("installation metadata file exceeds the safe size limit");
373
+ }
374
+ let value;
375
+ try {
376
+ value = JSON.parse(Buffer.from(metadataFile.content).toString("utf8"));
377
+ }
378
+ catch (error) {
379
+ throw new SourceMetadataError(`installation metadata file is not valid JSON: ${errorMessage(error)}`);
380
+ }
381
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
382
+ throw new SourceMetadataError("installation metadata file must contain a JSON object");
383
+ }
384
+ const record = value;
385
+ return Object.hasOwn(record, "method") ? record.method : value;
386
+ }
387
+ function requireInstallationLocation(selection) {
388
+ if (selection.installation === undefined) {
389
+ throw new SourceSelectionError(`User-global Skill ${JSON.stringify(selection.path)} must include an installation location`);
390
+ }
391
+ }
@@ -0,0 +1,144 @@
1
+ import { HOST_CHOICES } from "./catalog-actions.js";
2
+ import { runInstall } from "./catalog-installs.js";
3
+ import { errorText } from "./batch.js";
4
+ import { defaultAdoptionHosts, findAdoptionCandidates, prepareAdoption } from "./installations.js";
5
+ import { answerYesNo, applyChecklistOutcome, hostChecklistKey, moveCursor, versionInputKey } from "./mode-keys.js";
6
+ function cancel(context) {
7
+ context.setMessage({ kind: "ok", text: "Adoption cancelled" });
8
+ context.browse();
9
+ }
10
+ function fail(context, error) {
11
+ if (!context.isMounted())
12
+ return;
13
+ context.setMessage({ kind: "error", text: errorText(error) });
14
+ context.browse();
15
+ }
16
+ /** `A` / Enter on an unmanaged Skill: looks for the Source Skills it could be adopted as. */
17
+ export async function startAdoption(context, group) {
18
+ // Adoption keeps an existing directory in place, so it needs a real directory (the shared root first).
19
+ const entry = group.locations.find((location) => location.entry !== undefined)?.entry;
20
+ if (entry === undefined) {
21
+ context.setMessage({ kind: "error", text: "Adoption is not available for symlinks" });
22
+ return;
23
+ }
24
+ context.setMessage(undefined);
25
+ context.setMode({ kind: "busy", label: `Looking for a Source Skill named ${entry.name}...` });
26
+ try {
27
+ const candidates = await findAdoptionCandidates(context.operations, entry);
28
+ if (!context.isMounted())
29
+ return;
30
+ if (candidates.length === 0) {
31
+ context.setMessage({ kind: "error", text: `No Source Skill named "${entry.name}" exists in the registered Sources, so ${entry.path} cannot be adopted` });
32
+ context.browse();
33
+ }
34
+ else if (candidates.length === 1) {
35
+ context.setMode({ kind: "host", entry, skill: candidates[0], cursor: 0, selected: defaultAdoptionHosts(entry) });
36
+ }
37
+ else {
38
+ context.setMode({ kind: "pick", entry, candidates, cursor: 0 });
39
+ }
40
+ }
41
+ catch (error) {
42
+ fail(context, error);
43
+ }
44
+ }
45
+ async function prepare(context, mode, version) {
46
+ context.setMode({ kind: "busy", label: `Refreshing source and checking that ${mode.entry.name} matches...` });
47
+ try {
48
+ const adoption = await prepareAdoption(context.operations, context.env, mode.entry, mode.skill, { hosts: mode.hosts, version });
49
+ if (!context.isMounted())
50
+ return;
51
+ context.setMode({ kind: "confirm", entry: mode.entry, skill: mode.skill, adoption });
52
+ }
53
+ catch (error) {
54
+ fail(context, error);
55
+ }
56
+ }
57
+ async function confirm(context, mode, exposure) {
58
+ context.setMode({ kind: "busy", label: `Adopting ${mode.entry.name}...` });
59
+ let result;
60
+ try {
61
+ result = { kind: "ok", text: (await runInstall(mode.adoption.prepared, context.operations, exposure)).join(" ") };
62
+ }
63
+ catch (error) {
64
+ result = { kind: "error", text: errorText(error) };
65
+ }
66
+ await context.reload();
67
+ if (!context.isMounted())
68
+ return;
69
+ context.setMessage(result);
70
+ context.browse();
71
+ }
72
+ function handlePickKey(context, mode, input, key) {
73
+ const cursor = moveCursor(mode.cursor, mode.candidates.length, input, key);
74
+ if (key.escape)
75
+ cancel(context);
76
+ else if (cursor !== undefined)
77
+ context.setMode({ ...mode, cursor });
78
+ else if (key.return) {
79
+ context.setMode({ kind: "host", entry: mode.entry, skill: mode.candidates[mode.cursor], cursor: 0, selected: defaultAdoptionHosts(mode.entry) });
80
+ }
81
+ }
82
+ function handleHostKey(context, mode, input, key) {
83
+ applyChecklistOutcome(hostChecklistKey(mode, HOST_CHOICES, input, key), {
84
+ cancel: () => cancel(context),
85
+ update: (state) => context.setMode({ ...mode, ...state }),
86
+ submit: (hosts) => context.setMode({ kind: "version", entry: mode.entry, skill: mode.skill, hosts }),
87
+ setMessage: context.setMessage,
88
+ });
89
+ }
90
+ function handleVersionKey(context, mode, input, key) {
91
+ if (key.escape)
92
+ cancel(context);
93
+ else if (input === "1")
94
+ void prepare(context, mode, { policy: "latest" });
95
+ else if (input === "2")
96
+ context.setMode({ ...mode, kind: "fixed", value: "" });
97
+ }
98
+ function handleFixedKey(context, mode, input, key) {
99
+ const outcome = versionInputKey(mode.value, input, key);
100
+ if (outcome.kind === "cancel")
101
+ cancel(context);
102
+ else if (outcome.kind === "submit")
103
+ void prepare(context, mode, { policy: "fixed", version: mode.value });
104
+ else if (outcome.kind === "edit")
105
+ context.setMode({ ...mode, value: outcome.value });
106
+ }
107
+ function handleConfirmKey(context, mode, input, key) {
108
+ if (!mode.adoption.verdict.adoptable) {
109
+ if (key.escape || key.return || input === "n")
110
+ cancel(context);
111
+ return;
112
+ }
113
+ answerYesNo(input, key, () => {
114
+ if (mode.adoption.prepared.additionalHostExposure)
115
+ context.setMode({ ...mode, kind: "confirm-exposure" });
116
+ else
117
+ void confirm(context, mode, false);
118
+ }, () => cancel(context));
119
+ }
120
+ /** Handles a key in an adoption mode; other modes are ignored. */
121
+ export function handleAdoptionKey(context, mode, input, key) {
122
+ switch (mode.kind) {
123
+ case "pick":
124
+ handlePickKey(context, mode, input, key);
125
+ return;
126
+ case "host":
127
+ handleHostKey(context, mode, input, key);
128
+ return;
129
+ case "version":
130
+ handleVersionKey(context, mode, input, key);
131
+ return;
132
+ case "fixed":
133
+ handleFixedKey(context, mode, input, key);
134
+ return;
135
+ case "confirm":
136
+ handleConfirmKey(context, mode, input, key);
137
+ return;
138
+ case "confirm-exposure":
139
+ answerYesNo(input, key, () => void confirm(context, mode, true), () => cancel(context));
140
+ return;
141
+ default:
142
+ return;
143
+ }
144
+ }