@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.
- package/CHANGELOG.md +47 -0
- package/README.md +117 -3
- package/dist/src/app-cli.js +172 -0
- package/dist/src/app-flow.js +457 -0
- package/dist/src/app-latest.js +63 -0
- package/dist/src/app-owned-skills.js +42 -0
- package/dist/src/app-recipes.js +116 -0
- package/dist/src/app-schema.js +45 -0
- package/dist/src/app-version.js +46 -0
- package/dist/src/atomic-file.js +26 -0
- package/dist/src/cli.js +1256 -0
- package/dist/src/git-source.js +409 -0
- package/dist/src/path-safety.js +31 -0
- package/dist/src/process-runner.js +41 -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 +374 -0
- package/dist/src/skill-removal.js +119 -0
- package/dist/src/skill-update.js +244 -0
- package/dist/src/source-state.js +240 -0
- package/dist/src/sources.js +391 -0
- package/dist/src/tui/adoption-actions.js +144 -0
- package/dist/src/tui/app-actions.js +157 -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 +193 -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 +136 -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 +80 -0
- package/dist/src/tui/installations-view.js +154 -0
- package/dist/src/tui/installations.js +172 -0
- package/dist/src/tui/keys.js +31 -0
- package/dist/src/tui/manage-actions.js +174 -0
- package/dist/src/tui/manage-panel.js +65 -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 +89 -0
- package/dist/src/tui/updates-keys.js +104 -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 +44 -0
- package/dist/src/tui/updates-view.js +101 -0
- package/dist/src/tui/updates.js +230 -0
- package/dist/src/tui/view-state.js +42 -0
- package/dist/src/tui/window.js +31 -0
- package/dist/src/unmanaged-removal.js +60 -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 +565 -0
- package/dist/src/user-method.js +14 -0
- package/package.json +7 -4
- package/skills/agent-depot-apprecipe/SKILL.md +44 -0
- package/skills/agent-depot-apprecipe/assets/example.md +25 -0
- 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,157 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { PROJECT_HOSTS } from "../project-manifest.js";
|
|
4
|
+
import { errorText, runBatch } from "./batch.js";
|
|
5
|
+
import { answerYesNo, hostChecklistKey } from "./mode-keys.js";
|
|
6
|
+
import { HostChecklist, PreviewConfirm } from "./panel-parts.js";
|
|
7
|
+
export const APP_KINDS = ["app-approve", "app-hosts", "app-confirm", "app-manual"];
|
|
8
|
+
/** Narrows the view's mode without asserting a partial mode is an App mode. */
|
|
9
|
+
export function isAppMode(mode) {
|
|
10
|
+
return APP_KINDS.some(kind => kind === mode.kind);
|
|
11
|
+
}
|
|
12
|
+
/** Recipe name, falling back to the file identity for invalid recipes. */
|
|
13
|
+
export const appName = (entry) => entry.recipe?.name ?? entry.file;
|
|
14
|
+
function stepLines(step) {
|
|
15
|
+
return [
|
|
16
|
+
...(step.argv ? [`argv: ${JSON.stringify(step.argv)}`, `Executable: ${step.executable ?? "unavailable"}`] : []),
|
|
17
|
+
...(step.manual ? [`Manual: ${step.manual}`] : []),
|
|
18
|
+
`Working directory: ${step.cwd}`,
|
|
19
|
+
...(step.warning ? [step.warning] : []),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
async function prepareNext(context, requests, results = [], failures = 0) {
|
|
23
|
+
const { manage, apps } = context;
|
|
24
|
+
manage.setMode({ kind: "busy", label: "Preparing App step..." });
|
|
25
|
+
// Independent planning failures never stop the remaining marked Apps.
|
|
26
|
+
const [request, ...remaining] = requests;
|
|
27
|
+
if (!request) {
|
|
28
|
+
await manage.finish({ kind: failures > 0 ? "error" : "ok", text: results.join("\n") });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const plan = await apps.planLifecycle(request.entry, request.action, request.host);
|
|
33
|
+
if (manage.isMounted()) {
|
|
34
|
+
manage.setMode({
|
|
35
|
+
kind: !plan.argv && plan.manual ? "app-manual" : "app-confirm",
|
|
36
|
+
plan, remaining, results, failures,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (manage.isMounted()) {
|
|
42
|
+
await prepareNext(context, remaining, [...results, `${appName(request.entry)}: failed: ${errorText(error)}`], failures + 1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Opens approval, the Host checklist, or the first lifecycle preview for selected Apps. */
|
|
47
|
+
export async function startAppAction(context, entries, input) {
|
|
48
|
+
const { manage, apps } = context;
|
|
49
|
+
manage.setMessage(undefined);
|
|
50
|
+
try {
|
|
51
|
+
if (input === "approve") {
|
|
52
|
+
const entry = entries[0];
|
|
53
|
+
if (!entry)
|
|
54
|
+
throw new Error("No App selected");
|
|
55
|
+
manage.setMode({ kind: "busy", label: "Preparing recipe approval..." });
|
|
56
|
+
const steps = await apps.preview(entry);
|
|
57
|
+
if (manage.isMounted()) {
|
|
58
|
+
manage.setMode({ kind: "app-approve", entry, lines: steps.flatMap(step => [step.step, ...stepLines(step)]) });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else if (input === "h") {
|
|
62
|
+
const choices = PROJECT_HOSTS.filter(host => entries.some(entry => entry.recipe?.setup?.[host] || entry.recipe?.teardown?.[host]));
|
|
63
|
+
if (!choices.length)
|
|
64
|
+
throw new Error("No declared Host steps");
|
|
65
|
+
// This is an action selector, not a claim about persisted Host wiring.
|
|
66
|
+
manage.setMode({
|
|
67
|
+
kind: "app-hosts", entries, choices, cursor: 0,
|
|
68
|
+
selected: choices.filter(host => entries.some(entry => entry.recipe?.teardown?.[host])),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
await prepareNext(context, entries.map(entry => ({ entry, action: input === "u" ? "uninstall" : "install" })));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
await manage.finish({ kind: "error", text: errorText(error) });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function execute(context, mode) {
|
|
80
|
+
const { manage, apps } = context;
|
|
81
|
+
manage.setMode({ kind: "busy", label: "Running App step..." });
|
|
82
|
+
let manualRequired = false;
|
|
83
|
+
const [outcome] = await runBatch([mode.plan], async (plan) => {
|
|
84
|
+
const result = mode.kind === "app-manual"
|
|
85
|
+
? await apps.completeManual(plan, true)
|
|
86
|
+
: await apps.executeLifecycle(plan, true);
|
|
87
|
+
if (result.status === "manual required") {
|
|
88
|
+
manualRequired = true;
|
|
89
|
+
if (manage.isMounted())
|
|
90
|
+
manage.setMode({ ...mode, kind: "app-manual" });
|
|
91
|
+
return "manual required";
|
|
92
|
+
}
|
|
93
|
+
if (result.status === "failed")
|
|
94
|
+
throw new Error(result.reason);
|
|
95
|
+
manage.unmark([`app:${plan.entry.file}`]);
|
|
96
|
+
return `${appName(plan.entry)}: ${result.status}${result.installedVersion ? ` ${result.installedVersion}` : ""}`;
|
|
97
|
+
}, (plan, error) => `${appName(plan.entry)}: failed: ${errorText(error)}`);
|
|
98
|
+
if (manage.isMounted() && outcome && !manualRequired) {
|
|
99
|
+
await prepareNext(context, mode.remaining, [...mode.results, outcome.line], mode.failures + (outcome.ok ? 0 : 1));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Applies checklist and y/n keys, continuing independent batch steps after cancellation or failure. */
|
|
103
|
+
export function handleAppKey(context, mode, input, key) {
|
|
104
|
+
const { manage, apps } = context;
|
|
105
|
+
const cancel = () => {
|
|
106
|
+
manage.browse();
|
|
107
|
+
manage.setMessage({ kind: "ok", text: "App action cancelled" });
|
|
108
|
+
};
|
|
109
|
+
if (mode.kind === "app-hosts") {
|
|
110
|
+
const outcome = hostChecklistKey(mode, mode.choices, input, key);
|
|
111
|
+
if (outcome.kind === "cancel")
|
|
112
|
+
cancel();
|
|
113
|
+
else if (outcome.kind === "update")
|
|
114
|
+
manage.setMode({ ...mode, ...outcome.state });
|
|
115
|
+
else if (key.return) {
|
|
116
|
+
const requests = mode.entries.flatMap(entry => mode.choices.flatMap(host => {
|
|
117
|
+
const action = mode.selected.includes(host) ? "setup" : "teardown";
|
|
118
|
+
return entry.recipe?.[action]?.[host] ? [{ entry, action, host }] : [];
|
|
119
|
+
}));
|
|
120
|
+
if (!requests.length)
|
|
121
|
+
manage.setMessage({ kind: "error", text: "No declared steps for this selection" });
|
|
122
|
+
else
|
|
123
|
+
void prepareNext(context, requests);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else
|
|
127
|
+
answerYesNo(input, key, () => {
|
|
128
|
+
if (mode.kind !== "app-approve") {
|
|
129
|
+
void execute(context, mode);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
manage.setMode({ kind: "busy", label: "Approving recipe..." });
|
|
133
|
+
void apps.approve(mode.entry).then(() => manage.finish({ kind: "ok", text: `Approved ${appName(mode.entry)}` }), error => manage.finish({ kind: "error", text: errorText(error) }));
|
|
134
|
+
}, () => {
|
|
135
|
+
if (mode.kind === "app-confirm" || mode.kind === "app-manual") {
|
|
136
|
+
void prepareNext(context, mode.remaining, [...mode.results, `${appName(mode.plan.entry)}: cancelled`], mode.failures);
|
|
137
|
+
}
|
|
138
|
+
else
|
|
139
|
+
cancel();
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/** Renders App prompts using the existing checklist and preview-confirm panels. */
|
|
143
|
+
export function AppPanel({ mode }) {
|
|
144
|
+
if (mode.kind === "app-hosts") {
|
|
145
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["App Host steps: checked = setup, unchecked = teardown. Wiring is not tracked. Space/1-", mode.choices.length, " toggle, j/k move, Enter continue, Esc cancel."] }), _jsx(HostChecklist, { choices: mode.choices, cursor: mode.cursor, selected: mode.selected })] }));
|
|
146
|
+
}
|
|
147
|
+
if (mode.kind === "app-approve") {
|
|
148
|
+
return _jsx(PreviewConfirm, { lines: mode.lines, question: `Approve ${appName(mode.entry)}? y/n` });
|
|
149
|
+
}
|
|
150
|
+
return (_jsx(PreviewConfirm, { lines: [
|
|
151
|
+
`Environment: ${mode.plan.environment}`,
|
|
152
|
+
`App: ${appName(mode.plan.entry)} ${mode.plan.action}${mode.plan.host ? ` (${mode.plan.host})` : ""}`,
|
|
153
|
+
...stepLines(mode.plan),
|
|
154
|
+
], question: mode.kind === "app-manual"
|
|
155
|
+
? "Manual step done, check now? y/n"
|
|
156
|
+
: `${mode.plan.action} ${appName(mode.plan.entry)}? y/n` }));
|
|
157
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useApp, useStdout } from "ink";
|
|
3
|
+
import { useCallback, useRef, useState } from "react";
|
|
4
|
+
import { AGENT_DEPOT_PACKAGE_VERSION } from "../project-manifest.js";
|
|
5
|
+
import { CatalogView } from "./catalog-view.js";
|
|
6
|
+
import { InstallationsView } from "./installations-view.js";
|
|
7
|
+
import { SourcesView } from "./sources-view.js";
|
|
8
|
+
import { separatorLine, theme } from "./theme.js";
|
|
9
|
+
import { UpdatesView } from "./updates-view.js";
|
|
10
|
+
import { useKeys } from "./keys.js";
|
|
11
|
+
const TABS = [
|
|
12
|
+
["sources", "1 Sources"],
|
|
13
|
+
["catalog", "2 Catalog"],
|
|
14
|
+
["installations", "3 Installations"],
|
|
15
|
+
["updates", "4 Updates"],
|
|
16
|
+
];
|
|
17
|
+
/** Compact keys of the current view; the tab bar already shows how to switch views. */
|
|
18
|
+
function hintsOf(view, catalogFocusedOnSource) {
|
|
19
|
+
switch (view) {
|
|
20
|
+
case "sources":
|
|
21
|
+
return "j/k move · space mark · a all · Enter catalog · n add · r refresh · d remove · q quit";
|
|
22
|
+
case "catalog":
|
|
23
|
+
return `j/k move · Enter expand · space mark · a all · i install · / filter${catalogFocusedOnSource ? " · s all sources" : ""} · q quit`;
|
|
24
|
+
case "installations":
|
|
25
|
+
return "j/k move · Enter expand · space mark · a all · u uninstall · h add hosts · A adopt · q quit";
|
|
26
|
+
case "updates":
|
|
27
|
+
return "j/k move · space mark · a all · Enter preview · r check again · q quit";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function App({ operations, environment, onExit }) {
|
|
31
|
+
const { exit } = useApp();
|
|
32
|
+
const { stdout } = useStdout();
|
|
33
|
+
const [capturing, setCapturing] = useState(false);
|
|
34
|
+
// Updated synchronously by the views so keys in the same burst never see a stale capturing state.
|
|
35
|
+
const capturingRef = useRef(false);
|
|
36
|
+
const onCapturingChange = useCallback((value) => {
|
|
37
|
+
capturingRef.current = value;
|
|
38
|
+
setCapturing(value);
|
|
39
|
+
}, []);
|
|
40
|
+
const [view, setView] = useState("sources");
|
|
41
|
+
const [catalogSourceId, setCatalogSourceId] = useState();
|
|
42
|
+
const showView = (next) => setView(next);
|
|
43
|
+
useKeys((input) => {
|
|
44
|
+
if (capturingRef.current)
|
|
45
|
+
return;
|
|
46
|
+
if (input === "q") {
|
|
47
|
+
onExit?.();
|
|
48
|
+
exit();
|
|
49
|
+
}
|
|
50
|
+
else if (input === "1")
|
|
51
|
+
showView("sources");
|
|
52
|
+
else if (input === "2")
|
|
53
|
+
showView("catalog");
|
|
54
|
+
else if (input === "3")
|
|
55
|
+
showView("installations");
|
|
56
|
+
else if (input === "4")
|
|
57
|
+
showView("updates");
|
|
58
|
+
});
|
|
59
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: theme.accent, children: ["Agent Depot", AGENT_DEPOT_PACKAGE_VERSION === undefined ? "" : ` v${AGENT_DEPOT_PACKAGE_VERSION}`] }), _jsx(Text, { children: TABS.map(([name, label], position) => (_jsxs(Text, { color: view === name ? theme.accent : theme.inactive, bold: view === name, children: [position > 0 ? " " : "", view === name ? `[${label}]` : ` ${label} `] }, name))) }), _jsx(Text, { color: theme.muted, children: separatorLine(stdout.columns) }), view === "sources" ? (_jsx(SourcesView, { operations: operations, onCapturingChange: onCapturingChange, onOpenCatalog: (source) => {
|
|
60
|
+
setCatalogSourceId(source.id);
|
|
61
|
+
setView("catalog");
|
|
62
|
+
} })) : view === "installations" ? (_jsx(InstallationsView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : view === "updates" ? (_jsx(UpdatesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : (_jsx(CatalogView, { operations: operations, sourceId: catalogSourceId, environment: environment, onCapturingChange: onCapturingChange })), _jsx(Text, { color: theme.muted, children: capturing ? "Enter submit · Esc cancel · y/n confirm · space toggle" : hintsOf(view, catalogSourceId !== undefined) })] }));
|
|
63
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const errorText = (error) => (error instanceof Error ? error.message : String(error));
|
|
2
|
+
/**
|
|
3
|
+
* Applies the items one after another. A failure is reported for that item and
|
|
4
|
+
* the rest still run, so the outcomes always cover every item in order.
|
|
5
|
+
*/
|
|
6
|
+
export async function runBatch(items, run, describeFailure) {
|
|
7
|
+
const outcomes = [];
|
|
8
|
+
for (const item of items) {
|
|
9
|
+
try {
|
|
10
|
+
outcomes.push({ item, ok: true, line: await run(item) });
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
outcomes.push({ item, ok: false, line: describeFailure(item, error) });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return outcomes;
|
|
17
|
+
}
|
|
@@ -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
|
+
}
|