@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,107 @@
1
+ import { BROWSE, describeSkills, HOST_CHOICES, SCOPE_CHOICES } from "./catalog-actions.js";
2
+ import { prepareInstall, runInstall } from "./catalog-installs.js";
3
+ import { skillKey } from "./catalog-tree.js";
4
+ import { errorText, runBatch } from "./batch.js";
5
+ import { answerYesNo, applyChecklistOutcome, hostChecklistKey, versionInputKey } from "./mode-keys.js";
6
+ function cancel(context) {
7
+ context.setMessage({ kind: "ok", text: "Install cancelled" });
8
+ context.setAction(BROWSE);
9
+ }
10
+ async function startInstall(context, skills, choice) {
11
+ context.setAction({ kind: "busy", label: `Refreshing source and preparing install of ${describeSkills(skills)}...` });
12
+ const items = [];
13
+ for (const skill of skills) {
14
+ try {
15
+ items.push({ skill, prepared: await prepareInstall(context.operations, context.env, skill, choice) });
16
+ }
17
+ catch (error) {
18
+ items.push({ skill, error: errorText(error) });
19
+ }
20
+ }
21
+ if (!context.isMounted())
22
+ return;
23
+ if (items.every((item) => item.prepared === undefined)) {
24
+ const text = items.length === 1 ? items[0].error : items.map((item) => `Failed ${item.skill.name}: ${item.error}`).join("\n");
25
+ context.setMessage({ kind: "error", text });
26
+ context.setAction(BROWSE);
27
+ return;
28
+ }
29
+ context.setAction({ kind: "confirm-install", items });
30
+ }
31
+ /** Applies the prepared items one by one; a failure is reported for that item and the rest still run. */
32
+ async function confirmInstall(context, items, exposure) {
33
+ context.setAction({ kind: "busy", label: `Installing ${describeSkills(items.map((item) => item.skill))}...` });
34
+ const outcomes = await runBatch(items, async ({ prepared, error }) => {
35
+ if (prepared === undefined)
36
+ throw new Error(error);
37
+ return (await runInstall(prepared, context.operations, exposure && prepared.additionalHostExposure)).join(" ");
38
+ }, ({ skill }, caught) => `Failed ${skill.name}: ${errorText(caught)}`);
39
+ const lines = outcomes.map((outcome) => outcome.line);
40
+ const failed = new Set(outcomes.filter((outcome) => !outcome.ok).map((outcome) => skillKey(outcome.item.skill)));
41
+ // Installed Skills leave the list; only the failed ones stay marked for another try.
42
+ context.keepMarked(failed);
43
+ await context.finish({ kind: failed.size > 0 ? "error" : "ok", text: lines.join("\n") });
44
+ }
45
+ function handleHostKey(context, mode, input, key) {
46
+ applyChecklistOutcome(hostChecklistKey(mode, HOST_CHOICES, input, key), {
47
+ cancel: () => cancel(context),
48
+ update: (state) => context.setAction({ ...mode, ...state }),
49
+ submit: (hosts) => context.setAction({ kind: "scope", skills: mode.skills, hosts }),
50
+ setMessage: context.setMessage,
51
+ });
52
+ }
53
+ function handleScopeKey(context, mode, input, key) {
54
+ const scope = SCOPE_CHOICES[Number(input) - 1];
55
+ if (key.escape)
56
+ cancel(context);
57
+ else if (scope)
58
+ context.setAction({ kind: "version", skills: mode.skills, hosts: mode.hosts, scope });
59
+ }
60
+ function handleVersionKey(context, mode, input, key) {
61
+ if (key.escape)
62
+ cancel(context);
63
+ else if (input === "1")
64
+ void startInstall(context, mode.skills, { hosts: mode.hosts, scope: mode.scope, version: { policy: "latest" } });
65
+ else if (input === "2")
66
+ context.setAction({ ...mode, kind: "fixed", value: "" });
67
+ }
68
+ function handleFixedKey(context, mode, input, key) {
69
+ const outcome = versionInputKey(mode.value, input, key);
70
+ if (outcome.kind === "cancel")
71
+ cancel(context);
72
+ else if (outcome.kind === "submit") {
73
+ void startInstall(context, mode.skills, { hosts: mode.hosts, scope: mode.scope, version: { policy: "fixed", version: mode.value } });
74
+ }
75
+ else if (outcome.kind === "edit")
76
+ context.setAction({ ...mode, value: outcome.value });
77
+ }
78
+ /** Handles a key in one of the install modes; `browse` and `busy` ignore keys. */
79
+ export function handleCatalogActionKey(context, mode, input, key) {
80
+ switch (mode.kind) {
81
+ case "host":
82
+ handleHostKey(context, mode, input, key);
83
+ return;
84
+ case "scope":
85
+ handleScopeKey(context, mode, input, key);
86
+ return;
87
+ case "version":
88
+ handleVersionKey(context, mode, input, key);
89
+ return;
90
+ case "fixed":
91
+ handleFixedKey(context, mode, input, key);
92
+ return;
93
+ case "confirm-install":
94
+ answerYesNo(input, key, () => {
95
+ if (mode.items.some((item) => item.prepared?.additionalHostExposure))
96
+ context.setAction({ ...mode, kind: "confirm-exposure" });
97
+ else
98
+ void confirmInstall(context, mode.items, false);
99
+ }, () => cancel(context));
100
+ return;
101
+ case "confirm-exposure":
102
+ answerYesNo(input, key, () => void confirmInstall(context, mode.items, true), () => cancel(context));
103
+ return;
104
+ default:
105
+ return;
106
+ }
107
+ }
@@ -0,0 +1,193 @@
1
+ import { homedir } from "node:os";
2
+ import path from "node:path";
3
+ import { defaultProjectManifestPath, parseProjectManifest, PROJECT_HOSTS, ProjectManifestStore, } from "../project-manifest.js";
4
+ import { defaultProjectSkillTreeAccess, executeSingleInstall, needsAdditionalHostExposure, outputSingleInstallPreview, planSingleInstall, } from "../skill-install.js";
5
+ import { executeHostAddition, executeHostRemoval, planHostAddition, planHostRemoval, } from "../skill-hosts.js";
6
+ import { executeSkillRemoval, planSkillRemoval, SkillSelectionError, userGlobalRemovalOptions, } from "../skill-removal.js";
7
+ import { scanUserGlobalSkillInventory } from "../user-global-skill-inventory.js";
8
+ export const NO_INSTALLED = { global: [], project: [], sources: [], unmanagedNames: new Set() };
9
+ function homeOf(environment) {
10
+ return path.resolve(environment.homeDirectory ?? homedir());
11
+ }
12
+ function manifestStoreOf(environment) {
13
+ return environment.projectManifestStore ??
14
+ new ProjectManifestStore(defaultProjectManifestPath(path.resolve(environment.projectRoot ?? process.cwd())));
15
+ }
16
+ /** Loads user-global and project installations; anything unavailable or unreadable yields no markers. */
17
+ export async function loadInstalledSkills(operations, environment) {
18
+ const [global, project, sources] = await Promise.all([
19
+ operations.listUserGlobalInstallations?.().catch(() => []) ?? [],
20
+ manifestStoreOf(environment).load().then((manifest) => manifest.skills, () => []),
21
+ operations.listSources().catch(() => []),
22
+ ]);
23
+ const unmanagedNames = await scanUserGlobalSkillInventory({
24
+ homeDirectory: homeOf(environment),
25
+ appEnvironment: environment.appEnvironment,
26
+ managedInstallations: global,
27
+ }).then((inventory) => new Set([...inventory.unmanaged, ...inventory.symlinks].map((entry) => entry.name)), () => new Set());
28
+ return { global, project, sources, unmanagedNames };
29
+ }
30
+ /** Whether an unmanaged copy with the Skill's directory name exists on disk (the name an install would use). */
31
+ export function hasUnmanagedCopy(skill, installed) {
32
+ return installed.unmanagedNames.has(path.posix.basename(skill.path));
33
+ }
34
+ function matchesSource(source, sourceId, sources) {
35
+ if (source.kind === "builtin")
36
+ return source.id === sourceId;
37
+ if (!("url" in source))
38
+ return false;
39
+ return sources.some((candidate) => candidate.kind === "git" && candidate.id === sourceId && candidate.url === source.url);
40
+ }
41
+ function recordsFor(records, skill, sources) {
42
+ return records.filter((record) => record.path === skill.path && matchesSource(record.source, skill.sourceId, sources));
43
+ }
44
+ /** Whether the Skill is installed user-global or in the project manifest (same Source and path). */
45
+ export function isInstalled(skill, installed) {
46
+ return [installed.global, installed.project].some((records) => recordsFor(records, skill, installed.sources).length > 0);
47
+ }
48
+ /** Plan step: refreshes the Source, resolves and inspects the install and renders the same preview the CLI prints. */
49
+ export async function prepareInstall(operations, environment, skill, choice) {
50
+ const request = {
51
+ scope: choice.scope,
52
+ sourceId: skill.sourceId,
53
+ skillPath: skill.path,
54
+ hosts: choice.hosts,
55
+ version: choice.version,
56
+ // Confirming the preview is the review the CLI asks for with --portable-v1.
57
+ portableV1: true,
58
+ // Planning with confirmed set refreshes a Git Source before resolving, so
59
+ // the preview shows exactly what y installs (the CLI does this on --yes).
60
+ confirmed: true,
61
+ overwrite: false,
62
+ confirmAdditionalHostExposure: false,
63
+ };
64
+ const sourceAccess = environment.sourceAccess ?? defaultProjectSkillTreeAccess();
65
+ const base = { operations, sourceAccess, environment };
66
+ let plan;
67
+ let manifestStore;
68
+ if (choice.scope === "user-global") {
69
+ if (!operations.listUserGlobalInstallations || !operations.addUserGlobalInstallation) {
70
+ throw new Error("Configured Source operations cannot manage user-global installation state");
71
+ }
72
+ plan = await planSingleInstall(request, {
73
+ ...base,
74
+ root: homeOf(environment),
75
+ existing: await operations.listUserGlobalInstallations(),
76
+ recordedIn: "user-global state",
77
+ });
78
+ }
79
+ else {
80
+ manifestStore = manifestStoreOf(environment);
81
+ plan = await planSingleInstall(request, {
82
+ ...base,
83
+ root: path.resolve(environment.projectRoot ?? process.cwd()),
84
+ existing: (await manifestStore.load()).skills,
85
+ recordedIn: manifestStore.path,
86
+ });
87
+ }
88
+ const lines = [];
89
+ outputSingleInstallPreview(plan, (line) => lines.push(line));
90
+ // The TUI confirms interactively, so the CLI's --yes wording does not apply.
91
+ const preview = lines.filter((line) => !line.startsWith(" confirmation:"));
92
+ const additionalHostExposure = needsAdditionalHostExposure(plan.inspection, choice.hosts);
93
+ if (additionalHostExposure) {
94
+ preview.push(" ADDITIONAL HOST EXPOSURE: an identical Skill already exists; installing adds the missing Host location (equivalent to --confirm-additional-host) and needs a separate confirmation");
95
+ }
96
+ return { plan, manifestStore, preview, runsExternalCommand: plan.resolved.method !== undefined, additionalHostExposure };
97
+ }
98
+ /** Execute step: installs and persists the record; returns the result lines. */
99
+ export async function runInstall(prepared, operations, confirmAdditionalHostExposure = false) {
100
+ const lines = [];
101
+ const { manifestStore } = prepared;
102
+ const plan = confirmAdditionalHostExposure
103
+ ? { ...prepared.plan, request: { ...prepared.plan.request, confirmAdditionalHostExposure: true } }
104
+ : prepared.plan;
105
+ const persist = manifestStore
106
+ ? async (record) => {
107
+ // Read at persist time: a batch installs several Skills, and each record must keep the ones saved before it.
108
+ const current = (await manifestStore.load()).skills;
109
+ await manifestStore.save(parseProjectManifest({ version: 1, skills: [...current, record] }));
110
+ }
111
+ : (record) => operations.addUserGlobalInstallation(record);
112
+ await executeSingleInstall(plan, persist, (line) => lines.push(line));
113
+ return lines;
114
+ }
115
+ /** Why a preparation that is not `ready` refuses, in the words the TUI shows. */
116
+ export const UNINSTALL_REFUSALS = {
117
+ unsupported: "Uninstall is not supported by the configured operations",
118
+ "not-installed": "Skill is not installed",
119
+ "project-only": "Project uninstall is not supported",
120
+ };
121
+ /** Plan step for removing the highlighted Skill's user-global installation. */
122
+ export async function prepareUninstall(operations, environment, skill, installed) {
123
+ if (!operations.listUserGlobalInstallations || !operations.removeUserGlobalInstallations) {
124
+ return { kind: "unsupported" };
125
+ }
126
+ const installations = await operations.listUserGlobalInstallations();
127
+ const matches = recordsFor(installations, skill, installed.sources);
128
+ if (matches.length === 0) {
129
+ return { kind: recordsFor(installed.project, skill, installed.sources).length > 0 ? "project-only" : "not-installed" };
130
+ }
131
+ const options = userGlobalRemovalOptions(homeOf(environment), environment.installationOptions);
132
+ const record = matches[0];
133
+ const plan = await planSkillRemoval([record], options);
134
+ const scopeNote = ` removes the whole user-global installation, from hosts: ${record.hosts.join(", ")}`;
135
+ return { kind: "ready", plan: { ...plan, preview: [...plan.preview, scopeNote] }, installations };
136
+ }
137
+ /** Execute step: rechecks the plan, then removes files and records. */
138
+ export async function runUninstall(prepared, operations, environment) {
139
+ const options = userGlobalRemovalOptions(homeOf(environment), environment.installationOptions);
140
+ await executeSkillRemoval(prepared.plan, operations, options, prepared.installations);
141
+ }
142
+ /** The user-global record of the Skill, when it is installed there. */
143
+ export function globalRecord(skill, installed) {
144
+ return recordsFor(installed.global, skill, installed.sources)[0];
145
+ }
146
+ /** Hosts a user-global record does not serve yet, in checklist order. */
147
+ export function missingHosts(record) {
148
+ return PROJECT_HOSTS.filter((host) => !record.hosts.includes(host));
149
+ }
150
+ /** Reads the current records so a host change never acts on a stale checklist. */
151
+ async function currentGlobalRecord(operations, skill, installed) {
152
+ if (!operations.listUserGlobalInstallations || !operations.updateUserGlobalInstallation || !operations.removeUserGlobalInstallations) {
153
+ throw new Error("Configured Source operations cannot manage user-global installation state");
154
+ }
155
+ const installations = await operations.listUserGlobalInstallations();
156
+ const record = recordsFor(installations, skill, installed.sources)[0];
157
+ if (!record)
158
+ throw new Error("Skill is not installed");
159
+ return { record, installations };
160
+ }
161
+ /** A selection problem is a normal message for the TUI, like any other failed step. */
162
+ function asMessage(error) {
163
+ throw error instanceof SkillSelectionError ? new Error(error.message) : error;
164
+ }
165
+ /** Plan step for exposing an installed user-global Skill to more hosts. */
166
+ export async function prepareHostAddition(operations, environment, skill, installed, hosts) {
167
+ const { record, installations } = await currentGlobalRecord(operations, skill, installed);
168
+ const options = userGlobalRemovalOptions(homeOf(environment), environment.installationOptions);
169
+ const plan = await planHostAddition(record, hosts, options).catch(asMessage);
170
+ return { plan, installations, preview: plan.preview };
171
+ }
172
+ /** Execute step: the TUI's separate exposure confirmation stands in for `--confirm-additional-host`. */
173
+ export async function runHostAddition(prepared, operations, environment) {
174
+ const options = userGlobalRemovalOptions(homeOf(environment), environment.installationOptions);
175
+ await executeHostAddition(prepared.plan, operations, options, prepared.installations, true);
176
+ return `Added hosts ${prepared.plan.hosts.join(", ")} to ${prepared.plan.selection.path}`;
177
+ }
178
+ /** Plan step for removing some hosts of a user-global Skill; removing all of them is a full removal. */
179
+ export async function prepareHostRemoval(operations, environment, skill, installed, hosts) {
180
+ const { record, installations } = await currentGlobalRecord(operations, skill, installed);
181
+ const options = userGlobalRemovalOptions(homeOf(environment), environment.installationOptions);
182
+ const plan = await planHostRemoval(record, hosts, options).catch(asMessage);
183
+ return { plan, installations, preview: plan.kind === "full" ? plan.plan.preview : plan.preview };
184
+ }
185
+ /** Execute step: rechecks, removes only the chosen hosts' locations, then reconciles the record. */
186
+ export async function runHostRemoval(prepared, operations, environment, skill) {
187
+ const options = userGlobalRemovalOptions(homeOf(environment), environment.installationOptions);
188
+ await executeHostRemoval(prepared.plan, operations, options, prepared.installations);
189
+ const { plan } = prepared;
190
+ return plan.kind === "full"
191
+ ? `Removed ${skill.name} (${skill.path})`
192
+ : `Removed hosts ${plan.removed.join(", ")} from ${plan.selection.path}`;
193
+ }
@@ -0,0 +1,47 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { describeSkills, HOST_CHOICES, SCOPE_CHOICES } from "./catalog-actions.js";
4
+ import { skillKey } from "./catalog-tree.js";
5
+ import { hasUnmanagedCopy } from "./catalog-installs.js";
6
+ import { describeRow, UNMANAGED_MARKER } from "./catalog-tree.js";
7
+ import { HostChecklist, PreviewLines } from "./panel-parts.js";
8
+ import { rowStyle } from "./theme.js";
9
+ import { theme } from "./theme.js";
10
+ /** Scope and counts, the filter line, and why the list may be empty. */
11
+ export function CatalogHeader({ scope, listed, installable, total, filter }) {
12
+ return (_jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.muted, children: ["Scope: ", scope, " ", listed, " of ", installable] }), filter.editing || filter.query !== "" ? (_jsxs(Text, { children: ["Filter: ", filter.query, filter.editing ? "_" : ""] })) : null, total === 0 ? _jsx(Text, { children: "No skills" }) : null, total > 0 && installable === 0 ? _jsx(Text, { children: "All skills are installed" }) : null, installable > 0 && listed === 0 ? _jsx(Text, { children: "No matching skills" }) : null] }));
13
+ }
14
+ /** One line of the Catalog tree: cursor, indent, mark box, label and the unmanaged-copy marker. */
15
+ export function CatalogRowLine({ row, selected, marked, installed }) {
16
+ const { data } = row.node;
17
+ const isSource = data.kind === "source";
18
+ return (_jsxs(Text, { ...rowStyle(selected), color: isSource ? theme.group : undefined, children: [selected ? "> " : " ", " ".repeat(row.depth), isSource ? "" : marked ? "[x] " : "[ ] ", describeRow(row), data.kind === "skill" && hasUnmanagedCopy(data.skill, installed)
19
+ ? _jsxs(Text, { color: theme.marker, children: [" ", UNMANAGED_MARKER] })
20
+ : null] }));
21
+ }
22
+ /** The prompt of the install flow; browsing shows nothing. */
23
+ export function ActionPanel({ mode }) {
24
+ switch (mode.kind) {
25
+ case "browse":
26
+ return null;
27
+ case "host":
28
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Install ", describeSkills(mode.skills), ". Host (space/1-4 toggle, j/k move, Enter continue, Esc cancel):"] }), _jsx(HostChecklist, { choices: HOST_CHOICES, cursor: mode.cursor, selected: mode.selected })] }));
29
+ case "scope":
30
+ return _jsxs(Text, { children: ["Scope: ", SCOPE_CHOICES.map((scope, i) => `${i + 1} ${scope}`).join(" "), " (Esc cancel)"] });
31
+ case "version":
32
+ return _jsx(Text, { children: "Version: 1 latest 2 fixed (Esc cancel)" });
33
+ case "fixed":
34
+ return _jsxs(Text, { children: ["Fixed version: ", mode.value, "_ (Enter confirm, Esc cancel)"] });
35
+ case "confirm-install": {
36
+ const ready = mode.items.filter((item) => item.prepared !== undefined);
37
+ const single = mode.items.length === 1;
38
+ return (_jsxs(Box, { flexDirection: "column", children: [mode.items.map(({ skill, prepared, error }) => prepared === undefined ? (_jsxs(Text, { color: theme.error, children: ["Cannot install ", skill.name, ": ", error] }, skillKey(skill))) : (_jsxs(Box, { flexDirection: "column", children: [single ? null : _jsx(Text, { color: theme.group, children: skill.name }), _jsx(PreviewLines, { lines: prepared.preview })] }, skillKey(skill)))), ready.some((item) => item.prepared?.runsExternalCommand) ? _jsx(Text, { color: theme.warning, children: "This install runs an external command (see above)." }) : null, _jsxs(Text, { children: ["Install ", describeSkills(ready.map((item) => item.skill)), "? y/n"] })] }));
39
+ }
40
+ case "confirm-exposure": {
41
+ const names = mode.items.filter((item) => item.prepared?.additionalHostExposure).map((item) => item.skill.name);
42
+ return (_jsxs(Text, { color: theme.warning, children: [names.length === 1 ? `An identical ${names[0]} already exists` : `Identical copies of ${names.join(", ")} already exist`, "; adding the missing Host location needs separate confirmation (CLI: --confirm-additional-host). Confirm? y/n"] }));
43
+ }
44
+ case "busy":
45
+ return _jsx(Text, { children: mode.label });
46
+ }
47
+ }
@@ -0,0 +1,92 @@
1
+ import { useCallback, useEffect, useReducer, useRef, useState } from "react";
2
+ import { filterReducer, initialFilter } from "./catalog-filter.js";
3
+ import { loadInstalledSkills, NO_INSTALLED } from "./catalog-installs.js";
4
+ /** Discovers the Skills of one Source, or of all of them, and again whenever those inputs change. */
5
+ export function useCatalogSkills(operations, sourceId, all) {
6
+ const [state, setState] = useState({ status: "loading" });
7
+ useEffect(() => {
8
+ let cancelled = false;
9
+ if (!operations.discoverSkills) {
10
+ setState({ status: "error", message: "Discovery is not supported by the configured operations" });
11
+ return;
12
+ }
13
+ setState({ status: "loading" });
14
+ void (async () => {
15
+ try {
16
+ const ids = all || sourceId === undefined
17
+ ? (await operations.listSources()).map((source) => source.id)
18
+ : [sourceId];
19
+ const skills = await operations.discoverSkills(ids);
20
+ if (!cancelled)
21
+ setState({ status: "ready", skills });
22
+ }
23
+ catch (error) {
24
+ if (!cancelled)
25
+ setState({ status: "error", message: error instanceof Error ? error.message : String(error) });
26
+ }
27
+ })();
28
+ return () => {
29
+ cancelled = true;
30
+ };
31
+ }, [operations, sourceId, all]);
32
+ return state;
33
+ }
34
+ /** The Skills already installed, loaded on mount and on demand. */
35
+ export function useInstalledSkills(operations, env, mounted) {
36
+ const [installed, setInstalled] = useState(NO_INSTALLED);
37
+ const [installedReady, setInstalledReady] = useState(false);
38
+ const reloadInstalled = useCallback(async () => {
39
+ const loaded = await loadInstalledSkills(operations, env);
40
+ if (!mounted.current)
41
+ return;
42
+ setInstalled(loaded);
43
+ setInstalledReady(true);
44
+ }, [operations, env, mounted]);
45
+ useEffect(() => {
46
+ void reloadInstalled();
47
+ }, [reloadInstalled]);
48
+ return { installed, installedReady, reloadInstalled };
49
+ }
50
+ /**
51
+ * The filter box. Its state is mirrored in refs, updated first, so the shell
52
+ * never sees a stale capturing state and keystrokes delivered in one burst are not lost.
53
+ */
54
+ export function useCatalogFilter(actionRef, onCapturingChange) {
55
+ const [filter, dispatchFilter] = useReducer(filterReducer, initialFilter);
56
+ const filterRef = useRef(initialFilter);
57
+ // The typed query, so keystrokes delivered in one burst are not lost to stale closures.
58
+ const typed = useRef("");
59
+ const queryRef = useRef(filter.query);
60
+ queryRef.current = filter.query;
61
+ const dispatch = (event) => {
62
+ filterRef.current = filterReducer(filterRef.current, event);
63
+ onCapturingChange?.(filterRef.current.editing || actionRef.current.kind !== "browse");
64
+ dispatchFilter(event);
65
+ };
66
+ const edit = (query, restartList) => {
67
+ typed.current = query;
68
+ restartList();
69
+ dispatch({ type: "edit", query });
70
+ };
71
+ const clear = (restartList) => {
72
+ typed.current = "";
73
+ queryRef.current = "";
74
+ restartList();
75
+ dispatch({ type: "clear" });
76
+ };
77
+ const open = () => {
78
+ typed.current = filterRef.current.query;
79
+ dispatch({ type: "open" });
80
+ };
81
+ const handleEditKey = (input, key, restartList) => {
82
+ if (key.escape)
83
+ clear(restartList);
84
+ else if (key.return)
85
+ dispatch({ type: "keep" });
86
+ else if (key.backspace || key.delete)
87
+ edit(typed.current.slice(0, -1), restartList);
88
+ else if (input !== "" && !key.ctrl && !key.meta)
89
+ edit(typed.current + input, restartList);
90
+ };
91
+ return { filter, filterRef, queryRef, open, clear, handleEditKey };
92
+ }
@@ -0,0 +1,35 @@
1
+ import { defaultExpanded } from "./tree.js";
2
+ const DESCRIPTION_LIMIT = 60;
3
+ export const UNMANAGED_MARKER = "on disk, unmanaged · adopt it from Installations";
4
+ export const skillKey = (skill) => `skill:${skill.sourceId}:${skill.path}`;
5
+ function truncate(text) {
6
+ return text.length > DESCRIPTION_LIMIT ? `${text.slice(0, DESCRIPTION_LIMIT - 3)}...` : text;
7
+ }
8
+ /** Skills grouped by Source in first-seen order; a Source with no Skills never appears. */
9
+ export function buildTree(skills) {
10
+ const bySource = new Map();
11
+ for (const skill of skills)
12
+ bySource.set(skill.sourceId, [...(bySource.get(skill.sourceId) ?? []), skill]);
13
+ return [...bySource].map(([sourceId, members]) => ({
14
+ id: `source:${sourceId}`,
15
+ data: { kind: "source", sourceId, count: members.length },
16
+ children: members.map((skill) => ({ id: skillKey(skill), data: { kind: "skill", skill } })),
17
+ }));
18
+ }
19
+ function allSourceIds(roots) {
20
+ return new Set(roots.filter((root) => (root.children?.length ?? 0) > 0).map((root) => root.id));
21
+ }
22
+ /** While a filter is set every matching source is open so the matches show; otherwise only the first. */
23
+ export function openByDefault(nodes, query) {
24
+ return query === "" ? defaultExpanded(nodes) : allSourceIds(nodes);
25
+ }
26
+ export function describeRow(row) {
27
+ const { data } = row.node;
28
+ if (data.kind === "source")
29
+ return `${row.expanded ? "▾" : "▸"} ${data.sourceId} (${data.count})`;
30
+ return `${data.skill.name} ${truncate(data.skill.description)}`;
31
+ }
32
+ /** The Skills under the tree's Sources, in display order. */
33
+ export function leavesOf(roots) {
34
+ return roots.flatMap((root) => (root.children ?? []).flatMap((child) => child.data.kind === "skill" ? [child.data.skill] : []));
35
+ }
@@ -0,0 +1,89 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { BROWSE } from "./catalog-actions.js";
5
+ import { handleCatalogBrowseKey } from "./catalog-browse.js";
6
+ import { filterSkills } from "./catalog-filter.js";
7
+ import { handleCatalogActionKey } from "./catalog-flow.js";
8
+ import { isInstalled } from "./catalog-installs.js";
9
+ import { ActionPanel, CatalogHeader, CatalogRowLine } from "./catalog-panel.js";
10
+ import { ListFooter } from "./panel-parts.js";
11
+ import { useCatalogFilter, useCatalogSkills, useInstalledSkills } from "./catalog-state.js";
12
+ import { buildTree, leavesOf, openByDefault, skillKey } from "./catalog-tree.js";
13
+ import { theme } from "./theme.js";
14
+ import { useTreeNavigation } from "./tree-navigation.js";
15
+ import { useMarks, useMounted } from "./view-state.js";
16
+ import { computeWindow, useListHeight } from "./window.js";
17
+ import { useKeys } from "./keys.js";
18
+ const NO_ENVIRONMENT = {};
19
+ export function CatalogView({ operations, sourceId, onCapturingChange, environment, listHeight }) {
20
+ const height = useListHeight(listHeight, 6);
21
+ const env = environment ?? NO_ENVIRONMENT;
22
+ const [all, setAll] = useState(sourceId === undefined);
23
+ const mounted = useMounted();
24
+ const marks = useMarks();
25
+ const [message, setMessage] = useState();
26
+ const [action, setActionState] = useState(BROWSE);
27
+ // Mirrors the action mode synchronously, like the filter, so later keystrokes and the shell never see stale state.
28
+ const actionRef = useRef(BROWSE);
29
+ const filter = useCatalogFilter(actionRef, onCapturingChange);
30
+ const state = useCatalogSkills(operations, sourceId, all);
31
+ const { installed, installedReady, reloadInstalled } = useInstalledSkills(operations, env, mounted);
32
+ const setAction = (next) => {
33
+ actionRef.current = next;
34
+ onCapturingChange?.(filter.filterRef.current.editing || next.kind !== "browse");
35
+ setActionState(next);
36
+ };
37
+ const skills = state.status === "ready" ? state.skills : [];
38
+ const installable = useMemo(() => skills.filter((skill) => !isInstalled(skill, installed)), [state, installed]);
39
+ const visible = useMemo(() => filterSkills(installable, filter.filter.query), [installable, filter.filter.query]);
40
+ const roots = useMemo(() => buildTree(visible), [visible]);
41
+ const navigation = useTreeNavigation(roots, (nodes) => openByDefault(nodes, filter.queryRef.current));
42
+ // Discovering again starts the list over.
43
+ const { restart } = navigation;
44
+ useEffect(restart, [operations, sourceId, all, restart]);
45
+ const { rows, index } = navigation;
46
+ const flow = {
47
+ operations,
48
+ env,
49
+ setAction,
50
+ setMessage,
51
+ isMounted: () => mounted.current,
52
+ keepMarked: marks.setMarked,
53
+ finish: async (result) => {
54
+ await reloadInstalled();
55
+ if (!mounted.current)
56
+ return;
57
+ setMessage(result);
58
+ setAction(BROWSE);
59
+ },
60
+ };
61
+ const browse = {
62
+ navigation,
63
+ height,
64
+ marks,
65
+ filter,
66
+ canWiden: sourceId !== undefined,
67
+ ready: state.status === "ready",
68
+ toggleAll: () => setAll((value) => !value),
69
+ setMessage,
70
+ setAction,
71
+ };
72
+ useKeys((input, key) => {
73
+ const mode = actionRef.current;
74
+ if (mode.kind !== "browse")
75
+ handleCatalogActionKey(flow, mode, input, key);
76
+ else if (filter.filterRef.current.editing)
77
+ filter.handleEditKey(input, key, navigation.restart);
78
+ else
79
+ handleCatalogBrowseKey(browse, input, key);
80
+ });
81
+ const scope = all || sourceId === undefined ? "all sources" : sourceId;
82
+ if (state.status === "loading" || (state.status === "ready" && !installedReady))
83
+ return _jsx(Text, { children: "Loading skills..." });
84
+ if (state.status === "error")
85
+ return _jsxs(Text, { color: theme.error, children: ["Error: ", state.message] });
86
+ const window = computeWindow(rows.length, index, height);
87
+ const selectedCount = leavesOf(roots).filter((skill) => marks.marked.has(skillKey(skill))).length;
88
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(CatalogHeader, { scope: scope, listed: visible.length, installable: installable.length, total: skills.length, filter: filter.filter }), rows.slice(window.start, window.end).map((row, offset) => (_jsx(CatalogRowLine, { row: row, selected: window.start + offset === index, marked: marks.marked.has(row.node.id), installed: installed }, row.node.id))), _jsx(ListFooter, { indicator: window.indicator, selectedCount: selectedCount, message: message }), _jsx(ActionPanel, { mode: action })] }));
89
+ }
@@ -0,0 +1 @@
1
+ export {};