@iseoane/agent-depot 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +117 -3
  3. package/dist/src/app-cli.js +172 -0
  4. package/dist/src/app-flow.js +457 -0
  5. package/dist/src/app-latest.js +63 -0
  6. package/dist/src/app-owned-skills.js +42 -0
  7. package/dist/src/app-recipes.js +116 -0
  8. package/dist/src/app-schema.js +45 -0
  9. package/dist/src/app-version.js +46 -0
  10. package/dist/src/atomic-file.js +26 -0
  11. package/dist/src/cli.js +1256 -0
  12. package/dist/src/git-source.js +409 -0
  13. package/dist/src/path-safety.js +31 -0
  14. package/dist/src/process-runner.js +41 -0
  15. package/dist/src/project-installation.js +1411 -0
  16. package/dist/src/project-manifest.js +511 -0
  17. package/dist/src/skill-adoption.js +77 -0
  18. package/dist/src/skill-discovery.js +467 -0
  19. package/dist/src/skill-hosts.js +103 -0
  20. package/dist/src/skill-install.js +374 -0
  21. package/dist/src/skill-removal.js +119 -0
  22. package/dist/src/skill-update.js +244 -0
  23. package/dist/src/source-state.js +240 -0
  24. package/dist/src/sources.js +391 -0
  25. package/dist/src/tui/adoption-actions.js +144 -0
  26. package/dist/src/tui/app-actions.js +157 -0
  27. package/dist/src/tui/app.js +63 -0
  28. package/dist/src/tui/batch.js +17 -0
  29. package/dist/src/tui/bulk-actions.js +243 -0
  30. package/dist/src/tui/catalog-actions.js +9 -0
  31. package/dist/src/tui/catalog-browse.js +59 -0
  32. package/dist/src/tui/catalog-filter.js +20 -0
  33. package/dist/src/tui/catalog-flow.js +107 -0
  34. package/dist/src/tui/catalog-installs.js +193 -0
  35. package/dist/src/tui/catalog-panel.js +47 -0
  36. package/dist/src/tui/catalog-state.js +92 -0
  37. package/dist/src/tui/catalog-tree.js +35 -0
  38. package/dist/src/tui/catalog-view.js +89 -0
  39. package/dist/src/tui/environment.js +1 -0
  40. package/dist/src/tui/installations-browse.js +136 -0
  41. package/dist/src/tui/installations-mode.js +1 -0
  42. package/dist/src/tui/installations-panel.js +27 -0
  43. package/dist/src/tui/installations-rows.js +11 -0
  44. package/dist/src/tui/installations-tree.js +80 -0
  45. package/dist/src/tui/installations-view.js +154 -0
  46. package/dist/src/tui/installations.js +172 -0
  47. package/dist/src/tui/keys.js +31 -0
  48. package/dist/src/tui/manage-actions.js +174 -0
  49. package/dist/src/tui/manage-panel.js +65 -0
  50. package/dist/src/tui/mode-keys.js +61 -0
  51. package/dist/src/tui/panel-parts.js +23 -0
  52. package/dist/src/tui/render.js +8 -0
  53. package/dist/src/tui/sources-mode.js +17 -0
  54. package/dist/src/tui/sources-view.js +276 -0
  55. package/dist/src/tui/theme.js +29 -0
  56. package/dist/src/tui/tree-navigation.js +68 -0
  57. package/dist/src/tui/tree.js +53 -0
  58. package/dist/src/tui/unmanaged-actions.js +178 -0
  59. package/dist/src/tui/updates-flow.js +89 -0
  60. package/dist/src/tui/updates-keys.js +104 -0
  61. package/dist/src/tui/updates-mode.js +1 -0
  62. package/dist/src/tui/updates-model.js +48 -0
  63. package/dist/src/tui/updates-panel.js +44 -0
  64. package/dist/src/tui/updates-view.js +101 -0
  65. package/dist/src/tui/updates.js +230 -0
  66. package/dist/src/tui/view-state.js +42 -0
  67. package/dist/src/tui/window.js +31 -0
  68. package/dist/src/unmanaged-removal.js +60 -0
  69. package/dist/src/update-batch.js +376 -0
  70. package/dist/src/update-flow.js +135 -0
  71. package/dist/src/usage-error.js +7 -0
  72. package/dist/src/user-global-skill-inventory.js +565 -0
  73. package/dist/src/user-method.js +14 -0
  74. package/package.json +7 -4
  75. package/skills/agent-depot-apprecipe/SKILL.md +44 -0
  76. package/skills/agent-depot-apprecipe/assets/example.md +25 -0
  77. package/skills/doctor-md-agents/scripts/test_collect_sessions.py +0 -780
  78. package/skills/doctor-md-agents/scripts/test_extract_rules.py +0 -530
  79. package/skills/doctor-md-agents/scripts/test_render_report.py +0 -383
  80. package/skills/doctor-md-agents/scripts/test_scan_corrections.py +0 -343
  81. package/skills/doctor-md-agents/scripts/test_verify_citations.py +0 -236
  82. package/skills/doctor-md-agents/scripts/testdata/old_extract_rules_pre_t1.py.txt +0 -507
  83. package/skills/doctor-md-skill/scripts/test_collect_sessions.py +0 -593
  84. package/skills/doctor-md-skill/scripts/test_detect_machine_sessions.py +0 -181
  85. package/skills/doctor-md-skill/scripts/test_render_report.py +0 -241
  86. package/skills/doctor-md-skill/scripts/test_verify_citations.py +0 -196
@@ -0,0 +1,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), appEnvironment: environment.appEnvironment, 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), appEnvironment: environment.appEnvironment };
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,89 @@
1
+ import { useCallback, useEffect, 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 pendingAnswer = useRef(undefined);
11
+ useEffect(() => () => {
12
+ pendingAnswer.current?.(false);
13
+ }, []);
14
+ const load = useCallback(async (refresh) => {
15
+ const token = ++loadToken.current;
16
+ const phase = (next) => {
17
+ if (!mounted.current || token !== loadToken.current)
18
+ return;
19
+ setState({ status: "loading", label: PHASE_LABEL[next] });
20
+ };
21
+ setChecked([]);
22
+ setCursor(0);
23
+ setMode({ kind: "busy", label: refresh ? REFRESHING : CHECKING });
24
+ let next;
25
+ try {
26
+ next = { status: "ready", data: await loadUpdateRows(operations, env, { refresh, onPhase: phase, now: clock }) };
27
+ }
28
+ catch (error) {
29
+ // The scopes are read separately, so this is unexpected; show it as the check's result.
30
+ next = { status: "ready", data: emptyData(errorText(error), clock()) };
31
+ }
32
+ // A newer check (`r`) supersedes this one.
33
+ if (!mounted.current || token !== loadToken.current)
34
+ return;
35
+ setState(next);
36
+ setMode(BROWSE);
37
+ }, [operations, env, mounted, setMode, setChecked, setCursor, clock]);
38
+ const startPreview = async () => {
39
+ const { data: current, updatable: listed } = latest();
40
+ if (!current)
41
+ return;
42
+ const selected = listed.filter((row) => checkedRef.current.includes(row.key));
43
+ if (selected.length === 0) {
44
+ setMessage({ kind: "error", lines: ["Select at least one update with space or a"] });
45
+ return;
46
+ }
47
+ setMessage(undefined);
48
+ setMode({ kind: "busy", label: "Preparing preview..." });
49
+ try {
50
+ const prepared = await prepareUpdates(current, selected);
51
+ if (mounted.current)
52
+ setMode({ kind: "preview", prepared });
53
+ }
54
+ catch (error) {
55
+ if (!mounted.current)
56
+ return;
57
+ setMessage({ kind: "error", lines: [errorText(error)] });
58
+ setMode(BROWSE);
59
+ }
60
+ };
61
+ const apply = async (prepared, confirmedPaths) => {
62
+ setMode({ kind: "busy", label: "Applying updates..." });
63
+ let result;
64
+ try {
65
+ const outcome = await runUpdates(prepared, confirmedPaths, (plan, reason) => new Promise(resolve => {
66
+ if (!mounted.current) {
67
+ resolve(false);
68
+ return;
69
+ }
70
+ const answer = (done) => {
71
+ pendingAnswer.current = undefined;
72
+ resolve(done);
73
+ };
74
+ pendingAnswer.current = answer;
75
+ setMode({ kind: "manual", plan, reason, answer });
76
+ }));
77
+ result = { kind: outcome.failed === 0 ? "ok" : "error", lines: outcome.lines };
78
+ }
79
+ catch (error) {
80
+ result = { kind: "error", lines: [errorText(error)] };
81
+ }
82
+ if (!mounted.current)
83
+ return;
84
+ setMessage(result);
85
+ // The sources were fetched for the check that was just applied; only the installations changed.
86
+ await load(false);
87
+ };
88
+ return { state, load, startPreview, apply };
89
+ }
@@ -0,0 +1,104 @@
1
+ import { answerYesNo } from "./mode-keys.js";
2
+ import { pageStep } from "./window.js";
3
+ const SCOPE_KEYS = new Map([["t", "all"], ["p", "project"], ["g", "user-global"]]);
4
+ /** Moves the cursor with the arrows, j/k and paging; returns whether the key was one of them. */
5
+ function moveCursorKey(context, snapshot, input, key) {
6
+ const last = Math.max(snapshot.entries.length - 1, 0);
7
+ const { at } = snapshot;
8
+ if (key.downArrow || input === "j")
9
+ context.setCursor(Math.min(at + 1, last));
10
+ else if (key.upArrow || input === "k")
11
+ context.setCursor(Math.max(at - 1, 0));
12
+ else if (key.pageDown)
13
+ context.setCursor(Math.min(at + pageStep(context.height), last));
14
+ else if (key.pageUp)
15
+ context.setCursor(Math.max(at - pageStep(context.height), 0));
16
+ else
17
+ return false;
18
+ return true;
19
+ }
20
+ /** Space toggles the highlighted update, `a` selects them all. */
21
+ function selectKey(context, snapshot, input) {
22
+ if (input === " ") {
23
+ const entry = snapshot.entries[snapshot.at];
24
+ if (entry?.kind !== "update")
25
+ return true;
26
+ const checked = context.checked();
27
+ context.setChecked(checked.includes(entry.row.key) ? checked.filter((id) => id !== entry.row.key) : [...checked, entry.row.key]);
28
+ }
29
+ else if (input === "a")
30
+ context.setChecked(snapshot.updatable.map((row) => row.key));
31
+ else
32
+ return false;
33
+ return true;
34
+ }
35
+ /** Right/left unfold and fold the "cannot be checked" rows; Enter toggles the fold or previews the selection. */
36
+ function foldKey(context, snapshot, key) {
37
+ const entry = snapshot.entries[snapshot.at];
38
+ if (key.rightArrow) {
39
+ if (entry?.kind === "summary")
40
+ context.setShowUnknown(true);
41
+ }
42
+ else if (key.leftArrow) {
43
+ if (entry?.kind === "unknown")
44
+ context.setCursor(snapshot.updatable.length);
45
+ if (entry?.kind === "summary" || entry?.kind === "unknown")
46
+ context.setShowUnknown(false);
47
+ }
48
+ else if (key.return) {
49
+ if (entry?.kind === "summary")
50
+ context.setShowUnknown(!context.unknownShown());
51
+ else if (entry?.kind !== "unknown" && snapshot.data)
52
+ context.startPreview();
53
+ }
54
+ }
55
+ function handleBrowseKey(context, input, key) {
56
+ const snapshot = context.latest();
57
+ if (moveCursorKey(context, snapshot, input, key) || selectKey(context, snapshot, input))
58
+ return;
59
+ const scope = SCOPE_KEYS.get(input);
60
+ if (scope !== undefined)
61
+ context.changeScope(scope);
62
+ else if (input === "r")
63
+ context.recheck();
64
+ else
65
+ foldKey(context, snapshot, key);
66
+ }
67
+ function handlePreviewKey(context, mode, input, key) {
68
+ if (mode.prepared.applicable.length === 0) {
69
+ if (key.escape || key.return || input === "n")
70
+ context.cancel();
71
+ return;
72
+ }
73
+ answerYesNo(input, key, () => {
74
+ if (mode.prepared.nonCanonicalPaths.length > 0)
75
+ context.setMode({ ...mode, kind: "confirm-paths" });
76
+ else
77
+ context.apply(mode.prepared, []);
78
+ }, context.cancel);
79
+ }
80
+ /** Handles a key in the current Updates mode; `busy` ignores keys. */
81
+ export function handleUpdatesKey(context, mode, input, key) {
82
+ switch (mode.kind) {
83
+ case "manual":
84
+ answerYesNo(input, key, () => {
85
+ context.setMode({ kind: "busy", label: "Checking App version..." });
86
+ mode.answer(true);
87
+ }, () => {
88
+ context.setMode({ kind: "busy", label: "Continuing updates..." });
89
+ mode.answer(false);
90
+ });
91
+ return;
92
+ case "browse":
93
+ handleBrowseKey(context, input, key);
94
+ return;
95
+ case "preview":
96
+ handlePreviewKey(context, mode, input, key);
97
+ return;
98
+ case "confirm-paths":
99
+ answerYesNo(input, key, () => context.apply(mode.prepared, mode.prepared.nonCanonicalPaths), context.cancel);
100
+ return;
101
+ default:
102
+ return;
103
+ }
104
+ }
@@ -0,0 +1 @@
1
+ export const BROWSE = { kind: "browse" };
@@ -0,0 +1,48 @@
1
+ export const REFRESHING = "Refreshing sources...";
2
+ export const CHECKING = "Checking for updates...";
3
+ export const PHASE_LABEL = { refreshing: REFRESHING, checking: CHECKING };
4
+ /** Names the reasons worth knowing before expanding the collapsed "cannot be checked" row. */
5
+ export function describeUnknownReasons(rows) {
6
+ const newer = rows.filter((row) => /^installed by a newer agent depot/i.test(row.reason)).length;
7
+ const pinned = rows.filter((row) => /^pinned to agent depot/i.test(row.reason)).length;
8
+ const parts = [
9
+ ...(newer > 0 ? [`${newer} installed by a newer Agent Depot`] : []),
10
+ ...(pinned > 0 ? [`${pinned} pinned to another Agent Depot`] : []),
11
+ ];
12
+ return parts.length === 0 ? "" : ` (${parts.join(", ")})`;
13
+ }
14
+ const matchesScope = (row, filter) => filter === "all" || row.scope === filter;
15
+ /** The rows the list shows for a scope filter and fold state, and the cursor positions they make up. */
16
+ export function viewOf(data, scope, showUnknown) {
17
+ const shown = (data?.rows ?? []).filter((row) => matchesScope(row, scope));
18
+ const updatable = shown.filter((row) => row.status === "update available");
19
+ const unknown = shown.filter((row) => row.status === "cannot assess");
20
+ const upToDate = shown.filter((row) => row.status === "up to date").length;
21
+ const entries = [
22
+ ...updatable.map((row) => ({ kind: "update", row })),
23
+ ...(unknown.length > 0 ? [{ kind: "summary" }] : []),
24
+ ...(unknown.length > 0 && showUnknown ? unknown.map((row) => ({ kind: "unknown", row })) : []),
25
+ ];
26
+ return { shown, updatable, unknown, upToDate, entries };
27
+ }
28
+ export function describeRefresh(data) {
29
+ const { attempted, refreshed, failed, unreadable } = data.refresh;
30
+ if (!attempted)
31
+ return "sources not fetched again";
32
+ if (unreadable.length > 0) {
33
+ return `could not refresh: ${unreadable.map(({ scope, reason }) => `${reason} (${scope})`).join("; ")}; cached data was used`;
34
+ }
35
+ if (failed.length > 0) {
36
+ return `${failed.length} of ${failed.length + refreshed.length} sources could not be refreshed; their current data was used`;
37
+ }
38
+ return refreshed.length > 0 ? "sources refreshed" : "no Git sources to refresh";
39
+ }
40
+ /** Data for a check that failed as a whole, so the view can show the reason. */
41
+ export function emptyData(reason, checkedAt) {
42
+ return {
43
+ results: [{ scope: "project", error: reason }],
44
+ rows: [],
45
+ refresh: { attempted: false, refreshed: [], failed: [], unreadable: [] },
46
+ checkedAt,
47
+ };
48
+ }
@@ -0,0 +1,44 @@
1
+ import { jsxs as _jsxs, Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { rowStyle, theme } from "./theme.js";
4
+ import { CHECKING, describeRefresh, describeUnknownReasons, REFRESHING } from "./updates-model.js";
5
+ import { formatAgo } from "./updates.js";
6
+ import { PreviewLines } from "./panel-parts.js";
7
+ /** What the last check found out: its age, how the sources were refreshed, and what failed. */
8
+ export function CheckStatus({ data, now }) {
9
+ const errors = data.results.filter((result) => result.error !== undefined);
10
+ return (_jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.muted, children: ["checked ", formatAgo(now - data.checkedAt), " \u00B7 ", describeRefresh(data)] }), data.refresh.failed.map((failure) => (_jsxs(Text, { color: theme.warning, children: ["refresh failed for ", failure.source, ": ", failure.reason] }, failure.source))), data.appError ? _jsxs(Text, { color: theme.error, children: ["Error (Apps): ", data.appError] }) : null, errors.map((result) => _jsxs(Text, { color: theme.error, children: ["Error (", result.scope, "): ", result.error] }, result.scope))] }));
11
+ }
12
+ /** One line of the list: an update, the fold of unassessable items, or one of those when unfolded. */
13
+ export function EntryLine({ entry, selected, checked, view, showUnknown }) {
14
+ const cursor = selected ? "> " : " ";
15
+ if (entry.kind === "summary") {
16
+ return (_jsxs(Text, { ...rowStyle(selected), color: theme.inactive, children: [cursor, view.unknown.length, " cannot be checked ", showUnknown ? "▾" : "▸", describeUnknownReasons(view.unknown)] }));
17
+ }
18
+ const { row } = entry;
19
+ if (entry.kind === "unknown") {
20
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { ...rowStyle(selected), children: [cursor, " ", row.path, " ", row.scope] }), _jsxs(Text, { color: theme.muted, children: [" ", row.reason] })] }));
21
+ }
22
+ return (_jsxs(Text, { ...rowStyle(selected), children: [cursor, checked ? "[x]" : "[ ]", " ", row.path, " ", row.scope, " ", row.policy, " ", row.installed, " ", "->", " ", row.available] }));
23
+ }
24
+ /** Under the list: the window indicator, what is up to date or selected, and the last result. */
25
+ export function UpdatesFooter({ indicator, upToDate, updatableCount, checkedCount, message }) {
26
+ return (_jsxs(_Fragment, { children: [indicator ? _jsx(Text, { color: theme.muted, children: indicator }) : null, upToDate > 0 ? _jsxs(Text, { color: theme.muted, children: [upToDate, " up to date"] }) : null, updatableCount > 0 ? _jsxs(Text, { color: theme.muted, children: [checkedCount, " selected"] }) : null, message ? (_jsx(Box, { flexDirection: "column", children: message.lines.map((line, position) => _jsx(Text, { color: message.kind === "error" ? theme.error : theme.success, children: line }, position)) })) : null] }));
27
+ }
28
+ /** The prompt of the preview and confirmation modes; browsing and the check itself show nothing. */
29
+ export function ModePanel({ mode }) {
30
+ switch (mode.kind) {
31
+ case "manual":
32
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["App: ", mode.plan.entry.recipe.name] }), _jsxs(Text, { children: ["Environment: ", mode.plan.environment] }), _jsxs(Text, { children: ["Working directory: ", mode.plan.cwd] }), _jsxs(Text, { children: ["Manual: ", mode.plan.manual] }), mode.reason ? _jsx(Text, { color: theme.warning, children: mode.reason }) : null, _jsx(Text, { children: "Manual step done, check now? y/n" })] }));
33
+ case "browse":
34
+ return null;
35
+ case "busy":
36
+ return mode.label === REFRESHING || mode.label === CHECKING ? null : _jsx(Text, { children: mode.label });
37
+ case "preview":
38
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(PreviewLines, { lines: mode.prepared.lines }), _jsx(PreviewLines, { lines: mode.prepared.appFailures }), mode.prepared.failures.map(({ item, message }) => (_jsxs(Text, { color: theme.error, children: ["Preview failed for ", JSON.stringify(item.id), ": ", message] }, item.id))), mode.prepared.applicable.length === 0
39
+ ? _jsx(Text, { children: "Nothing can be applied. Enter/Esc to go back" })
40
+ : _jsxs(Text, { children: ["Apply ", mode.prepared.applicable.length, " ", mode.prepared.applicable.length === 1 ? "update" : "updates", "? y/n"] })] }));
41
+ case "confirm-paths":
42
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.warning, children: "Confirm replacing non-canonical locations (outside .agents/skills and .claude/skills):" }), mode.prepared.nonCanonicalPaths.map((location, position) => _jsxs(Text, { color: theme.warning, children: [" [", location.scope, "] ", JSON.stringify(location.path)] }, position)), _jsx(Text, { children: "Replace exactly these paths? y/n" })] }));
43
+ }
44
+ }