@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +8 -3
  3. package/dist/src/cli.js +1164 -0
  4. package/dist/src/git-source.js +409 -0
  5. package/dist/src/path-safety.js +31 -0
  6. package/dist/src/process-runner.js +32 -0
  7. package/dist/src/project-installation.js +1411 -0
  8. package/dist/src/project-manifest.js +511 -0
  9. package/dist/src/skill-adoption.js +77 -0
  10. package/dist/src/skill-discovery.js +467 -0
  11. package/dist/src/skill-hosts.js +103 -0
  12. package/dist/src/skill-install.js +362 -0
  13. package/dist/src/skill-removal.js +119 -0
  14. package/dist/src/skill-update.js +244 -0
  15. package/dist/src/source-state.js +265 -0
  16. package/dist/src/sources.js +391 -0
  17. package/dist/src/tui/adoption-actions.js +144 -0
  18. package/dist/src/tui/app.js +63 -0
  19. package/dist/src/tui/batch.js +17 -0
  20. package/dist/src/tui/bulk-actions.js +243 -0
  21. package/dist/src/tui/catalog-actions.js +9 -0
  22. package/dist/src/tui/catalog-browse.js +59 -0
  23. package/dist/src/tui/catalog-filter.js +20 -0
  24. package/dist/src/tui/catalog-flow.js +107 -0
  25. package/dist/src/tui/catalog-installs.js +189 -0
  26. package/dist/src/tui/catalog-panel.js +47 -0
  27. package/dist/src/tui/catalog-state.js +92 -0
  28. package/dist/src/tui/catalog-tree.js +35 -0
  29. package/dist/src/tui/catalog-view.js +89 -0
  30. package/dist/src/tui/environment.js +1 -0
  31. package/dist/src/tui/installations-browse.js +114 -0
  32. package/dist/src/tui/installations-mode.js +1 -0
  33. package/dist/src/tui/installations-panel.js +27 -0
  34. package/dist/src/tui/installations-rows.js +11 -0
  35. package/dist/src/tui/installations-tree.js +68 -0
  36. package/dist/src/tui/installations-view.js +114 -0
  37. package/dist/src/tui/installations.js +149 -0
  38. package/dist/src/tui/keys.js +31 -0
  39. package/dist/src/tui/manage-actions.js +172 -0
  40. package/dist/src/tui/manage-panel.js +59 -0
  41. package/dist/src/tui/mode-keys.js +61 -0
  42. package/dist/src/tui/panel-parts.js +23 -0
  43. package/dist/src/tui/render.js +8 -0
  44. package/dist/src/tui/sources-mode.js +17 -0
  45. package/dist/src/tui/sources-view.js +276 -0
  46. package/dist/src/tui/theme.js +29 -0
  47. package/dist/src/tui/tree-navigation.js +68 -0
  48. package/dist/src/tui/tree.js +53 -0
  49. package/dist/src/tui/unmanaged-actions.js +178 -0
  50. package/dist/src/tui/updates-flow.js +74 -0
  51. package/dist/src/tui/updates-keys.js +95 -0
  52. package/dist/src/tui/updates-mode.js +1 -0
  53. package/dist/src/tui/updates-model.js +48 -0
  54. package/dist/src/tui/updates-panel.js +42 -0
  55. package/dist/src/tui/updates-view.js +101 -0
  56. package/dist/src/tui/updates.js +159 -0
  57. package/dist/src/tui/view-state.js +42 -0
  58. package/dist/src/tui/window.js +31 -0
  59. package/dist/src/unmanaged-removal.js +57 -0
  60. package/dist/src/update-batch.js +376 -0
  61. package/dist/src/update-flow.js +135 -0
  62. package/dist/src/usage-error.js +7 -0
  63. package/dist/src/user-global-skill-inventory.js +559 -0
  64. package/dist/src/user-method.js +14 -0
  65. package/package.json +4 -1
  66. package/skills/doctor-md-agents/scripts/test_collect_sessions.py +0 -780
  67. package/skills/doctor-md-agents/scripts/test_extract_rules.py +0 -530
  68. package/skills/doctor-md-agents/scripts/test_render_report.py +0 -383
  69. package/skills/doctor-md-agents/scripts/test_scan_corrections.py +0 -343
  70. package/skills/doctor-md-agents/scripts/test_verify_citations.py +0 -236
  71. package/skills/doctor-md-agents/scripts/testdata/old_extract_rules_pre_t1.py.txt +0 -507
  72. package/skills/doctor-md-skill/scripts/test_collect_sessions.py +0 -593
  73. package/skills/doctor-md-skill/scripts/test_detect_machine_sessions.py +0 -181
  74. package/skills/doctor-md-skill/scripts/test_render_report.py +0 -241
  75. package/skills/doctor-md-skill/scripts/test_verify_citations.py +0 -196
@@ -0,0 +1,276 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { useCallback, useEffect, useReducer, useRef, useState } from "react";
4
+ import { initialMode, modeReducer } from "./sources-mode.js";
5
+ import { rowStyle, theme } from "./theme.js";
6
+ import { computeWindow, pageStep, useListHeight } from "./window.js";
7
+ import { useKeys } from "./keys.js";
8
+ /** The highlighted index: by stable id while that source exists, otherwise the bounded fallback index. */
9
+ function resolveIndex(sources, selection) {
10
+ const found = sources.findIndex((source) => source.id === selection.id);
11
+ return found >= 0 ? found : Math.min(selection.index, Math.max(sources.length - 1, 0));
12
+ }
13
+ /** Refreshes each source in order; one failure is reported and does not stop the others. */
14
+ async function refreshEach(operations, targets) {
15
+ const lines = [];
16
+ let failed = false;
17
+ for (const source of targets) {
18
+ try {
19
+ lines.push(`Refreshed Git Source: ${(await operations.refreshSource(source.id)).id}`);
20
+ }
21
+ catch (error) {
22
+ failed = true;
23
+ lines.push(`Failed ${source.id}: ${errorText(error)}`);
24
+ }
25
+ }
26
+ return { kind: failed ? "error" : "ok", text: lines.join("\n") };
27
+ }
28
+ function errorText(error) {
29
+ return error instanceof Error ? error.message : String(error);
30
+ }
31
+ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, listHeight }) {
32
+ const height = useListHeight(listHeight, 7);
33
+ const [state, setState] = useState({ status: "loading" });
34
+ // The highlight follows the source id; the index is only the fallback once that source is gone.
35
+ // Selection and marks are mirrored in refs updated first, so keys delivered in one burst act on the latest state.
36
+ const [selection, setSelectionState] = useState({ index: 0 });
37
+ const selectionRef = useRef({ index: 0 });
38
+ const setSelection = (next) => {
39
+ selectionRef.current = next;
40
+ setSelectionState(next);
41
+ };
42
+ const [mode, dispatchMode] = useReducer(modeReducer, initialMode);
43
+ // Mirrors the mode synchronously so the shell and later keystrokes never see a stale capturing state.
44
+ const modeRef = useRef(initialMode);
45
+ const mounted = useRef(true);
46
+ const [message, setMessage] = useState();
47
+ // Git sources marked for a combined refresh; the built-in source can never be marked.
48
+ const [marked, setMarkedState] = useState(new Set());
49
+ const markedRef = useRef(new Set());
50
+ const setMarked = (next) => {
51
+ markedRef.current = next;
52
+ setMarkedState(next);
53
+ };
54
+ // Mirrors the typed URL synchronously so keystrokes delivered in one burst are not lost to stale closures.
55
+ const typed = useRef("");
56
+ const reload = useCallback(async () => {
57
+ try {
58
+ const sources = await operations.listSources();
59
+ if (mounted.current)
60
+ setState({ status: "ready", sources });
61
+ }
62
+ catch (error) {
63
+ if (mounted.current)
64
+ setState({ status: "error", message: errorText(error) });
65
+ }
66
+ }, [operations]);
67
+ useEffect(() => {
68
+ mounted.current = true;
69
+ return () => {
70
+ mounted.current = false;
71
+ };
72
+ }, []);
73
+ const dispatch = (event) => {
74
+ modeRef.current = modeReducer(modeRef.current, event);
75
+ onCapturingChange?.(modeRef.current.kind !== "browse");
76
+ dispatchMode(event);
77
+ };
78
+ useEffect(() => {
79
+ let cancelled = false;
80
+ operations.listSources().then((sources) => {
81
+ if (!cancelled)
82
+ setState({ status: "ready", sources });
83
+ }, (error) => {
84
+ if (!cancelled)
85
+ setState({ status: "error", message: errorText(error) });
86
+ });
87
+ return () => {
88
+ cancelled = true;
89
+ };
90
+ }, [operations]);
91
+ const allSources = state.status === "ready" ? state.sources : [];
92
+ const builtin = allSources.find((source) => source.kind === "builtin");
93
+ // The built-in source is a fixed row; only Git sources are selectable and navigable.
94
+ const sources = allSources.filter((source) => source.kind === "git");
95
+ const sourcesRef = useRef(sources);
96
+ sourcesRef.current = sources;
97
+ const selected = resolveIndex(sources, selection);
98
+ const select = (index) => {
99
+ const listed = sourcesRef.current;
100
+ const bounded = Math.min(Math.max(index, 0), Math.max(listed.length - 1, 0));
101
+ setSelection({ id: listed[bounded]?.id, index: bounded });
102
+ };
103
+ /** Runs a mutating action in the busy mode, reports its outcome and reloads the list. */
104
+ const run = async (action) => {
105
+ dispatch({ type: "busy" });
106
+ setMessage(undefined);
107
+ let outcome;
108
+ try {
109
+ const result = await action();
110
+ outcome = typeof result === "string" ? { kind: "ok", text: result } : result;
111
+ }
112
+ catch (error) {
113
+ outcome = { kind: "error", text: `Error: ${errorText(error)}` };
114
+ }
115
+ if (!mounted.current)
116
+ return;
117
+ setMessage(outcome);
118
+ await reload();
119
+ if (mounted.current)
120
+ dispatch({ type: "done" });
121
+ };
122
+ /** Marks the given ids, or unmarks them all when every one is already marked. */
123
+ const toggle = (ids) => {
124
+ const next = new Set(markedRef.current);
125
+ if (ids.every((id) => next.has(id)))
126
+ ids.forEach((id) => next.delete(id));
127
+ else
128
+ ids.forEach((id) => next.add(id));
129
+ setMarked(next);
130
+ };
131
+ const stop = (text) => setMessage({ kind: "error", text });
132
+ const startRemoval = async (source) => {
133
+ if (!operations.listUserGlobalInstallations || !operations.removeGitSource) {
134
+ stop("Removing sources is not supported by the configured operations");
135
+ return;
136
+ }
137
+ dispatch({ type: "busy" });
138
+ setMessage(undefined);
139
+ try {
140
+ const installations = await operations.listUserGlobalInstallations();
141
+ if (!mounted.current)
142
+ return;
143
+ const dependent = installations.some((selection) => selection.source.kind === "external" && "url" in selection.source && selection.source.url === source.url);
144
+ if (dependent) {
145
+ stop(`Dependent user-global Skills exist; run \`agent-depot source remove ${source.id}\` in the CLI`);
146
+ dispatch({ type: "done" });
147
+ }
148
+ else {
149
+ dispatch({ type: "confirm", action: "remove", source });
150
+ }
151
+ }
152
+ catch (error) {
153
+ if (!mounted.current)
154
+ return;
155
+ stop(`Error: ${errorText(error)}`);
156
+ dispatch({ type: "done" });
157
+ }
158
+ };
159
+ useKeys((input, key) => {
160
+ const mode = modeRef.current;
161
+ if (mode.kind === "busy")
162
+ return;
163
+ if (mode.kind === "input") {
164
+ if (key.escape)
165
+ dispatch({ type: "done" });
166
+ else if (key.return) {
167
+ const url = typed.current.trim();
168
+ if (url === "")
169
+ return;
170
+ void run(async () => `Added Git Source: ${(await operations.addGitSource(url)).id}`);
171
+ }
172
+ else if (key.backspace || key.delete) {
173
+ typed.current = typed.current.slice(0, -1);
174
+ dispatch({ type: "edit", value: typed.current });
175
+ }
176
+ else if (input !== "" && !key.ctrl && !key.meta) {
177
+ typed.current += input;
178
+ dispatch({ type: "edit", value: typed.current });
179
+ }
180
+ return;
181
+ }
182
+ if (mode.kind === "confirm") {
183
+ if (input === "y") {
184
+ if (mode.action === "refresh") {
185
+ const { sources: targets } = mode;
186
+ void run(async () => {
187
+ const result = await refreshEach(operations, targets);
188
+ setMarked(new Set());
189
+ return result;
190
+ });
191
+ }
192
+ else {
193
+ const { source } = mode;
194
+ void run(async () => {
195
+ await operations.removeGitSource?.(source.id);
196
+ return `Removed Git Source: ${source.id}`;
197
+ });
198
+ }
199
+ }
200
+ else if (input === "n" || key.escape)
201
+ dispatch({ type: "done" });
202
+ return;
203
+ }
204
+ const listed = sourcesRef.current;
205
+ const at = resolveIndex(listed, selectionRef.current);
206
+ const highlighted = listed[at];
207
+ if (key.downArrow || input === "j") {
208
+ select(at + 1);
209
+ }
210
+ else if (key.upArrow || input === "k") {
211
+ select(at - 1);
212
+ }
213
+ else if (key.pageDown) {
214
+ select(at + pageStep(height));
215
+ }
216
+ else if (key.pageUp) {
217
+ select(at - pageStep(height));
218
+ }
219
+ else if (key.return && highlighted) {
220
+ onOpenCatalog?.(highlighted);
221
+ }
222
+ else if (input === "n") {
223
+ setMessage(undefined);
224
+ typed.current = "";
225
+ dispatch({ type: "input" });
226
+ }
227
+ else if (input === " " && highlighted) {
228
+ setMessage(undefined);
229
+ toggle([highlighted.id]);
230
+ }
231
+ else if (input === "a") {
232
+ if (listed.length === 0)
233
+ stop("There are no Git sources to select");
234
+ else {
235
+ setMessage(undefined);
236
+ toggle(listed.map((source) => source.id));
237
+ }
238
+ }
239
+ else if (input === "r") {
240
+ const targets = markedRef.current.size > 0
241
+ ? listed.filter((source) => markedRef.current.has(source.id))
242
+ : highlighted ? [highlighted] : [];
243
+ if (targets.length > 0) {
244
+ setMessage(undefined);
245
+ dispatch({ type: "confirm", action: "refresh", sources: targets });
246
+ }
247
+ }
248
+ else if (input === "d" && highlighted) {
249
+ void startRemoval(highlighted);
250
+ }
251
+ });
252
+ // Sources removed since they were marked no longer count.
253
+ const selectedCount = sources.filter((source) => marked.has(source.id)).length;
254
+ if (state.status === "loading")
255
+ return _jsx(Text, { children: "Loading sources..." });
256
+ if (state.status === "error")
257
+ return _jsxs(Text, { color: theme.error, children: ["Error: ", state.message] });
258
+ const window = computeWindow(sources.length, selected, height);
259
+ return (_jsxs(Box, { flexDirection: "column", children: [builtin ? _jsxs(Text, { color: theme.muted, children: [" ", builtin.id, " \u00B7 included"] }) : null, sources.length === 0 ? _jsx(Text, { children: "No Git sources \u00B7 n to add" }) : null, sources.length > 0 ? _jsxs(Text, { color: theme.muted, children: [" ", "ID KIND URL"] }) : null, sources.slice(window.start, window.end).map((source, offset) => {
260
+ const index = window.start + offset;
261
+ return (_jsxs(Text, { ...rowStyle(index === selected), children: [index === selected ? "> " : " ", marked.has(source.id) ? "[x] " : "[ ] ", source.id, " ", source.kind, " ", source.url] }, source.id));
262
+ }), window.indicator ? _jsx(Text, { color: theme.muted, children: window.indicator }) : null, selectedCount > 0 ? _jsxs(Text, { children: [selectedCount, " selected"] }) : null, _jsx(Prompt, { mode: mode }), message ? _jsx(Text, { color: message.kind === "error" ? theme.error : theme.success, children: message.text }) : null] }));
263
+ }
264
+ function Prompt({ mode }) {
265
+ if (mode.kind === "input")
266
+ return _jsxs(Text, { children: ["URL: ", mode.value] });
267
+ if (mode.kind === "busy")
268
+ return _jsx(Text, { children: "Working..." });
269
+ if (mode.kind === "confirm") {
270
+ const lines = mode.action === "refresh"
271
+ ? mode.sources.map((source) => `refresh Git Source ${source.id} from ${source.url}`)
272
+ : [`remove Git Source ${mode.source.id} from ${mode.source.url}`];
273
+ return _jsxs(Text, { children: [lines.join("\n"), "\n", "[y/n]"] });
274
+ }
275
+ return null;
276
+ }
@@ -0,0 +1,29 @@
1
+ /** Central xeo palette for the TUI; views take every colour from here. */
2
+ export const theme = {
3
+ /** Title and active tab. */
4
+ accent: "#ff2e9a",
5
+ /** Group nodes and selection text. */
6
+ group: "#7aa2f7",
7
+ /** Markers such as hosts and versions. */
8
+ marker: "#449dab",
9
+ success: "#9ece6a",
10
+ warning: "#e0af68",
11
+ error: "#f7768e",
12
+ /** Hints and footer. */
13
+ muted: "#414868",
14
+ /** Inactive tabs. */
15
+ inactive: "#565f89",
16
+ /** Background of the selected row. */
17
+ selection: "#292e42",
18
+ };
19
+ /** Columns assumed when the terminal width is unknown. */
20
+ export const FALLBACK_TERMINAL_COLUMNS = 80;
21
+ /** Double-line rule as wide as the terminal. */
22
+ export function separatorLine(columns) {
23
+ const width = columns !== undefined && columns > 0 ? Math.floor(columns) : FALLBACK_TERMINAL_COLUMNS;
24
+ return "═".repeat(width);
25
+ }
26
+ /** Text props for a list row: bold on the selection background when selected. */
27
+ export function rowStyle(selected) {
28
+ return selected ? { bold: true, backgroundColor: theme.selection } : { bold: false };
29
+ }
@@ -0,0 +1,68 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ import { collapseOrParent, expandNode, flattenVisible, resolveSelection, } from "./tree.js";
3
+ import { pageStep } from "./window.js";
4
+ /**
5
+ * Expansion and selection of a tree, mirrored in refs so keys delivered in one
6
+ * burst act on the latest state. `openByDefault` decides which groups are open
7
+ * until the user changes them.
8
+ */
9
+ export function useTreeNavigation(roots, openByDefault) {
10
+ const [expandedState, setExpandedState] = useState();
11
+ const expandedRef = useRef(undefined);
12
+ const [selection, setSelectionState] = useState({ index: 0 });
13
+ const selectionRef = useRef({ index: 0 });
14
+ const rootsRef = useRef(roots);
15
+ rootsRef.current = roots;
16
+ const openByDefaultRef = useRef(openByDefault);
17
+ openByDefaultRef.current = openByDefault;
18
+ const setExpanded = useCallback((next) => {
19
+ expandedRef.current = next;
20
+ setExpandedState(next);
21
+ }, []);
22
+ const setSelection = useCallback((next) => {
23
+ selectionRef.current = next;
24
+ setSelectionState(next);
25
+ }, []);
26
+ const restart = useCallback(() => {
27
+ setSelection({ index: 0 });
28
+ setExpanded(undefined);
29
+ }, [setSelection, setExpanded]);
30
+ const rows = flattenVisible(roots, expandedState ?? openByDefault(roots));
31
+ const latest = () => {
32
+ const open = expandedRef.current ?? openByDefaultRef.current(rootsRef.current);
33
+ const visible = flattenVisible(rootsRef.current, open);
34
+ return { open, visible, at: resolveSelection(visible, selectionRef.current) };
35
+ };
36
+ const latestRoots = () => rootsRef.current;
37
+ return { rows, index: resolveSelection(rows, selection), latest, latestRoots, setExpanded, setSelection, restart };
38
+ }
39
+ /**
40
+ * Up/down, paging and expand/collapse keys shared by the tree views. Returns
41
+ * whether the key was one of them; Enter, space and letters belong to the view.
42
+ */
43
+ export function navigateTree(navigation, snapshot, height, input, key) {
44
+ const { open, visible, at } = snapshot;
45
+ const row = visible[at];
46
+ const moveTo = (target) => {
47
+ const bounded = Math.min(Math.max(target, 0), Math.max(visible.length - 1, 0));
48
+ navigation.setSelection({ id: visible[bounded]?.node.id, index: bounded });
49
+ };
50
+ if (key.downArrow || input === "j")
51
+ moveTo(at + 1);
52
+ else if (key.upArrow || input === "k")
53
+ moveTo(at - 1);
54
+ else if (key.pageDown)
55
+ moveTo(at + pageStep(height));
56
+ else if (key.pageUp)
57
+ moveTo(at - pageStep(height));
58
+ else if (key.rightArrow && row)
59
+ navigation.setExpanded(expandNode(open, row));
60
+ else if (key.leftArrow && row) {
61
+ const next = collapseOrParent(open, visible, at);
62
+ navigation.setExpanded(next.expanded);
63
+ navigation.setSelection({ id: next.selectedId, index: at });
64
+ }
65
+ else
66
+ return false;
67
+ return true;
68
+ }
@@ -0,0 +1,53 @@
1
+ /** Initially only the first group with children is open; every other group starts collapsed. */
2
+ export function defaultExpanded(roots) {
3
+ const first = roots.find((root) => (root.children?.length ?? 0) > 0);
4
+ return new Set(first ? [first.id] : []);
5
+ }
6
+ /** The visible nodes in display order: children appear only under expanded parents. */
7
+ export function flattenVisible(roots, expanded) {
8
+ const rows = [];
9
+ const visit = (nodes, depth, parentId) => {
10
+ for (const node of nodes) {
11
+ const expandable = (node.children?.length ?? 0) > 0;
12
+ const open = expandable && expanded.has(node.id);
13
+ rows.push({ node, depth, parentId, expandable, expanded: open });
14
+ if (open)
15
+ visit(node.children, depth + 1, node.id);
16
+ }
17
+ };
18
+ visit(roots, 0, undefined);
19
+ return rows;
20
+ }
21
+ /** Expands the node; returns the same set when the node is a leaf or already open. */
22
+ export function expandNode(expanded, row) {
23
+ if (!row.expandable || row.expanded)
24
+ return expanded;
25
+ return new Set(expanded).add(row.node.id);
26
+ }
27
+ /** Expands a collapsed node and collapses an expanded one; leaves are unchanged. */
28
+ export function toggleNode(expanded, row) {
29
+ if (!row.expandable)
30
+ return expanded;
31
+ const next = new Set(expanded);
32
+ if (row.expanded)
33
+ next.delete(row.node.id);
34
+ else
35
+ next.add(row.node.id);
36
+ return next;
37
+ }
38
+ /** Left arrow: collapse an open node, otherwise select its parent (a top-level node stays selected). */
39
+ export function collapseOrParent(expanded, rows, index) {
40
+ const row = rows[index];
41
+ if (!row)
42
+ return { expanded, selectedId: undefined };
43
+ if (row.expanded)
44
+ return { expanded: toggleNode(expanded, row), selectedId: row.node.id };
45
+ return { expanded, selectedId: row.parentId ?? row.node.id };
46
+ }
47
+ /** Index of the selected row: by stable id when still visible, otherwise the bounded fallback index. */
48
+ export function resolveSelection(rows, selection) {
49
+ const found = rows.findIndex((row) => row.node.id === selection.id);
50
+ if (found >= 0)
51
+ return found;
52
+ return Math.min(Math.max(selection.index, 0), Math.max(rows.length - 1, 0));
53
+ }
@@ -0,0 +1,178 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { describeUnmanagedRemoval, describeUnmanagedSymlinkRemoval, removeInspectedUnmanagedSkill, removeInspectedUnmanagedSymlink, } from "../unmanaged-removal.js";
4
+ import { inspectUserGlobalSkillRemoval, inspectUserGlobalSymlinkRemoval, } from "../user-global-skill-inventory.js";
5
+ import { answerYesNo, moveCursor } from "./mode-keys.js";
6
+ import { homeOf } from "./installations.js";
7
+ export const UNMANAGED_KINDS = ["unmanaged-scope", "unmanaged-pick", "confirm-unmanaged"];
8
+ const errorText = (error) => (error instanceof Error ? error.message : String(error));
9
+ /** Links first, so a Claude link never outlives the canonical directory it points to. */
10
+ function linksFirst(locations) {
11
+ return [...locations].sort((left, right) => Number(left.linkTarget === undefined) - Number(right.linkTarget === undefined));
12
+ }
13
+ /** The canonical form of a path, or its normalized form when it cannot be resolved (a dangling target). */
14
+ async function canonical(candidate) {
15
+ try {
16
+ return await realpath(candidate);
17
+ }
18
+ catch {
19
+ return path.resolve(candidate);
20
+ }
21
+ }
22
+ /** The links of the group that point at `directory`, whether or not they were chosen. */
23
+ async function linksTargeting(group, directory) {
24
+ const wanted = await canonical(directory);
25
+ const found = [];
26
+ for (const candidate of group.locations) {
27
+ if (candidate.linkTarget === undefined)
28
+ continue;
29
+ if (await canonical(path.resolve(path.dirname(candidate.path), candidate.linkTarget)) === wanted)
30
+ found.push(candidate);
31
+ }
32
+ return found;
33
+ }
34
+ /** Plan step: inspects every chosen location exactly as the CLI does; changes nothing. */
35
+ export async function prepareUnmanagedRemoval(operations, environment, group, locations) {
36
+ if (!operations.listUserGlobalInstallations)
37
+ throw new Error("Uninstall is not supported by the configured operations");
38
+ const installations = await operations.listUserGlobalInstallations();
39
+ const options = { homeDirectory: homeOf(environment), managedInstallations: installations };
40
+ const items = [];
41
+ const preview = [`WARNING: agent-depot did not create these files (unmanaged skill ${group.name})`];
42
+ // A chosen directory takes the links that point at it along, so none is left dangling.
43
+ const planned = new Map(locations.map((chosen) => [chosen.path, chosen]));
44
+ const dependents = new Map();
45
+ for (const chosen of locations) {
46
+ if (chosen.linkTarget !== undefined)
47
+ continue;
48
+ const links = await linksTargeting(group, chosen.path);
49
+ dependents.set(chosen.path, links);
50
+ for (const link of links) {
51
+ if (planned.has(link.path))
52
+ continue;
53
+ planned.set(link.path, link);
54
+ preview.push(`also removes link ${JSON.stringify(link.path)} → ${JSON.stringify(chosen.path)} (it would dangle)`);
55
+ }
56
+ }
57
+ for (const location of linksFirst([...planned.values()])) {
58
+ if (location.linkTarget !== undefined) {
59
+ const inspection = await inspectUserGlobalSymlinkRemoval(location.path, options);
60
+ items.push({ kind: "link", inspection });
61
+ preview.push(...describeUnmanagedSymlinkRemoval(inspection));
62
+ }
63
+ else {
64
+ const inspection = await inspectUserGlobalSkillRemoval(location.path, options);
65
+ items.push({ kind: "directory", inspection, links: (dependents.get(location.path) ?? []).map((link) => link.path) });
66
+ preview.push(...describeUnmanagedRemoval(inspection));
67
+ }
68
+ }
69
+ return { items, installations, preview };
70
+ }
71
+ /** Execute step: each location is rechecked and removed on its own, so one failure does not stop the others. */
72
+ export async function runUnmanagedRemoval(prepared, operations, environment) {
73
+ const context = { operations, homeDirectory: homeOf(environment) };
74
+ const lines = [];
75
+ let failed = false;
76
+ const notRemoved = new Set();
77
+ for (const item of prepared.items) {
78
+ const { path: location } = item.inspection;
79
+ const blocking = item.kind === "directory" ? item.links.find((link) => notRemoved.has(link)) : undefined;
80
+ if (blocking !== undefined) {
81
+ failed = true;
82
+ notRemoved.add(location);
83
+ lines.push(`Skipped ${location}: link ${blocking} could not be removed`);
84
+ continue;
85
+ }
86
+ try {
87
+ if (item.kind === "link")
88
+ await removeInspectedUnmanagedSymlink(context, item.inspection, prepared.installations);
89
+ else
90
+ await removeInspectedUnmanagedSkill(context, item.inspection, prepared.installations);
91
+ lines.push(`Removed ${location}`);
92
+ }
93
+ catch (error) {
94
+ failed = true;
95
+ notRemoved.add(location);
96
+ lines.push(`Failed ${location}: ${errorText(error)}`);
97
+ }
98
+ }
99
+ return { kind: failed ? "error" : "ok", text: lines.join("\n") };
100
+ }
101
+ async function startPreview(context, group, locations) {
102
+ context.setMode({ kind: "busy", label: `Preparing removal of ${group.name}...` });
103
+ try {
104
+ const prepared = await prepareUnmanagedRemoval(context.operations, context.env, group, locations);
105
+ if (!context.isMounted())
106
+ return;
107
+ context.setMode({ kind: "confirm-unmanaged", group, prepared });
108
+ }
109
+ catch (error) {
110
+ if (!context.isMounted())
111
+ return;
112
+ context.setMessage({ kind: "error", text: errorText(error) });
113
+ context.browse();
114
+ }
115
+ }
116
+ /** `u` on an unmanaged skill: one location goes straight to the preview; several ask all or choose. */
117
+ export function startUnmanagedRemoval(context, group) {
118
+ if (group.locations.length > 1)
119
+ context.setMode({ kind: "unmanaged-scope", group });
120
+ else
121
+ void startPreview(context, group, group.locations);
122
+ }
123
+ function cancel(context) {
124
+ context.setMessage({ kind: "ok", text: "Uninstall cancelled" });
125
+ context.browse();
126
+ }
127
+ async function execute(context, group, prepared) {
128
+ context.setMode({ kind: "busy", label: `Removing ${group.name}...` });
129
+ await context.finish(await runUnmanagedRemoval(prepared, context.operations, context.env));
130
+ }
131
+ /** Handles a key in an unmanaged removal mode; returns false for any other mode. */
132
+ export function handleUnmanagedKey(context, mode, input, key) {
133
+ const current = mode;
134
+ switch (current.kind) {
135
+ case "unmanaged-scope":
136
+ if (key.escape)
137
+ cancel(context);
138
+ else if (input === "1")
139
+ void startPreview(context, current.group, current.group.locations);
140
+ else if (input === "2")
141
+ context.setMode({ kind: "unmanaged-pick", group: current.group, cursor: 0, selected: [] });
142
+ return true;
143
+ case "unmanaged-pick": {
144
+ const { locations } = current.group;
145
+ const toggle = (index) => {
146
+ const location = locations[index];
147
+ if (!location)
148
+ return;
149
+ const selected = current.selected.includes(location.path)
150
+ ? current.selected.filter((candidate) => candidate !== location.path)
151
+ : [...current.selected, location.path];
152
+ context.setMode({ ...current, selected });
153
+ };
154
+ const cursor = moveCursor(current.cursor, locations.length, input, key);
155
+ if (key.escape)
156
+ cancel(context);
157
+ else if (cursor !== undefined)
158
+ context.setMode({ ...current, cursor });
159
+ else if (input === " ")
160
+ toggle(current.cursor);
161
+ else if (Number(input) >= 1 && Number(input) <= locations.length)
162
+ toggle(Number(input) - 1);
163
+ else if (key.return) {
164
+ const chosen = locations.filter((location) => current.selected.includes(location.path));
165
+ if (chosen.length > 0)
166
+ void startPreview(context, current.group, chosen);
167
+ else
168
+ context.setMessage({ kind: "error", text: "Select at least one location" });
169
+ }
170
+ return true;
171
+ }
172
+ case "confirm-unmanaged":
173
+ answerYesNo(input, key, () => void execute(context, current.group, current.prepared), () => cancel(context));
174
+ return true;
175
+ default:
176
+ return false;
177
+ }
178
+ }
@@ -0,0 +1,74 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ import { errorText } from "./batch.js";
3
+ import { BROWSE } from "./updates-mode.js";
4
+ import { CHECKING, emptyData, PHASE_LABEL, REFRESHING } from "./updates-model.js";
5
+ import { loadUpdateRows, prepareUpdates, runUpdates } from "./updates.js";
6
+ export function useUpdatesFlow(inputs) {
7
+ const { operations, env, clock, mounted, checkedRef, latest, setChecked, setCursor, setMode, setMessage } = inputs;
8
+ const [state, setState] = useState({ status: "loading", label: REFRESHING });
9
+ const loadToken = useRef(0);
10
+ const load = useCallback(async (refresh) => {
11
+ const token = ++loadToken.current;
12
+ const phase = (next) => {
13
+ if (!mounted.current || token !== loadToken.current)
14
+ return;
15
+ setState({ status: "loading", label: PHASE_LABEL[next] });
16
+ };
17
+ setChecked([]);
18
+ setCursor(0);
19
+ setMode({ kind: "busy", label: refresh ? REFRESHING : CHECKING });
20
+ let next;
21
+ try {
22
+ next = { status: "ready", data: await loadUpdateRows(operations, env, { refresh, onPhase: phase, now: clock }) };
23
+ }
24
+ catch (error) {
25
+ // The scopes are read separately, so this is unexpected; show it as the check's result.
26
+ next = { status: "ready", data: emptyData(errorText(error), clock()) };
27
+ }
28
+ // A newer check (`r`) supersedes this one.
29
+ if (!mounted.current || token !== loadToken.current)
30
+ return;
31
+ setState(next);
32
+ setMode(BROWSE);
33
+ }, [operations, env, mounted, setMode, setChecked, setCursor, clock]);
34
+ const startPreview = async () => {
35
+ const { data: current, updatable: listed } = latest();
36
+ if (!current)
37
+ return;
38
+ const selected = listed.filter((row) => checkedRef.current.includes(row.key));
39
+ if (selected.length === 0) {
40
+ setMessage({ kind: "error", lines: ["Select at least one update with space or a"] });
41
+ return;
42
+ }
43
+ setMessage(undefined);
44
+ setMode({ kind: "busy", label: "Preparing preview..." });
45
+ try {
46
+ const prepared = await prepareUpdates(current, selected);
47
+ if (mounted.current)
48
+ setMode({ kind: "preview", selected: selected.map((row) => row.item), prepared });
49
+ }
50
+ catch (error) {
51
+ if (!mounted.current)
52
+ return;
53
+ setMessage({ kind: "error", lines: [errorText(error)] });
54
+ setMode(BROWSE);
55
+ }
56
+ };
57
+ const apply = async (prepared, confirmedPaths) => {
58
+ setMode({ kind: "busy", label: "Applying updates..." });
59
+ let result;
60
+ try {
61
+ const outcome = await runUpdates(prepared, confirmedPaths);
62
+ result = { kind: outcome.failed === 0 ? "ok" : "error", lines: outcome.lines };
63
+ }
64
+ catch (error) {
65
+ result = { kind: "error", lines: [errorText(error)] };
66
+ }
67
+ if (!mounted.current)
68
+ return;
69
+ setMessage(result);
70
+ // The sources were fetched for the check that was just applied; only the installations changed.
71
+ await load(false);
72
+ };
73
+ return { state, load, startPreview, apply };
74
+ }