@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,47 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { describeSkills, HOST_CHOICES, SCOPE_CHOICES } from "./catalog-actions.js";
4
+ import { skillKey } from "./catalog-tree.js";
5
+ import { hasUnmanagedCopy } from "./catalog-installs.js";
6
+ import { describeRow, UNMANAGED_MARKER } from "./catalog-tree.js";
7
+ import { HostChecklist, PreviewLines } from "./panel-parts.js";
8
+ import { rowStyle } from "./theme.js";
9
+ import { theme } from "./theme.js";
10
+ /** Scope and counts, the filter line, and why the list may be empty. */
11
+ export function CatalogHeader({ scope, listed, installable, total, filter }) {
12
+ return (_jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.muted, children: ["Scope: ", scope, " ", listed, " of ", installable] }), filter.editing || filter.query !== "" ? (_jsxs(Text, { children: ["Filter: ", filter.query, filter.editing ? "_" : ""] })) : null, total === 0 ? _jsx(Text, { children: "No skills" }) : null, total > 0 && installable === 0 ? _jsx(Text, { children: "All skills are installed" }) : null, installable > 0 && listed === 0 ? _jsx(Text, { children: "No matching skills" }) : null] }));
13
+ }
14
+ /** One line of the Catalog tree: cursor, indent, mark box, label and the unmanaged-copy marker. */
15
+ export function CatalogRowLine({ row, selected, marked, installed }) {
16
+ const { data } = row.node;
17
+ const isSource = data.kind === "source";
18
+ return (_jsxs(Text, { ...rowStyle(selected), color: isSource ? theme.group : undefined, children: [selected ? "> " : " ", " ".repeat(row.depth), isSource ? "" : marked ? "[x] " : "[ ] ", describeRow(row), data.kind === "skill" && hasUnmanagedCopy(data.skill, installed)
19
+ ? _jsxs(Text, { color: theme.marker, children: [" ", UNMANAGED_MARKER] })
20
+ : null] }));
21
+ }
22
+ /** The prompt of the install flow; browsing shows nothing. */
23
+ export function ActionPanel({ mode }) {
24
+ switch (mode.kind) {
25
+ case "browse":
26
+ return null;
27
+ case "host":
28
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Install ", describeSkills(mode.skills), ". Host (space/1-4 toggle, j/k move, Enter continue, Esc cancel):"] }), _jsx(HostChecklist, { choices: HOST_CHOICES, cursor: mode.cursor, selected: mode.selected })] }));
29
+ case "scope":
30
+ return _jsxs(Text, { children: ["Scope: ", SCOPE_CHOICES.map((scope, i) => `${i + 1} ${scope}`).join(" "), " (Esc cancel)"] });
31
+ case "version":
32
+ return _jsx(Text, { children: "Version: 1 latest 2 fixed (Esc cancel)" });
33
+ case "fixed":
34
+ return _jsxs(Text, { children: ["Fixed version: ", mode.value, "_ (Enter confirm, Esc cancel)"] });
35
+ case "confirm-install": {
36
+ const ready = mode.items.filter((item) => item.prepared !== undefined);
37
+ const single = mode.items.length === 1;
38
+ return (_jsxs(Box, { flexDirection: "column", children: [mode.items.map(({ skill, prepared, error }) => prepared === undefined ? (_jsxs(Text, { color: theme.error, children: ["Cannot install ", skill.name, ": ", error] }, skillKey(skill))) : (_jsxs(Box, { flexDirection: "column", children: [single ? null : _jsx(Text, { color: theme.group, children: skill.name }), _jsx(PreviewLines, { lines: prepared.preview })] }, skillKey(skill)))), ready.some((item) => item.prepared?.runsExternalCommand) ? _jsx(Text, { color: theme.warning, children: "This install runs an external command (see above)." }) : null, _jsxs(Text, { children: ["Install ", describeSkills(ready.map((item) => item.skill)), "? y/n"] })] }));
39
+ }
40
+ case "confirm-exposure": {
41
+ const names = mode.items.filter((item) => item.prepared?.additionalHostExposure).map((item) => item.skill.name);
42
+ return (_jsxs(Text, { color: theme.warning, children: [names.length === 1 ? `An identical ${names[0]} already exists` : `Identical copies of ${names.join(", ")} already exist`, "; adding the missing Host location needs separate confirmation (CLI: --confirm-additional-host). Confirm? y/n"] }));
43
+ }
44
+ case "busy":
45
+ return _jsx(Text, { children: mode.label });
46
+ }
47
+ }
@@ -0,0 +1,92 @@
1
+ import { useCallback, useEffect, useReducer, useRef, useState } from "react";
2
+ import { filterReducer, initialFilter } from "./catalog-filter.js";
3
+ import { loadInstalledSkills, NO_INSTALLED } from "./catalog-installs.js";
4
+ /** Discovers the Skills of one Source, or of all of them, and again whenever those inputs change. */
5
+ export function useCatalogSkills(operations, sourceId, all) {
6
+ const [state, setState] = useState({ status: "loading" });
7
+ useEffect(() => {
8
+ let cancelled = false;
9
+ if (!operations.discoverSkills) {
10
+ setState({ status: "error", message: "Discovery is not supported by the configured operations" });
11
+ return;
12
+ }
13
+ setState({ status: "loading" });
14
+ void (async () => {
15
+ try {
16
+ const ids = all || sourceId === undefined
17
+ ? (await operations.listSources()).map((source) => source.id)
18
+ : [sourceId];
19
+ const skills = await operations.discoverSkills(ids);
20
+ if (!cancelled)
21
+ setState({ status: "ready", skills });
22
+ }
23
+ catch (error) {
24
+ if (!cancelled)
25
+ setState({ status: "error", message: error instanceof Error ? error.message : String(error) });
26
+ }
27
+ })();
28
+ return () => {
29
+ cancelled = true;
30
+ };
31
+ }, [operations, sourceId, all]);
32
+ return state;
33
+ }
34
+ /** The Skills already installed, loaded on mount and on demand. */
35
+ export function useInstalledSkills(operations, env, mounted) {
36
+ const [installed, setInstalled] = useState(NO_INSTALLED);
37
+ const [installedReady, setInstalledReady] = useState(false);
38
+ const reloadInstalled = useCallback(async () => {
39
+ const loaded = await loadInstalledSkills(operations, env);
40
+ if (!mounted.current)
41
+ return;
42
+ setInstalled(loaded);
43
+ setInstalledReady(true);
44
+ }, [operations, env, mounted]);
45
+ useEffect(() => {
46
+ void reloadInstalled();
47
+ }, [reloadInstalled]);
48
+ return { installed, installedReady, reloadInstalled };
49
+ }
50
+ /**
51
+ * The filter box. Its state is mirrored in refs, updated first, so the shell
52
+ * never sees a stale capturing state and keystrokes delivered in one burst are not lost.
53
+ */
54
+ export function useCatalogFilter(actionRef, onCapturingChange) {
55
+ const [filter, dispatchFilter] = useReducer(filterReducer, initialFilter);
56
+ const filterRef = useRef(initialFilter);
57
+ // The typed query, so keystrokes delivered in one burst are not lost to stale closures.
58
+ const typed = useRef("");
59
+ const queryRef = useRef(filter.query);
60
+ queryRef.current = filter.query;
61
+ const dispatch = (event) => {
62
+ filterRef.current = filterReducer(filterRef.current, event);
63
+ onCapturingChange?.(filterRef.current.editing || actionRef.current.kind !== "browse");
64
+ dispatchFilter(event);
65
+ };
66
+ const edit = (query, restartList) => {
67
+ typed.current = query;
68
+ restartList();
69
+ dispatch({ type: "edit", query });
70
+ };
71
+ const clear = (restartList) => {
72
+ typed.current = "";
73
+ queryRef.current = "";
74
+ restartList();
75
+ dispatch({ type: "clear" });
76
+ };
77
+ const open = () => {
78
+ typed.current = filterRef.current.query;
79
+ dispatch({ type: "open" });
80
+ };
81
+ const handleEditKey = (input, key, restartList) => {
82
+ if (key.escape)
83
+ clear(restartList);
84
+ else if (key.return)
85
+ dispatch({ type: "keep" });
86
+ else if (key.backspace || key.delete)
87
+ edit(typed.current.slice(0, -1), restartList);
88
+ else if (input !== "" && !key.ctrl && !key.meta)
89
+ edit(typed.current + input, restartList);
90
+ };
91
+ return { filter, filterRef, queryRef, open, clear, handleEditKey };
92
+ }
@@ -0,0 +1,35 @@
1
+ import { defaultExpanded } from "./tree.js";
2
+ const DESCRIPTION_LIMIT = 60;
3
+ export const UNMANAGED_MARKER = "on disk, unmanaged · adopt it from Installations";
4
+ export const skillKey = (skill) => `skill:${skill.sourceId}:${skill.path}`;
5
+ function truncate(text) {
6
+ return text.length > DESCRIPTION_LIMIT ? `${text.slice(0, DESCRIPTION_LIMIT - 3)}...` : text;
7
+ }
8
+ /** Skills grouped by Source in first-seen order; a Source with no Skills never appears. */
9
+ export function buildTree(skills) {
10
+ const bySource = new Map();
11
+ for (const skill of skills)
12
+ bySource.set(skill.sourceId, [...(bySource.get(skill.sourceId) ?? []), skill]);
13
+ return [...bySource].map(([sourceId, members]) => ({
14
+ id: `source:${sourceId}`,
15
+ data: { kind: "source", sourceId, count: members.length },
16
+ children: members.map((skill) => ({ id: skillKey(skill), data: { kind: "skill", skill } })),
17
+ }));
18
+ }
19
+ function allSourceIds(roots) {
20
+ return new Set(roots.filter((root) => (root.children?.length ?? 0) > 0).map((root) => root.id));
21
+ }
22
+ /** While a filter is set every matching source is open so the matches show; otherwise only the first. */
23
+ export function openByDefault(nodes, query) {
24
+ return query === "" ? defaultExpanded(nodes) : allSourceIds(nodes);
25
+ }
26
+ export function describeRow(row) {
27
+ const { data } = row.node;
28
+ if (data.kind === "source")
29
+ return `${row.expanded ? "▾" : "▸"} ${data.sourceId} (${data.count})`;
30
+ return `${data.skill.name} ${truncate(data.skill.description)}`;
31
+ }
32
+ /** The Skills under the tree's Sources, in display order. */
33
+ export function leavesOf(roots) {
34
+ return roots.flatMap((root) => (root.children ?? []).flatMap((child) => child.data.kind === "skill" ? [child.data.skill] : []));
35
+ }
@@ -0,0 +1,89 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { BROWSE } from "./catalog-actions.js";
5
+ import { handleCatalogBrowseKey } from "./catalog-browse.js";
6
+ import { filterSkills } from "./catalog-filter.js";
7
+ import { handleCatalogActionKey } from "./catalog-flow.js";
8
+ import { isInstalled } from "./catalog-installs.js";
9
+ import { ActionPanel, CatalogHeader, CatalogRowLine } from "./catalog-panel.js";
10
+ import { ListFooter } from "./panel-parts.js";
11
+ import { useCatalogFilter, useCatalogSkills, useInstalledSkills } from "./catalog-state.js";
12
+ import { buildTree, leavesOf, openByDefault, skillKey } from "./catalog-tree.js";
13
+ import { theme } from "./theme.js";
14
+ import { useTreeNavigation } from "./tree-navigation.js";
15
+ import { useMarks, useMounted } from "./view-state.js";
16
+ import { computeWindow, useListHeight } from "./window.js";
17
+ import { useKeys } from "./keys.js";
18
+ const NO_ENVIRONMENT = {};
19
+ export function CatalogView({ operations, sourceId, onCapturingChange, environment, listHeight }) {
20
+ const height = useListHeight(listHeight, 6);
21
+ const env = environment ?? NO_ENVIRONMENT;
22
+ const [all, setAll] = useState(sourceId === undefined);
23
+ const mounted = useMounted();
24
+ const marks = useMarks();
25
+ const [message, setMessage] = useState();
26
+ const [action, setActionState] = useState(BROWSE);
27
+ // Mirrors the action mode synchronously, like the filter, so later keystrokes and the shell never see stale state.
28
+ const actionRef = useRef(BROWSE);
29
+ const filter = useCatalogFilter(actionRef, onCapturingChange);
30
+ const state = useCatalogSkills(operations, sourceId, all);
31
+ const { installed, installedReady, reloadInstalled } = useInstalledSkills(operations, env, mounted);
32
+ const setAction = (next) => {
33
+ actionRef.current = next;
34
+ onCapturingChange?.(filter.filterRef.current.editing || next.kind !== "browse");
35
+ setActionState(next);
36
+ };
37
+ const skills = state.status === "ready" ? state.skills : [];
38
+ const installable = useMemo(() => skills.filter((skill) => !isInstalled(skill, installed)), [state, installed]);
39
+ const visible = useMemo(() => filterSkills(installable, filter.filter.query), [installable, filter.filter.query]);
40
+ const roots = useMemo(() => buildTree(visible), [visible]);
41
+ const navigation = useTreeNavigation(roots, (nodes) => openByDefault(nodes, filter.queryRef.current));
42
+ // Discovering again starts the list over.
43
+ const { restart } = navigation;
44
+ useEffect(restart, [operations, sourceId, all, restart]);
45
+ const { rows, index } = navigation;
46
+ const flow = {
47
+ operations,
48
+ env,
49
+ setAction,
50
+ setMessage,
51
+ isMounted: () => mounted.current,
52
+ keepMarked: marks.setMarked,
53
+ finish: async (result) => {
54
+ await reloadInstalled();
55
+ if (!mounted.current)
56
+ return;
57
+ setMessage(result);
58
+ setAction(BROWSE);
59
+ },
60
+ };
61
+ const browse = {
62
+ navigation,
63
+ height,
64
+ marks,
65
+ filter,
66
+ canWiden: sourceId !== undefined,
67
+ ready: state.status === "ready",
68
+ toggleAll: () => setAll((value) => !value),
69
+ setMessage,
70
+ setAction,
71
+ };
72
+ useKeys((input, key) => {
73
+ const mode = actionRef.current;
74
+ if (mode.kind !== "browse")
75
+ handleCatalogActionKey(flow, mode, input, key);
76
+ else if (filter.filterRef.current.editing)
77
+ filter.handleEditKey(input, key, navigation.restart);
78
+ else
79
+ handleCatalogBrowseKey(browse, input, key);
80
+ });
81
+ const scope = all || sourceId === undefined ? "all sources" : sourceId;
82
+ if (state.status === "loading" || (state.status === "ready" && !installedReady))
83
+ return _jsx(Text, { children: "Loading skills..." });
84
+ if (state.status === "error")
85
+ return _jsxs(Text, { color: theme.error, children: ["Error: ", state.message] });
86
+ const window = computeWindow(rows.length, index, height);
87
+ const selectedCount = leavesOf(roots).filter((skill) => marks.marked.has(skillKey(skill))).length;
88
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(CatalogHeader, { scope: scope, listed: visible.length, installable: installable.length, total: skills.length, filter: filter.filter }), rows.slice(window.start, window.end).map((row, offset) => (_jsx(CatalogRowLine, { row: row, selected: window.start + offset === index, marked: marks.marked.has(row.node.id), installed: installed }, row.node.id))), _jsx(ListFooter, { indicator: window.indicator, selectedCount: selectedCount, message: message }), _jsx(ActionPanel, { mode: action })] }));
89
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,114 @@
1
+ import { startBulkHostAddition, startBulkUninstall } from "./bulk-actions.js";
2
+ import { skillOf, sourceIdOf } from "./installations.js";
3
+ import { leavesOf, targetOf } from "./installations-tree.js";
4
+ import { startHostAddition, startUninstall } from "./manage-actions.js";
5
+ import { startUnmanagedRemoval } from "./unmanaged-actions.js";
6
+ import { navigateTree } from "./tree-navigation.js";
7
+ import { toggleNode } from "./tree.js";
8
+ import { toggledMark } from "./view-state.js";
9
+ /** Why a key does nothing on a group row. */
10
+ const GROUP_ROW_MESSAGE = {
11
+ A: "Adoption is not available for group rows",
12
+ u: "Uninstall is not available for group rows",
13
+ h: "Host changes are not available for group rows",
14
+ i: "Host changes are not available for group rows",
15
+ };
16
+ /** The marked leaves in display order; marks whose rows no longer exist are ignored. */
17
+ function markedTargets(context) {
18
+ const roots = context.navigation.latestRoots();
19
+ return leavesOf(roots).filter((node) => context.marks.markedRef.current.has(node.id)).flatMap((node) => targetOf(node) ?? []);
20
+ }
21
+ function markRow(context, row) {
22
+ if (!row)
23
+ return;
24
+ if (row.node.data.kind === "group") {
25
+ context.setMessage({ kind: "error", text: "Mark skills, not group rows" });
26
+ return;
27
+ }
28
+ context.setMessage(undefined);
29
+ context.marks.setMarked(toggledMark(context.marks.markedRef.current, row.node.id));
30
+ }
31
+ /** `a`: marks every listed leaf, or unmarks them when they are all marked already. */
32
+ function markAllListed(context, visible) {
33
+ const { markedRef, setMarked } = context.marks;
34
+ const leaves = visible.filter((candidate) => candidate.node.data.kind !== "group").map((candidate) => candidate.node.id);
35
+ if (leaves.length === 0) {
36
+ context.setMessage({ kind: "error", text: "There are no skills to select" });
37
+ return;
38
+ }
39
+ context.setMessage(undefined);
40
+ const all = leaves.every((id) => markedRef.current.has(id));
41
+ setMarked(all
42
+ ? new Set([...markedRef.current].filter((id) => !leaves.includes(id)))
43
+ : new Set([...markedRef.current, ...leaves]));
44
+ }
45
+ function actOnUnmanaged(context, group, input, key) {
46
+ if (input === "u")
47
+ startUnmanagedRemoval(context.manage, group);
48
+ else if (input === "A" || key.return)
49
+ context.startAdoption(group);
50
+ else
51
+ context.setMessage({ kind: "error", text: "Host changes are not available for unmanaged skills" });
52
+ }
53
+ function actOnInstallation(context, data, item, input, key) {
54
+ if (input === "A" || key.return) {
55
+ context.setMessage({ kind: "error", text: "Adoption applies to unmanaged skills; u uninstalls and h adds hosts here" });
56
+ return;
57
+ }
58
+ if (item.row.scope === "project") {
59
+ context.setMessage({ kind: "error", text: input === "u" ? "Project uninstall is not supported" : "Project installations are managed with the CLI" });
60
+ return;
61
+ }
62
+ const sourceId = sourceIdOf(item.row.selection, data.sources);
63
+ if (sourceId === undefined) {
64
+ context.setMessage({ kind: "error", text: "The Source of this installation is no longer registered" });
65
+ return;
66
+ }
67
+ const skill = skillOf(item.row, sourceId);
68
+ if (input === "u")
69
+ startUninstall(context.manage, skill);
70
+ else
71
+ startHostAddition(context.manage, skill);
72
+ }
73
+ /** `A`, Enter, `u`, `h` and `i` on the highlighted row. */
74
+ function actOnRow(context, snapshot, row, input, key) {
75
+ const { data } = context;
76
+ if (!row || !data)
77
+ return;
78
+ const item = row.node.data;
79
+ if (item.kind === "group") {
80
+ if (key.return)
81
+ context.navigation.setExpanded(toggleNode(snapshot.open, row));
82
+ else
83
+ context.setMessage({ kind: "error", text: GROUP_ROW_MESSAGE[input] });
84
+ return;
85
+ }
86
+ context.setMessage(undefined);
87
+ if (item.kind === "unmanaged")
88
+ actOnUnmanaged(context, item.group, input, key);
89
+ else
90
+ actOnInstallation(context, data, item, input, key);
91
+ }
92
+ /** Keys while browsing: navigation, marks, bulk actions and the per-row actions. */
93
+ export function handleBrowseKey(context, input, key) {
94
+ const snapshot = context.navigation.latest();
95
+ if (navigateTree(context.navigation, snapshot, context.height, input, key))
96
+ return;
97
+ const row = snapshot.visible[snapshot.at];
98
+ const rowAction = input === "A" || key.return || input === "u" || input === "h" || input === "i";
99
+ if (input === " ")
100
+ markRow(context, row);
101
+ else if (input === "a")
102
+ markAllListed(context, snapshot.visible);
103
+ else if ((input === "u" || input === "h" || input === "i") && context.data && markedTargets(context).length > 0) {
104
+ // Marks, including those on collapsed rows, turn the action into a bulk one.
105
+ context.setMessage(undefined);
106
+ const targets = markedTargets(context);
107
+ if (input === "u")
108
+ void startBulkUninstall(context.manage, targets);
109
+ else
110
+ startBulkHostAddition(context.manage, targets);
111
+ }
112
+ else if (rowAction)
113
+ actOnRow(context, snapshot, row, input, key);
114
+ }
@@ -0,0 +1 @@
1
+ export const BROWSE = { kind: "browse" };
@@ -0,0 +1,27 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { HOST_CHOICES } from "./catalog-actions.js";
4
+ import { ManagePanel } from "./manage-panel.js";
5
+ import { HostChecklist, PreviewLines } from "./panel-parts.js";
6
+ import { theme } from "./theme.js";
7
+ /** The prompt of the adoption and manage modes; browsing shows nothing. */
8
+ export function ModePanel({ mode }) {
9
+ switch (mode.kind) {
10
+ case "browse":
11
+ return null;
12
+ case "pick":
13
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Adopt ", mode.entry.name, ": several Source Skills match. j/k move, Enter select, Esc cancel:"] }), mode.candidates.map((skill, position) => (_jsxs(Text, { children: [position === mode.cursor ? "> " : " ", skill.path, " ", skill.sourceId] }, `${skill.sourceId}:${skill.path}`)))] }));
14
+ case "host":
15
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Adopt ", mode.entry.name, " as ", mode.skill.path, ". Host (space/1-4 toggle, j/k move, Enter continue, Esc cancel):"] }), _jsx(HostChecklist, { choices: HOST_CHOICES, cursor: mode.cursor, selected: mode.selected })] }));
16
+ case "version":
17
+ return _jsx(Text, { children: "Version: 1 latest 2 fixed (Esc cancel)" });
18
+ case "fixed":
19
+ return _jsxs(Text, { children: ["Fixed version: ", mode.value, "_ (Enter confirm, Esc cancel)"] });
20
+ case "confirm":
21
+ return mode.adoption.verdict.adoptable ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(PreviewLines, { lines: mode.adoption.prepared.preview }), mode.adoption.prepared.runsExternalCommand ? _jsx(Text, { color: theme.warning, children: "This install runs an external command (see above)." }) : null, _jsxs(Text, { children: ["Adopt ", mode.entry.name, "? y/n"] })] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.error, children: mode.adoption.verdict.reason }), _jsx(Text, { children: "Enter/Esc to go back" })] }));
22
+ case "confirm-exposure":
23
+ return (_jsxs(Text, { color: theme.warning, children: ["Adopting ", mode.entry.name, " adds the missing Host location and exposes it to more hosts; this needs separate confirmation (CLI: --confirm-additional-host). Confirm? y/n"] }));
24
+ default:
25
+ return _jsx(ManagePanel, { mode: mode });
26
+ }
27
+ }
@@ -0,0 +1,11 @@
1
+ import { jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from "ink";
3
+ import { describeDetails, describeLinks, describeNode, markersOf } from "./installations-tree.js";
4
+ import { rowStyle, theme } from "./theme.js";
5
+ /** One line of the Installations tree: cursor, indent, mark box, label and the leaf's details. */
6
+ export function TreeRowLine({ row, selected, marked }) {
7
+ const item = row.node.data;
8
+ const leaf = !row.expandable && item.kind !== "group";
9
+ const prefix = `${selected ? "> " : " "}${" ".repeat(row.depth)}${leaf ? (marked ? "[x] " : "[ ] ") : ""}`;
10
+ return (_jsxs(Text, { ...rowStyle(selected), color: item.kind === "group" ? theme.group : undefined, children: [prefix, describeNode(row), item.kind === "installation" ? (_jsxs(Text, { color: theme.marker, children: [" ", describeDetails(item.row), markersOf(item.row) === "" ? "" : ` ${markersOf(item.row)}`] })) : null, item.kind === "unmanaged" && describeLinks(item.group) !== "" ? (_jsxs(Text, { color: theme.marker, children: [" ", describeLinks(item.group)] })) : null] }));
11
+ }
@@ -0,0 +1,68 @@
1
+ /** Managed installations of one scope, grouped by Source in first-seen order. */
2
+ function sourceNodes(scope, rows) {
3
+ const bySource = new Map();
4
+ for (const row of rows)
5
+ bySource.set(row.source, [...(bySource.get(row.source) ?? []), row]);
6
+ return [...bySource].map(([source, members]) => ({
7
+ id: `source:${scope}:${source}`,
8
+ data: { kind: "group", label: `${source} (${members.length})` },
9
+ children: members.map((row) => ({
10
+ id: `installation:${scope}:${source}:${row.selection.installation?.path ?? row.selection.path}`,
11
+ data: { kind: "installation", row },
12
+ })),
13
+ }));
14
+ }
15
+ /** Scope, then Source, then Skills; Unmanaged is its own group. Counts come from data already loaded. */
16
+ export function buildTree(data) {
17
+ const roots = [];
18
+ if (data.global !== undefined) {
19
+ roots.push({
20
+ id: "scope:user-global",
21
+ data: { kind: "group", label: `Managed (user-global) (${data.global.length})` },
22
+ children: sourceNodes("user-global", data.global),
23
+ });
24
+ }
25
+ roots.push({
26
+ id: "scope:project",
27
+ data: { kind: "group", label: `Managed (project) (${data.project.length})` },
28
+ children: sourceNodes("project", data.project),
29
+ });
30
+ roots.push({
31
+ id: "scope:unmanaged",
32
+ data: { kind: "group", label: `Unmanaged (user-global) (${data.unmanaged.length})` },
33
+ children: data.unmanaged.map((group) => ({ id: `unmanaged:${group.name}`, data: { kind: "unmanaged", group } })),
34
+ });
35
+ return roots;
36
+ }
37
+ /** Version, policy and hosts of a leaf; `adopted` / `modified` only when true (the legend explains them). */
38
+ export function describeDetails({ selection, policy, installed }) {
39
+ return `${installed} (${policy}) [${selection.hosts.join(", ")}]`;
40
+ }
41
+ export function markersOf({ adopted, modified }) {
42
+ return [adopted ? "adopted" : "", modified ? "modified" : ""].filter((flag) => flag !== "").join(" ");
43
+ }
44
+ /** Every leaf of the tree in display order, whether or not its group is open. */
45
+ export function leavesOf(roots) {
46
+ return roots.flatMap((node) => (node.children === undefined ? (node.data.kind === "group" ? [] : [node]) : leavesOf(node.children)));
47
+ }
48
+ export function targetOf(node) {
49
+ const { data } = node;
50
+ if (data.kind === "installation")
51
+ return { id: node.id, leaf: { kind: "installation", row: data.row } };
52
+ if (data.kind === "unmanaged")
53
+ return { id: node.id, leaf: { kind: "unmanaged", group: data.group } };
54
+ return undefined;
55
+ }
56
+ export function describeNode(row) {
57
+ const { data } = row.node;
58
+ if (data.kind === "group")
59
+ return `${row.expandable ? (row.expanded ? "▾" : "▸") : " "} ${data.label}`;
60
+ if (data.kind === "installation")
61
+ return data.row.selection.path;
62
+ return `${data.group.name} [${data.group.hosts.join(", ")}]`;
63
+ }
64
+ /** `symlink → <target>` for the locations that are links, one entry per distinct target. */
65
+ export function describeLinks(group) {
66
+ const targets = [...new Set(group.locations.flatMap((location) => location.linkTarget === undefined ? [] : [location.linkTarget]))];
67
+ return targets.length === 0 ? "" : `symlink → ${targets.join(", ")}`;
68
+ }
@@ -0,0 +1,114 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { loadInstallations } from "./installations.js";
5
+ import { BROWSE } from "./installations-mode.js";
6
+ import { errorText } from "./batch.js";
7
+ import { handleAdoptionKey, startAdoption } from "./adoption-actions.js";
8
+ import { handleBrowseKey } from "./installations-browse.js";
9
+ import { handleManageKey } from "./manage-actions.js";
10
+ import { ModePanel } from "./installations-panel.js";
11
+ import { TreeRowLine } from "./installations-rows.js";
12
+ import { buildTree, leavesOf, markersOf } from "./installations-tree.js";
13
+ import { ListFooter } from "./panel-parts.js";
14
+ import { useTreeNavigation } from "./tree-navigation.js";
15
+ import { defaultExpanded } from "./tree.js";
16
+ import { theme } from "./theme.js";
17
+ import { useMarks, useMounted } from "./view-state.js";
18
+ import { computeWindow, useListHeight } from "./window.js";
19
+ import { useKeys } from "./keys.js";
20
+ const NO_ENVIRONMENT = {};
21
+ const LEGEND = "adopted: tracked in place · modified: changed on disk";
22
+ export function InstallationsView({ operations, environment, onCapturingChange, listHeight }) {
23
+ const height = useListHeight(listHeight, 7);
24
+ const env = environment ?? NO_ENVIRONMENT;
25
+ const [state, setState] = useState({ status: "loading" });
26
+ const [mode, setModeState] = useState(BROWSE);
27
+ // Mirrors the mode synchronously so later keystrokes and the shell never see stale state.
28
+ const modeRef = useRef(BROWSE);
29
+ const marks = useMarks();
30
+ const mounted = useMounted();
31
+ const [message, setMessage] = useState();
32
+ const setMode = (next) => {
33
+ modeRef.current = next;
34
+ onCapturingChange?.(next.kind !== "browse");
35
+ setModeState(next);
36
+ };
37
+ const reload = useCallback(async () => {
38
+ try {
39
+ const data = await loadInstallations(operations, env);
40
+ if (mounted.current)
41
+ setState({ status: "ready", data });
42
+ }
43
+ catch (error) {
44
+ if (mounted.current)
45
+ setState({ status: "error", message: errorText(error) });
46
+ }
47
+ }, [operations, env, mounted]);
48
+ useEffect(() => {
49
+ void reload();
50
+ }, [reload]);
51
+ const data = state.status === "ready" ? state.data : undefined;
52
+ const roots = useMemo(() => (data ? buildTree(data) : []), [data]);
53
+ const navigation = useTreeNavigation(roots, defaultExpanded);
54
+ const { rows, index } = navigation;
55
+ const installed = useMemo(() => ({
56
+ global: (data?.global ?? []).map((row) => row.selection),
57
+ project: (data?.project ?? []).map((row) => row.selection),
58
+ sources: data?.sources ?? [],
59
+ unmanagedNames: new Set(),
60
+ }), [data]);
61
+ const manage = {
62
+ operations,
63
+ env,
64
+ installed,
65
+ setMode,
66
+ browse: () => setMode(BROWSE),
67
+ setMessage,
68
+ isMounted: () => mounted.current,
69
+ unmark: (ids) => marks.setMarked(new Set([...marks.markedRef.current].filter((id) => !ids.includes(id)))),
70
+ finish: async (result) => {
71
+ await reload();
72
+ if (!mounted.current)
73
+ return;
74
+ setMessage(result);
75
+ setMode(BROWSE);
76
+ },
77
+ };
78
+ const adoption = {
79
+ operations,
80
+ env,
81
+ setMode,
82
+ browse: manage.browse,
83
+ setMessage,
84
+ isMounted: manage.isMounted,
85
+ reload,
86
+ };
87
+ const browse = {
88
+ navigation,
89
+ height,
90
+ marks,
91
+ data,
92
+ manage,
93
+ startAdoption: (group) => void startAdoption(adoption, group),
94
+ setMessage,
95
+ };
96
+ useKeys((input, key) => {
97
+ const current = modeRef.current;
98
+ if (current.kind === "browse")
99
+ handleBrowseKey(browse, input, key);
100
+ else if (!handleManageKey(manage, current, input, key))
101
+ handleAdoptionKey(adoption, current, input, key);
102
+ });
103
+ if (state.status === "loading")
104
+ return _jsx(Text, { children: "Loading installations..." });
105
+ if (state.status === "error")
106
+ return _jsxs(Text, { color: theme.error, children: ["Error: ", state.message] });
107
+ const ready = state.data;
108
+ const window = computeWindow(rows.length, index, height);
109
+ const listed = rows.slice(window.start, window.end);
110
+ const selectedCount = leavesOf(roots).filter((node) => marks.marked.has(node.id)).length;
111
+ // The legend explains the markers, so it shows only while a rendered row carries one.
112
+ const legendNeeded = listed.some((row) => row.node.data.kind === "installation" && markersOf(row.node.data.row) !== "");
113
+ return (_jsxs(Box, { flexDirection: "column", children: [ready.global === undefined ? _jsx(Text, { children: "User-global installations are not supported by the configured operations" }) : null, ready.projectError === undefined ? null : _jsxs(Text, { color: theme.error, children: ["Error: ", ready.projectError] }), listed.map((row, offset) => (_jsx(TreeRowLine, { row: row, selected: window.start + offset === index, marked: marks.marked.has(row.node.id) }, row.node.id))), _jsx(ListFooter, { indicator: window.indicator, selectedCount: selectedCount, message: message }), legendNeeded ? _jsx(Text, { color: theme.muted, children: LEGEND }) : null, _jsx(ModePanel, { mode: mode })] }));
114
+ }