@iseoane/agent-depot 0.2.0 → 0.2.1
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.
- package/CHANGELOG.md +25 -0
- package/README.md +8 -3
- package/dist/src/cli.js +1164 -0
- package/dist/src/git-source.js +409 -0
- package/dist/src/path-safety.js +31 -0
- package/dist/src/process-runner.js +32 -0
- package/dist/src/project-installation.js +1411 -0
- package/dist/src/project-manifest.js +511 -0
- package/dist/src/skill-adoption.js +77 -0
- package/dist/src/skill-discovery.js +467 -0
- package/dist/src/skill-hosts.js +103 -0
- package/dist/src/skill-install.js +362 -0
- package/dist/src/skill-removal.js +119 -0
- package/dist/src/skill-update.js +244 -0
- package/dist/src/source-state.js +265 -0
- package/dist/src/sources.js +391 -0
- package/dist/src/tui/adoption-actions.js +144 -0
- package/dist/src/tui/app.js +63 -0
- package/dist/src/tui/batch.js +17 -0
- package/dist/src/tui/bulk-actions.js +243 -0
- package/dist/src/tui/catalog-actions.js +9 -0
- package/dist/src/tui/catalog-browse.js +59 -0
- package/dist/src/tui/catalog-filter.js +20 -0
- package/dist/src/tui/catalog-flow.js +107 -0
- package/dist/src/tui/catalog-installs.js +189 -0
- package/dist/src/tui/catalog-panel.js +47 -0
- package/dist/src/tui/catalog-state.js +92 -0
- package/dist/src/tui/catalog-tree.js +35 -0
- package/dist/src/tui/catalog-view.js +89 -0
- package/dist/src/tui/environment.js +1 -0
- package/dist/src/tui/installations-browse.js +114 -0
- package/dist/src/tui/installations-mode.js +1 -0
- package/dist/src/tui/installations-panel.js +27 -0
- package/dist/src/tui/installations-rows.js +11 -0
- package/dist/src/tui/installations-tree.js +68 -0
- package/dist/src/tui/installations-view.js +114 -0
- package/dist/src/tui/installations.js +149 -0
- package/dist/src/tui/keys.js +31 -0
- package/dist/src/tui/manage-actions.js +172 -0
- package/dist/src/tui/manage-panel.js +59 -0
- package/dist/src/tui/mode-keys.js +61 -0
- package/dist/src/tui/panel-parts.js +23 -0
- package/dist/src/tui/render.js +8 -0
- package/dist/src/tui/sources-mode.js +17 -0
- package/dist/src/tui/sources-view.js +276 -0
- package/dist/src/tui/theme.js +29 -0
- package/dist/src/tui/tree-navigation.js +68 -0
- package/dist/src/tui/tree.js +53 -0
- package/dist/src/tui/unmanaged-actions.js +178 -0
- package/dist/src/tui/updates-flow.js +74 -0
- package/dist/src/tui/updates-keys.js +95 -0
- package/dist/src/tui/updates-mode.js +1 -0
- package/dist/src/tui/updates-model.js +48 -0
- package/dist/src/tui/updates-panel.js +42 -0
- package/dist/src/tui/updates-view.js +101 -0
- package/dist/src/tui/updates.js +159 -0
- package/dist/src/tui/view-state.js +42 -0
- package/dist/src/tui/window.js +31 -0
- package/dist/src/unmanaged-removal.js +57 -0
- package/dist/src/update-batch.js +376 -0
- package/dist/src/update-flow.js +135 -0
- package/dist/src/usage-error.js +7 -0
- package/dist/src/user-global-skill-inventory.js +559 -0
- package/dist/src/user-method.js +14 -0
- package/package.json +4 -1
- package/skills/doctor-md-agents/scripts/test_collect_sessions.py +0 -780
- package/skills/doctor-md-agents/scripts/test_extract_rules.py +0 -530
- package/skills/doctor-md-agents/scripts/test_render_report.py +0 -383
- package/skills/doctor-md-agents/scripts/test_scan_corrections.py +0 -343
- package/skills/doctor-md-agents/scripts/test_verify_citations.py +0 -236
- package/skills/doctor-md-agents/scripts/testdata/old_extract_rules_pre_t1.py.txt +0 -507
- package/skills/doctor-md-skill/scripts/test_collect_sessions.py +0 -593
- package/skills/doctor-md-skill/scripts/test_detect_machine_sessions.py +0 -181
- package/skills/doctor-md-skill/scripts/test_render_report.py +0 -241
- package/skills/doctor-md-skill/scripts/test_verify_citations.py +0 -196
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { PROJECT_HOSTS } from "../project-manifest.js";
|
|
2
|
+
import { assertManagedInstallationRecordsUnchanged } from "../skill-removal.js";
|
|
3
|
+
import { errorText, runBatch } from "./batch.js";
|
|
4
|
+
import { missingHosts, prepareHostAddition, prepareUninstall, runHostAddition, runUninstall, UNINSTALL_REFUSALS, } from "./catalog-installs.js";
|
|
5
|
+
import { answerYesNo, applyChecklistOutcome, hostChecklistKey } from "./mode-keys.js";
|
|
6
|
+
import { skillOf, sourceIdOf } from "./installations.js";
|
|
7
|
+
import { prepareUnmanagedRemoval, runUnmanagedRemoval } from "./unmanaged-actions.js";
|
|
8
|
+
export const BULK_KINDS = ["bulk-hosts", "confirm-bulk-uninstall", "confirm-bulk-hosts", "confirm-bulk-exposure"];
|
|
9
|
+
const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
10
|
+
function skip(target, label, reason) {
|
|
11
|
+
return { id: target.id, label, reason };
|
|
12
|
+
}
|
|
13
|
+
function labelOf(target) {
|
|
14
|
+
const { leaf } = target;
|
|
15
|
+
if (leaf.kind === "unmanaged")
|
|
16
|
+
return leaf.group.name;
|
|
17
|
+
return leaf.row.scope === "project" ? `${leaf.row.selection.path} (project)` : leaf.row.selection.path;
|
|
18
|
+
}
|
|
19
|
+
/** Reads the records once, so every item of the batch is checked against the same state. */
|
|
20
|
+
async function listRecords(operations) {
|
|
21
|
+
return operations.listUserGlobalInstallations ? operations.listUserGlobalInstallations() : [];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Drift check before an item: the records must be exactly what the previous items were expected to leave.
|
|
25
|
+
* The baseline only ever advances by the change an item made, so an outside change is never absorbed.
|
|
26
|
+
*/
|
|
27
|
+
async function assertNoDrift(operations, expected) {
|
|
28
|
+
const actual = await listRecords(operations);
|
|
29
|
+
try {
|
|
30
|
+
assertManagedInstallationRecordsUnchanged(expected, actual);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new Error("The installation records changed outside this batch since the previous item; this item was left untouched");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const sameRecord = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
37
|
+
/** Plan step of a bulk uninstall: prepares each marked leaf with its shared plan, or records why it is skipped. */
|
|
38
|
+
export async function prepareBulkUninstall(context, targets) {
|
|
39
|
+
const { operations, env, installed } = context;
|
|
40
|
+
const installations = await listRecords(operations);
|
|
41
|
+
const items = [];
|
|
42
|
+
const skipped = [];
|
|
43
|
+
for (const target of targets) {
|
|
44
|
+
const label = labelOf(target);
|
|
45
|
+
const { leaf } = target;
|
|
46
|
+
try {
|
|
47
|
+
if (leaf.kind === "unmanaged") {
|
|
48
|
+
items.push({ kind: "unmanaged", id: target.id, label, prepared: await prepareUnmanagedRemoval(operations, env, leaf.group, leaf.group.locations) });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (leaf.row.scope === "project") {
|
|
52
|
+
skipped.push(skip(target, label, UNINSTALL_REFUSALS["project-only"]));
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const sourceId = sourceIdOf(leaf.row.selection, installed.sources);
|
|
56
|
+
if (sourceId === undefined) {
|
|
57
|
+
skipped.push(skip(target, label, "The Source of this installation is no longer registered"));
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const skill = skillOf(leaf.row, sourceId);
|
|
61
|
+
const prepared = await prepareUninstall(operations, env, skill, installed);
|
|
62
|
+
if (prepared.kind === "ready")
|
|
63
|
+
items.push({ kind: "managed", id: target.id, label, skill, prepared });
|
|
64
|
+
else
|
|
65
|
+
skipped.push(skip(target, label, UNINSTALL_REFUSALS[prepared.kind]));
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
skipped.push(skip(target, label, errorText(error)));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { items, skipped, installations };
|
|
72
|
+
}
|
|
73
|
+
function resultOf(outcomes, skipped) {
|
|
74
|
+
const lines = [...outcomes.map((outcome) => outcome.line), ...skipped.map(({ label, reason }) => `Skipped ${label}: ${reason}`)];
|
|
75
|
+
return {
|
|
76
|
+
message: { kind: outcomes.some((outcome) => !outcome.ok) ? "error" : "ok", text: lines.join("\n") },
|
|
77
|
+
applied: outcomes.filter((outcome) => outcome.ok).map((outcome) => outcome.item.id),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Execute step: each item is rechecked and applied on its own against the records the previous items were expected to leave. */
|
|
81
|
+
export async function runBulkUninstall(plan, operations, env) {
|
|
82
|
+
let expected = plan.installations;
|
|
83
|
+
const outcomes = await runBatch(plan.items, async (item) => {
|
|
84
|
+
await assertNoDrift(operations, expected);
|
|
85
|
+
if (item.kind === "unmanaged") {
|
|
86
|
+
const result = await runUnmanagedRemoval({ ...item.prepared, installations: expected }, operations, env);
|
|
87
|
+
if (result.kind === "error")
|
|
88
|
+
throw new Error(result.text);
|
|
89
|
+
return result.text;
|
|
90
|
+
}
|
|
91
|
+
await runUninstall({ ...item.prepared, installations: expected }, operations, env);
|
|
92
|
+
// Only the record this item removed moves the baseline the next item is checked against.
|
|
93
|
+
expected = expected.filter((record) => !item.prepared.plan.selections.some((removed) => sameRecord(removed, record)));
|
|
94
|
+
return `Removed ${item.label}`;
|
|
95
|
+
}, (item, error) => (item.kind === "unmanaged" ? errorText(error) : `Failed ${item.label}: ${errorText(error)}`));
|
|
96
|
+
return resultOf(outcomes, plan.skipped);
|
|
97
|
+
}
|
|
98
|
+
/** Managed user-global leaves that can gain hosts, and the leaves that cannot with their reasons. */
|
|
99
|
+
export function classifyHostTargets(context, targets) {
|
|
100
|
+
const ready = [];
|
|
101
|
+
const skipped = [];
|
|
102
|
+
for (const target of targets) {
|
|
103
|
+
const label = labelOf(target);
|
|
104
|
+
const { leaf } = target;
|
|
105
|
+
if (leaf.kind === "unmanaged") {
|
|
106
|
+
skipped.push(skip(target, label, "Host changes are not available for unmanaged skills"));
|
|
107
|
+
}
|
|
108
|
+
else if (leaf.row.scope === "project") {
|
|
109
|
+
skipped.push(skip(target, label, "Project installations are managed with the CLI"));
|
|
110
|
+
}
|
|
111
|
+
else if (missingHosts(leaf.row.selection).length === 0) {
|
|
112
|
+
skipped.push(skip(target, label, "already has every host"));
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
const sourceId = sourceIdOf(leaf.row.selection, context.installed.sources);
|
|
116
|
+
if (sourceId === undefined)
|
|
117
|
+
skipped.push(skip(target, label, "The Source of this installation is no longer registered"));
|
|
118
|
+
else
|
|
119
|
+
ready.push({ id: target.id, label, skill: skillOf(leaf.row, sourceId), hosts: leaf.row.selection.hosts });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { ready, skipped };
|
|
123
|
+
}
|
|
124
|
+
/** Plan step after the checklist: each item adds only the chosen hosts it lacks. */
|
|
125
|
+
export async function prepareBulkHostAddition(context, targets, alreadySkipped, chosen) {
|
|
126
|
+
const { operations, env, installed } = context;
|
|
127
|
+
const installations = await listRecords(operations);
|
|
128
|
+
const items = [];
|
|
129
|
+
const skipped = [...alreadySkipped];
|
|
130
|
+
for (const target of targets) {
|
|
131
|
+
const lacking = chosen.filter((host) => !target.hosts.includes(host));
|
|
132
|
+
if (lacking.length === 0) {
|
|
133
|
+
skipped.push({ id: target.id, label: target.label, reason: `already has ${chosen.join(", ")}` });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
items.push({ id: target.id, label: target.label, prepared: await prepareHostAddition(operations, env, target.skill, installed, lacking) });
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
skipped.push({ id: target.id, label: target.label, reason: errorText(error) });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { items, skipped, installations };
|
|
144
|
+
}
|
|
145
|
+
/** Execute step: the single exposure confirmation covers every item; each one is rechecked on its own. */
|
|
146
|
+
export async function runBulkHostAddition(plan, operations, env) {
|
|
147
|
+
let expected = plan.installations;
|
|
148
|
+
const outcomes = await runBatch(plan.items, async (item) => {
|
|
149
|
+
await assertNoDrift(operations, expected);
|
|
150
|
+
const line = await runHostAddition({ ...item.prepared, installations: expected }, operations, env);
|
|
151
|
+
// Only the record this item updated moves the baseline the next item is checked against.
|
|
152
|
+
const { selection, updated } = item.prepared.plan;
|
|
153
|
+
expected = expected.map((record) => (sameRecord(record, selection) ? updated : record));
|
|
154
|
+
return line;
|
|
155
|
+
}, (item, error) => `Failed ${item.label}: ${errorText(error)}`);
|
|
156
|
+
return resultOf(outcomes, plan.skipped);
|
|
157
|
+
}
|
|
158
|
+
function cancel(context, text) {
|
|
159
|
+
context.setMessage({ kind: "ok", text });
|
|
160
|
+
context.browse();
|
|
161
|
+
}
|
|
162
|
+
/** Runs a prepare step in the busy mode; a plan with items goes to its confirmation, otherwise the reasons are reported. */
|
|
163
|
+
async function preparePlan(context, label, prepare, confirmation) {
|
|
164
|
+
context.setMode({ kind: "busy", label });
|
|
165
|
+
try {
|
|
166
|
+
const plan = await prepare();
|
|
167
|
+
if (!context.isMounted())
|
|
168
|
+
return;
|
|
169
|
+
if (plan.items.length === 0) {
|
|
170
|
+
context.setMessage({ kind: "error", text: plan.skipped.map(({ label: name, reason }) => `Skipped ${name}: ${reason}`).join("\n") });
|
|
171
|
+
context.browse();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
context.setMode(confirmation(plan));
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (!context.isMounted())
|
|
178
|
+
return;
|
|
179
|
+
context.setMessage({ kind: "error", text: errorText(error) });
|
|
180
|
+
context.browse();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** `u` with marks: prepares every marked leaf, then asks once. */
|
|
184
|
+
export function startBulkUninstall(context, targets) {
|
|
185
|
+
return preparePlan(context, `Preparing uninstall of ${count(targets.length, "item")}...`, () => prepareBulkUninstall(context, targets), (plan) => ({ kind: "confirm-bulk-uninstall", plan }));
|
|
186
|
+
}
|
|
187
|
+
/** `h` with marks: asks the hosts once, for the union of what the eligible items lack. */
|
|
188
|
+
export function startBulkHostAddition(context, targets) {
|
|
189
|
+
const { ready, skipped } = classifyHostTargets(context, targets);
|
|
190
|
+
const choices = PROJECT_HOSTS.filter((host) => ready.some((target) => !target.hosts.includes(host)));
|
|
191
|
+
if (ready.length === 0) {
|
|
192
|
+
context.setMessage({ kind: "error", text: skipped.map(({ label, reason }) => `Skipped ${label}: ${reason}`).join("\n") });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
context.setMessage(undefined);
|
|
196
|
+
context.setMode({ kind: "bulk-hosts", targets: ready, skipped, choices, cursor: 0, selected: [] });
|
|
197
|
+
}
|
|
198
|
+
function prepareHosts(context, mode) {
|
|
199
|
+
return preparePlan(context, `Preparing host change of ${count(mode.targets.length, "item")}...`, () => prepareBulkHostAddition(context, mode.targets, mode.skipped, mode.selected), (plan) => ({ kind: "confirm-bulk-hosts", plan }));
|
|
200
|
+
}
|
|
201
|
+
async function apply(context, label, action) {
|
|
202
|
+
context.setMode({ kind: "busy", label });
|
|
203
|
+
let result;
|
|
204
|
+
try {
|
|
205
|
+
result = await action();
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
result = { message: { kind: "error", text: errorText(error) }, applied: [] };
|
|
209
|
+
}
|
|
210
|
+
context.unmark(result.applied);
|
|
211
|
+
await context.finish(result.message);
|
|
212
|
+
}
|
|
213
|
+
/** Handles a key in a bulk mode; returns false for any other mode. */
|
|
214
|
+
export function handleBulkKey(context, mode, input, key) {
|
|
215
|
+
const current = mode;
|
|
216
|
+
const { operations, env } = context;
|
|
217
|
+
switch (current.kind) {
|
|
218
|
+
case "bulk-hosts": {
|
|
219
|
+
applyChecklistOutcome(hostChecklistKey(current, current.choices, input, key), {
|
|
220
|
+
cancel: () => cancel(context, "Add hosts cancelled"),
|
|
221
|
+
update: (state) => context.setMode({ ...current, ...state }),
|
|
222
|
+
submit: () => void prepareHosts(context, current),
|
|
223
|
+
setMessage: context.setMessage,
|
|
224
|
+
});
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
case "confirm-bulk-uninstall":
|
|
228
|
+
answerYesNo(input, key, () => {
|
|
229
|
+
void apply(context, `Removing ${count(current.plan.items.length, "item")}...`, () => runBulkUninstall(current.plan, operations, env));
|
|
230
|
+
}, () => cancel(context, "Uninstall cancelled"));
|
|
231
|
+
return true;
|
|
232
|
+
case "confirm-bulk-hosts":
|
|
233
|
+
answerYesNo(input, key, () => context.setMode({ kind: "confirm-bulk-exposure", plan: current.plan }), () => cancel(context, "Add hosts cancelled"));
|
|
234
|
+
return true;
|
|
235
|
+
case "confirm-bulk-exposure":
|
|
236
|
+
answerYesNo(input, key, () => {
|
|
237
|
+
void apply(context, `Changing hosts of ${count(current.plan.items.length, "item")}...`, () => runBulkHostAddition(current.plan, operations, env));
|
|
238
|
+
}, () => cancel(context, "Add hosts cancelled"));
|
|
239
|
+
return true;
|
|
240
|
+
default:
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { PROJECT_HOSTS } from "../project-manifest.js";
|
|
2
|
+
/** Host choices in checklist order; labels use the project's host identifiers. */
|
|
3
|
+
export const HOST_CHOICES = PROJECT_HOSTS;
|
|
4
|
+
export const SCOPE_CHOICES = ["project", "user-global"];
|
|
5
|
+
/** `beta` for one Skill, `3 skills` for a batch. */
|
|
6
|
+
export function describeSkills(skills) {
|
|
7
|
+
return skills.length === 1 ? skills[0].name : `${skills.length} skills`;
|
|
8
|
+
}
|
|
9
|
+
export const BROWSE = { kind: "browse" };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { leavesOf, skillKey } from "./catalog-tree.js";
|
|
2
|
+
import { navigateTree } from "./tree-navigation.js";
|
|
3
|
+
import { toggleNode } from "./tree.js";
|
|
4
|
+
import { toggledMark } from "./view-state.js";
|
|
5
|
+
/** Space: marks the highlighted Skill for a batch install. */
|
|
6
|
+
function markRow(context, row) {
|
|
7
|
+
if (row.node.data.kind !== "skill") {
|
|
8
|
+
context.setMessage({ kind: "error", text: "Mark skills, not source rows" });
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
context.setMessage(undefined);
|
|
12
|
+
context.marks.setMarked(toggledMark(context.marks.markedRef.current, row.node.id));
|
|
13
|
+
}
|
|
14
|
+
/** `a`: marks every listed Skill, or clears the marks when they are all marked already. */
|
|
15
|
+
function markAllListed(context) {
|
|
16
|
+
const { markedRef, setMarked } = context.marks;
|
|
17
|
+
const leaves = leavesOf(context.navigation.latestRoots());
|
|
18
|
+
if (leaves.length === 0) {
|
|
19
|
+
context.setMessage({ kind: "error", text: "There are no skills to select" });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
context.setMessage(undefined);
|
|
23
|
+
setMarked(leaves.every((skill) => markedRef.current.has(skillKey(skill))) ? new Set() : new Set(leaves.map(skillKey)));
|
|
24
|
+
}
|
|
25
|
+
/** `i`: installs the marked Skills that are listed, or the highlighted one. */
|
|
26
|
+
function chooseHosts(context, row) {
|
|
27
|
+
// Only what is listed installs: marks on Skills hidden by the filter are ignored.
|
|
28
|
+
const marked = leavesOf(context.navigation.latestRoots())
|
|
29
|
+
.filter((skill) => context.marks.markedRef.current.has(skillKey(skill)));
|
|
30
|
+
const highlighted = row.node.data.kind === "skill" ? [row.node.data.skill] : [];
|
|
31
|
+
const chosen = marked.length > 0 ? marked : highlighted;
|
|
32
|
+
if (chosen.length === 0) {
|
|
33
|
+
context.setMessage({ kind: "error", text: "Install is not available for source rows; highlight a skill or mark some with space" });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
context.setMessage(undefined);
|
|
37
|
+
context.setAction({ kind: "host", skills: chosen, cursor: 0, selected: [] });
|
|
38
|
+
}
|
|
39
|
+
/** Keys while browsing the Catalog: navigation, filter, marks and starting an install. */
|
|
40
|
+
export function handleCatalogBrowseKey(context, input, key) {
|
|
41
|
+
const snapshot = context.navigation.latest();
|
|
42
|
+
if (navigateTree(context.navigation, snapshot, context.height, input, key))
|
|
43
|
+
return;
|
|
44
|
+
const row = snapshot.visible[snapshot.at];
|
|
45
|
+
if (key.return && row)
|
|
46
|
+
context.navigation.setExpanded(toggleNode(snapshot.open, row));
|
|
47
|
+
else if (input === "/")
|
|
48
|
+
context.filter.open();
|
|
49
|
+
else if (key.escape && context.filter.filterRef.current.query !== "")
|
|
50
|
+
context.filter.clear(context.navigation.restart);
|
|
51
|
+
else if (input === "s" && context.canWiden)
|
|
52
|
+
context.toggleAll();
|
|
53
|
+
else if (input === " " && row)
|
|
54
|
+
markRow(context, row);
|
|
55
|
+
else if (input === "a")
|
|
56
|
+
markAllListed(context);
|
|
57
|
+
else if (input === "i" && context.ready && row)
|
|
58
|
+
chooseHosts(context, row);
|
|
59
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const initialFilter = { editing: false, query: "" };
|
|
2
|
+
export function filterReducer(state, event) {
|
|
3
|
+
switch (event.type) {
|
|
4
|
+
case "open":
|
|
5
|
+
return { ...state, editing: true };
|
|
6
|
+
case "edit":
|
|
7
|
+
return state.editing ? { editing: true, query: event.query } : state;
|
|
8
|
+
case "keep":
|
|
9
|
+
return { ...state, editing: false };
|
|
10
|
+
case "clear":
|
|
11
|
+
return initialFilter;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Case-insensitive match on name and description. */
|
|
15
|
+
export function filterSkills(skills, query) {
|
|
16
|
+
const needle = query.trim().toLowerCase();
|
|
17
|
+
if (needle === "")
|
|
18
|
+
return skills;
|
|
19
|
+
return skills.filter((skill) => skill.name.toLowerCase().includes(needle) || skill.description.toLowerCase().includes(needle));
|
|
20
|
+
}
|
|
@@ -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,189 @@
|
|
|
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({ homeDirectory: homeOf(environment), managedInstallations: global }).then((inventory) => new Set([...inventory.unmanaged, ...inventory.symlinks].map((entry) => entry.name)), () => new Set());
|
|
24
|
+
return { global, project, sources, unmanagedNames };
|
|
25
|
+
}
|
|
26
|
+
/** Whether an unmanaged copy with the Skill's directory name exists on disk (the name an install would use). */
|
|
27
|
+
export function hasUnmanagedCopy(skill, installed) {
|
|
28
|
+
return installed.unmanagedNames.has(path.posix.basename(skill.path));
|
|
29
|
+
}
|
|
30
|
+
function matchesSource(source, sourceId, sources) {
|
|
31
|
+
if (source.kind === "builtin")
|
|
32
|
+
return source.id === sourceId;
|
|
33
|
+
if (!("url" in source))
|
|
34
|
+
return false;
|
|
35
|
+
return sources.some((candidate) => candidate.kind === "git" && candidate.id === sourceId && candidate.url === source.url);
|
|
36
|
+
}
|
|
37
|
+
function recordsFor(records, skill, sources) {
|
|
38
|
+
return records.filter((record) => record.path === skill.path && matchesSource(record.source, skill.sourceId, sources));
|
|
39
|
+
}
|
|
40
|
+
/** Whether the Skill is installed user-global or in the project manifest (same Source and path). */
|
|
41
|
+
export function isInstalled(skill, installed) {
|
|
42
|
+
return [installed.global, installed.project].some((records) => recordsFor(records, skill, installed.sources).length > 0);
|
|
43
|
+
}
|
|
44
|
+
/** Plan step: refreshes the Source, resolves and inspects the install and renders the same preview the CLI prints. */
|
|
45
|
+
export async function prepareInstall(operations, environment, skill, choice) {
|
|
46
|
+
const request = {
|
|
47
|
+
scope: choice.scope,
|
|
48
|
+
sourceId: skill.sourceId,
|
|
49
|
+
skillPath: skill.path,
|
|
50
|
+
hosts: choice.hosts,
|
|
51
|
+
version: choice.version,
|
|
52
|
+
// Confirming the preview is the review the CLI asks for with --portable-v1.
|
|
53
|
+
portableV1: true,
|
|
54
|
+
// Planning with confirmed set refreshes a Git Source before resolving, so
|
|
55
|
+
// the preview shows exactly what y installs (the CLI does this on --yes).
|
|
56
|
+
confirmed: true,
|
|
57
|
+
overwrite: false,
|
|
58
|
+
confirmAdditionalHostExposure: false,
|
|
59
|
+
};
|
|
60
|
+
const sourceAccess = environment.sourceAccess ?? defaultProjectSkillTreeAccess();
|
|
61
|
+
const base = { operations, sourceAccess, environment };
|
|
62
|
+
let plan;
|
|
63
|
+
let manifestStore;
|
|
64
|
+
if (choice.scope === "user-global") {
|
|
65
|
+
if (!operations.listUserGlobalInstallations || !operations.addUserGlobalInstallation) {
|
|
66
|
+
throw new Error("Configured Source operations cannot manage user-global installation state");
|
|
67
|
+
}
|
|
68
|
+
plan = await planSingleInstall(request, {
|
|
69
|
+
...base,
|
|
70
|
+
root: homeOf(environment),
|
|
71
|
+
existing: await operations.listUserGlobalInstallations(),
|
|
72
|
+
recordedIn: "user-global state",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
manifestStore = manifestStoreOf(environment);
|
|
77
|
+
plan = await planSingleInstall(request, {
|
|
78
|
+
...base,
|
|
79
|
+
root: path.resolve(environment.projectRoot ?? process.cwd()),
|
|
80
|
+
existing: (await manifestStore.load()).skills,
|
|
81
|
+
recordedIn: manifestStore.path,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const lines = [];
|
|
85
|
+
outputSingleInstallPreview(plan, (line) => lines.push(line));
|
|
86
|
+
// The TUI confirms interactively, so the CLI's --yes wording does not apply.
|
|
87
|
+
const preview = lines.filter((line) => !line.startsWith(" confirmation:"));
|
|
88
|
+
const additionalHostExposure = needsAdditionalHostExposure(plan.inspection, choice.hosts);
|
|
89
|
+
if (additionalHostExposure) {
|
|
90
|
+
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");
|
|
91
|
+
}
|
|
92
|
+
return { plan, manifestStore, preview, runsExternalCommand: plan.resolved.method !== undefined, additionalHostExposure };
|
|
93
|
+
}
|
|
94
|
+
/** Execute step: installs and persists the record; returns the result lines. */
|
|
95
|
+
export async function runInstall(prepared, operations, confirmAdditionalHostExposure = false) {
|
|
96
|
+
const lines = [];
|
|
97
|
+
const { manifestStore } = prepared;
|
|
98
|
+
const plan = confirmAdditionalHostExposure
|
|
99
|
+
? { ...prepared.plan, request: { ...prepared.plan.request, confirmAdditionalHostExposure: true } }
|
|
100
|
+
: prepared.plan;
|
|
101
|
+
const persist = manifestStore
|
|
102
|
+
? async (record) => {
|
|
103
|
+
// Read at persist time: a batch installs several Skills, and each record must keep the ones saved before it.
|
|
104
|
+
const current = (await manifestStore.load()).skills;
|
|
105
|
+
await manifestStore.save(parseProjectManifest({ version: 1, skills: [...current, record] }));
|
|
106
|
+
}
|
|
107
|
+
: (record) => operations.addUserGlobalInstallation(record);
|
|
108
|
+
await executeSingleInstall(plan, persist, (line) => lines.push(line));
|
|
109
|
+
return lines;
|
|
110
|
+
}
|
|
111
|
+
/** Why a preparation that is not `ready` refuses, in the words the TUI shows. */
|
|
112
|
+
export const UNINSTALL_REFUSALS = {
|
|
113
|
+
unsupported: "Uninstall is not supported by the configured operations",
|
|
114
|
+
"not-installed": "Skill is not installed",
|
|
115
|
+
"project-only": "Project uninstall is not supported",
|
|
116
|
+
};
|
|
117
|
+
/** Plan step for removing the highlighted Skill's user-global installation. */
|
|
118
|
+
export async function prepareUninstall(operations, environment, skill, installed) {
|
|
119
|
+
if (!operations.listUserGlobalInstallations || !operations.removeUserGlobalInstallations) {
|
|
120
|
+
return { kind: "unsupported" };
|
|
121
|
+
}
|
|
122
|
+
const installations = await operations.listUserGlobalInstallations();
|
|
123
|
+
const matches = recordsFor(installations, skill, installed.sources);
|
|
124
|
+
if (matches.length === 0) {
|
|
125
|
+
return { kind: recordsFor(installed.project, skill, installed.sources).length > 0 ? "project-only" : "not-installed" };
|
|
126
|
+
}
|
|
127
|
+
const options = userGlobalRemovalOptions(homeOf(environment), environment.installationOptions);
|
|
128
|
+
const record = matches[0];
|
|
129
|
+
const plan = await planSkillRemoval([record], options);
|
|
130
|
+
const scopeNote = ` removes the whole user-global installation, from hosts: ${record.hosts.join(", ")}`;
|
|
131
|
+
return { kind: "ready", plan: { ...plan, preview: [...plan.preview, scopeNote] }, installations };
|
|
132
|
+
}
|
|
133
|
+
/** Execute step: rechecks the plan, then removes files and records. */
|
|
134
|
+
export async function runUninstall(prepared, operations, environment) {
|
|
135
|
+
const options = userGlobalRemovalOptions(homeOf(environment), environment.installationOptions);
|
|
136
|
+
await executeSkillRemoval(prepared.plan, operations, options, prepared.installations);
|
|
137
|
+
}
|
|
138
|
+
/** The user-global record of the Skill, when it is installed there. */
|
|
139
|
+
export function globalRecord(skill, installed) {
|
|
140
|
+
return recordsFor(installed.global, skill, installed.sources)[0];
|
|
141
|
+
}
|
|
142
|
+
/** Hosts a user-global record does not serve yet, in checklist order. */
|
|
143
|
+
export function missingHosts(record) {
|
|
144
|
+
return PROJECT_HOSTS.filter((host) => !record.hosts.includes(host));
|
|
145
|
+
}
|
|
146
|
+
/** Reads the current records so a host change never acts on a stale checklist. */
|
|
147
|
+
async function currentGlobalRecord(operations, skill, installed) {
|
|
148
|
+
if (!operations.listUserGlobalInstallations || !operations.updateUserGlobalInstallation || !operations.removeUserGlobalInstallations) {
|
|
149
|
+
throw new Error("Configured Source operations cannot manage user-global installation state");
|
|
150
|
+
}
|
|
151
|
+
const installations = await operations.listUserGlobalInstallations();
|
|
152
|
+
const record = recordsFor(installations, skill, installed.sources)[0];
|
|
153
|
+
if (!record)
|
|
154
|
+
throw new Error("Skill is not installed");
|
|
155
|
+
return { record, installations };
|
|
156
|
+
}
|
|
157
|
+
/** A selection problem is a normal message for the TUI, like any other failed step. */
|
|
158
|
+
function asMessage(error) {
|
|
159
|
+
throw error instanceof SkillSelectionError ? new Error(error.message) : error;
|
|
160
|
+
}
|
|
161
|
+
/** Plan step for exposing an installed user-global Skill to more hosts. */
|
|
162
|
+
export async function prepareHostAddition(operations, environment, skill, installed, hosts) {
|
|
163
|
+
const { record, installations } = await currentGlobalRecord(operations, skill, installed);
|
|
164
|
+
const options = userGlobalRemovalOptions(homeOf(environment), environment.installationOptions);
|
|
165
|
+
const plan = await planHostAddition(record, hosts, options).catch(asMessage);
|
|
166
|
+
return { plan, installations, preview: plan.preview };
|
|
167
|
+
}
|
|
168
|
+
/** Execute step: the TUI's separate exposure confirmation stands in for `--confirm-additional-host`. */
|
|
169
|
+
export async function runHostAddition(prepared, operations, environment) {
|
|
170
|
+
const options = userGlobalRemovalOptions(homeOf(environment), environment.installationOptions);
|
|
171
|
+
await executeHostAddition(prepared.plan, operations, options, prepared.installations, true);
|
|
172
|
+
return `Added hosts ${prepared.plan.hosts.join(", ")} to ${prepared.plan.selection.path}`;
|
|
173
|
+
}
|
|
174
|
+
/** Plan step for removing some hosts of a user-global Skill; removing all of them is a full removal. */
|
|
175
|
+
export async function prepareHostRemoval(operations, environment, skill, installed, hosts) {
|
|
176
|
+
const { record, installations } = await currentGlobalRecord(operations, skill, installed);
|
|
177
|
+
const options = userGlobalRemovalOptions(homeOf(environment), environment.installationOptions);
|
|
178
|
+
const plan = await planHostRemoval(record, hosts, options).catch(asMessage);
|
|
179
|
+
return { plan, installations, preview: plan.kind === "full" ? plan.plan.preview : plan.preview };
|
|
180
|
+
}
|
|
181
|
+
/** Execute step: rechecks, removes only the chosen hosts' locations, then reconciles the record. */
|
|
182
|
+
export async function runHostRemoval(prepared, operations, environment, skill) {
|
|
183
|
+
const options = userGlobalRemovalOptions(homeOf(environment), environment.installationOptions);
|
|
184
|
+
await executeHostRemoval(prepared.plan, operations, options, prepared.installations);
|
|
185
|
+
const { plan } = prepared;
|
|
186
|
+
return plan.kind === "full"
|
|
187
|
+
? `Removed ${skill.name} (${skill.path})`
|
|
188
|
+
: `Removed hosts ${plan.removed.join(", ")} from ${plan.selection.path}`;
|
|
189
|
+
}
|