@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,149 @@
1
+ import { homedir } from "node:os";
2
+ import path from "node:path";
3
+ import { defaultProjectManifestPath, ProjectManifestStore, } from "../project-manifest.js";
4
+ import { formatVersionPolicy } from "../skill-install.js";
5
+ import { inspectSkillRemovals, userGlobalRemovalOptions } from "../skill-removal.js";
6
+ import { scanUserGlobalSkillInventory, } from "../user-global-skill-inventory.js";
7
+ import { prepareInstall } from "./catalog-installs.js";
8
+ /** Hosts in display order; the Claude root serves Claude Code, the shared root serves the others. */
9
+ const HOST_DISPLAY_ORDER = ["claude", "pi", "codex", "opencode"];
10
+ const ROOT_HOSTS = {
11
+ agents: ["pi", "codex", "opencode"],
12
+ claude: ["claude"],
13
+ };
14
+ /**
15
+ * Groups the unmanaged directories, the unmanaged symlinks and the Claude links that
16
+ * expose an unmanaged canonical directory by Skill name. Hosts come from the root kinds.
17
+ */
18
+ export function groupUnmanaged(inventory) {
19
+ const byName = new Map();
20
+ const add = (name, location) => byName.set(name, [...(byName.get(name) ?? []), location]);
21
+ for (const entry of inventory.unmanaged)
22
+ add(entry.name, { path: entry.path, root: entry.root, entry });
23
+ for (const link of inventory.symlinks)
24
+ add(link.name, { path: link.path, root: link.root, linkTarget: link.target });
25
+ const unmanagedPaths = new Set(inventory.unmanaged.map((entry) => path.resolve(entry.path)));
26
+ for (const exposure of inventory.exposures) {
27
+ if (unmanagedPaths.has(path.resolve(exposure.target))) {
28
+ add(exposure.name, { path: exposure.path, root: exposure.root, linkTarget: exposure.target });
29
+ }
30
+ }
31
+ return [...byName]
32
+ .sort(([left], [right]) => left.localeCompare(right))
33
+ .map(([name, locations]) => {
34
+ const ordered = [...locations].sort((left, right) => Number(left.root === "claude") - Number(right.root === "claude"));
35
+ const hosts = HOST_DISPLAY_ORDER.filter((host) => ordered.some((location) => ROOT_HOSTS[location.root].includes(host)));
36
+ return { name, locations: ordered, hosts };
37
+ });
38
+ }
39
+ export function homeOf(environment) {
40
+ return path.resolve(environment.homeDirectory ?? homedir());
41
+ }
42
+ function projectRootOf(environment) {
43
+ return path.resolve(environment.projectRoot ?? process.cwd());
44
+ }
45
+ function sourceLabel(source) {
46
+ if (source.kind === "builtin")
47
+ return source.id;
48
+ return "url" in source ? `${source.url}${source.ref === undefined ? "" : `@${source.ref}`}` : source.path;
49
+ }
50
+ /** Short label of version evidence: the package version or the first commit characters. */
51
+ export function formatVersionEvidence(evidence) {
52
+ if (!evidence)
53
+ return "unknown";
54
+ return evidence.kind === "builtin-package" ? evidence.version : evidence.commit.slice(0, 7);
55
+ }
56
+ function installedVersion(selection) {
57
+ return formatVersionEvidence(selection.installation?.resolvedVersion);
58
+ }
59
+ async function toRow(scope, selection, inspectionOptions) {
60
+ const modified = await inspectSkillRemovals([selection], inspectionOptions).then((plan) => plan.inspections[0]?.modified, () => undefined);
61
+ return {
62
+ scope,
63
+ selection,
64
+ source: sourceLabel(selection.source),
65
+ policy: formatVersionPolicy(selection.version),
66
+ installed: installedVersion(selection),
67
+ adopted: selection.installation?.adopted === true,
68
+ ...(modified === undefined ? {} : { modified }),
69
+ };
70
+ }
71
+ /** Loads both scopes and the unmanaged user-global inventory the CLI's `update check` uses. */
72
+ export async function loadInstallations(operations, environment) {
73
+ const home = homeOf(environment);
74
+ const globalRecords = operations.listUserGlobalInstallations
75
+ ? await operations.listUserGlobalInstallations()
76
+ : undefined;
77
+ const globalOptions = userGlobalRemovalOptions(home, environment.installationOptions);
78
+ const global = globalRecords
79
+ ? await Promise.all(globalRecords.map((record) => toRow("user-global", record, globalOptions)))
80
+ : undefined;
81
+ const store = environment.projectManifestStore ??
82
+ new ProjectManifestStore(defaultProjectManifestPath(projectRootOf(environment)));
83
+ let projectRecords = [];
84
+ let projectError;
85
+ try {
86
+ projectRecords = (await store.load()).skills;
87
+ }
88
+ catch (error) {
89
+ projectError = error instanceof Error ? error.message : String(error);
90
+ }
91
+ const projectOptions = {
92
+ projectRoot: projectRootOf(environment),
93
+ sourceAccess: { readSkillTree: async () => [] },
94
+ ...environment.installationOptions,
95
+ };
96
+ const project = await Promise.all(projectRecords.map((record) => toRow("project", record, projectOptions)));
97
+ const unmanaged = globalRecords
98
+ ? groupUnmanaged(await scanUserGlobalSkillInventory({ homeDirectory: home, managedInstallations: globalRecords }))
99
+ : [];
100
+ const sources = await operations.listSources().catch(() => []);
101
+ return { global, project, ...(projectError === undefined ? {} : { projectError }), unmanaged, sources };
102
+ }
103
+ /** Source id of an installation's Source, when that Source is still registered. */
104
+ export function sourceIdOf(selection, sources) {
105
+ const source = selection.source;
106
+ if (source.kind === "builtin")
107
+ return source.id;
108
+ if (!("url" in source))
109
+ return undefined;
110
+ return sources.find((candidate) => candidate.kind === "git" && candidate.url === source.url)?.id;
111
+ }
112
+ /** The Skill of an installation, as the shared manage flows expect it. */
113
+ export function skillOf(row, sourceId) {
114
+ return { sourceId, path: row.selection.path, name: path.posix.basename(row.selection.path), description: "" };
115
+ }
116
+ /** Source Skills whose directory name equals the unmanaged Skill's, across every registered Source. */
117
+ export async function findAdoptionCandidates(operations, entry) {
118
+ if (!operations.discoverSkills)
119
+ throw new Error("Discovery is not supported by the configured operations");
120
+ const ids = (await operations.listSources()).map((source) => source.id);
121
+ return (await operations.discoverSkills(ids)).filter((skill) => path.posix.basename(skill.path) === entry.name);
122
+ }
123
+ /** Hosts that already read the unmanaged Skill's location, so adopting it exposes nothing new. */
124
+ export function defaultAdoptionHosts(entry) {
125
+ return entry.host === "claude" ? ["claude"] : ["pi", "codex", "opencode"];
126
+ }
127
+ /**
128
+ * Adoption keeps the existing location, so it only applies when that exact
129
+ * location's content is identical to the selected Source Skill (the CLI's rule).
130
+ */
131
+ export function adoptionVerdict(prepared, entry) {
132
+ const { inspection } = prepared.plan;
133
+ const location = path.resolve(entry.path);
134
+ const identical = inspection.locations.some((candidate) => candidate.status === "identical" && path.resolve(candidate.path) === location);
135
+ if (identical)
136
+ return { adoptable: true };
137
+ const collision = inspection.collision ?? inspection.locations.find((candidate) => candidate.collision)?.collision;
138
+ return {
139
+ adoptable: false,
140
+ reason: collision
141
+ ? `Cannot adopt ${entry.path}: it does not match the selected Source Skill (${collision.reason} at ${collision.path}); nothing was changed`
142
+ : `Cannot adopt ${entry.path}: no identical existing copy was found, so an install would create a new copy instead; nothing was changed`,
143
+ };
144
+ }
145
+ /** Plan step: the shared install plan (refreshing the Source) plus the adoption verdict. Changes nothing. */
146
+ export async function prepareAdoption(operations, environment, entry, skill, choice) {
147
+ const prepared = await prepareInstall(operations, environment, skill, { ...choice, scope: "user-global" });
148
+ return { prepared, verdict: adoptionVerdict(prepared, entry) };
149
+ }
@@ -0,0 +1,31 @@
1
+ import { useInput } from "ink";
2
+ /** Plain printable characters only: control characters and escape sequences are never split. */
3
+ const PRINTABLE = /^[^\p{Cc}]+$/u;
4
+ /**
5
+ * Ink strips the ESC of sequences it does not resolve and hands over the rest
6
+ * (`[200~` paste markers, `[I` focus events, `[<0;10;5M` mouse reports). Splitting
7
+ * them would replay digits and letters as key presses, so they are kept whole.
8
+ * Only the final bytes xterm actually emits count (CSI: arrows, Home/End, focus, mouse,
9
+ * Shift+Tab, F1-F4, `~`; SS3: arrows, Home/End, F1-F4), so typed text such as `Ok` is not one.
10
+ */
11
+ const ESCAPE_REMNANT = /^(?:\[[\d;<:?]*[A-DFHIMOPQRSZm~]|O[A-DFHPQRS])$/;
12
+ const PASTE_MARKERS = new Set(["[200~", "[201~"]);
13
+ /**
14
+ * `useInput` that treats several plain characters delivered in one write (fast
15
+ * typing, a paste) as the separate key presses they are, in order.
16
+ */
17
+ export function useKeys(handler) {
18
+ useInput((input, key) => {
19
+ if (ESCAPE_REMNANT.test(input)) {
20
+ if (!PASTE_MARKERS.has(input))
21
+ handler(input, key);
22
+ return;
23
+ }
24
+ if (input.length > 1 && !key.ctrl && !key.meta && !key.escape && PRINTABLE.test(input)) {
25
+ for (const character of input)
26
+ handler(character, key);
27
+ return;
28
+ }
29
+ handler(input, key);
30
+ });
31
+ }
@@ -0,0 +1,172 @@
1
+ import { globalRecord, missingHosts, prepareHostAddition, prepareHostRemoval, prepareUninstall, runHostAddition, runHostRemoval, runUninstall, UNINSTALL_REFUSALS, } from "./catalog-installs.js";
2
+ import { errorText } from "./batch.js";
3
+ import { BULK_KINDS, handleBulkKey } from "./bulk-actions.js";
4
+ import { answerYesNo, applyChecklistOutcome, hostChecklistKey } from "./mode-keys.js";
5
+ import { handleUnmanagedKey, UNMANAGED_KINDS } from "./unmanaged-actions.js";
6
+ const MANAGE_KINDS = new Set([
7
+ "remove-scope",
8
+ "hosts-add",
9
+ "hosts-remove",
10
+ "confirm-host-add",
11
+ "confirm-host-exposure",
12
+ "confirm-host-remove",
13
+ "confirm-uninstall",
14
+ ...UNMANAGED_KINDS,
15
+ ...BULK_KINDS,
16
+ "busy",
17
+ ]);
18
+ function isManageMode(mode) {
19
+ return MANAGE_KINDS.has(mode.kind);
20
+ }
21
+ export { errorText };
22
+ function cancel(context, text) {
23
+ context.setMessage({ kind: "ok", text });
24
+ context.browse();
25
+ }
26
+ /** `u`: one host goes straight to full removal; several ask whether to remove all or choose. */
27
+ export function startUninstall(context, skill) {
28
+ const record = globalRecord(skill, context.installed);
29
+ if (record && record.hosts.length > 1)
30
+ context.setMode({ kind: "remove-scope", skill, hosts: record.hosts });
31
+ else
32
+ void startFullUninstall(context, skill);
33
+ }
34
+ async function startFullUninstall(context, skill) {
35
+ context.setMode({ kind: "busy", label: `Preparing uninstall of ${skill.name}...` });
36
+ try {
37
+ const prepared = await prepareUninstall(context.operations, context.env, skill, context.installed);
38
+ if (!context.isMounted())
39
+ return;
40
+ if (prepared.kind === "ready") {
41
+ context.setMode({ kind: "confirm-uninstall", skill, prepared });
42
+ return;
43
+ }
44
+ context.setMessage({ kind: "error", text: UNINSTALL_REFUSALS[prepared.kind] });
45
+ }
46
+ catch (error) {
47
+ if (!context.isMounted())
48
+ return;
49
+ context.setMessage({ kind: "error", text: errorText(error) });
50
+ }
51
+ context.browse();
52
+ }
53
+ /** Opens the host checklist for the hosts the installed Skill does not serve yet. */
54
+ export function startHostAddition(context, skill) {
55
+ const record = globalRecord(skill, context.installed);
56
+ const missing = record ? missingHosts(record) : [];
57
+ if (missing.length === 0) {
58
+ context.setMessage({ kind: "error", text: record ? `${skill.name} already has every host` : "Skill is not installed" });
59
+ return;
60
+ }
61
+ context.setMode({ kind: "hosts-add", skill, choices: missing, cursor: 0, selected: [] });
62
+ }
63
+ async function startHostChange(context, mode) {
64
+ const { operations, env, installed } = context;
65
+ context.setMode({ kind: "busy", label: `Preparing host change of ${mode.skill.name}...` });
66
+ try {
67
+ if (mode.kind === "hosts-add") {
68
+ const prepared = await prepareHostAddition(operations, env, mode.skill, installed, mode.selected);
69
+ if (!context.isMounted())
70
+ return;
71
+ context.setMode({ kind: "confirm-host-add", skill: mode.skill, prepared });
72
+ }
73
+ else {
74
+ const prepared = await prepareHostRemoval(operations, env, mode.skill, installed, mode.selected);
75
+ if (!context.isMounted())
76
+ return;
77
+ context.setMode({ kind: "confirm-host-remove", skill: mode.skill, prepared });
78
+ }
79
+ }
80
+ catch (error) {
81
+ if (!context.isMounted())
82
+ return;
83
+ context.setMessage({ kind: "error", text: errorText(error) });
84
+ context.browse();
85
+ }
86
+ }
87
+ /** Runs a confirmed step in the busy mode and finishes with its outcome. */
88
+ async function execute(context, label, action) {
89
+ context.setMode({ kind: "busy", label });
90
+ let result;
91
+ try {
92
+ result = { kind: "ok", text: await action() };
93
+ }
94
+ catch (error) {
95
+ result = { kind: "error", text: errorText(error) };
96
+ }
97
+ await context.finish(result);
98
+ }
99
+ function handleHostChecklistKey(context, mode, input, key) {
100
+ applyChecklistOutcome(hostChecklistKey(mode, mode.choices, input, key), {
101
+ cancel: () => cancel(context, mode.kind === "hosts-add" ? "Install cancelled" : "Uninstall cancelled"),
102
+ update: (state) => context.setMode({ ...mode, ...state }),
103
+ submit: () => void startHostChange(context, mode),
104
+ setMessage: context.setMessage,
105
+ });
106
+ }
107
+ function handleRemoveScopeKey(context, mode, input, key) {
108
+ if (key.escape)
109
+ cancel(context, "Uninstall cancelled");
110
+ else if (input === "1")
111
+ void startFullUninstall(context, mode.skill);
112
+ else if (input === "2")
113
+ context.setMode({ kind: "hosts-remove", skill: mode.skill, choices: mode.hosts, cursor: 0, selected: [] });
114
+ }
115
+ /** Keys of the confirmation modes: `y` runs the step (or asks for exposure consent first), `n`/Esc cancels. */
116
+ function handleConfirmKey(context, mode, input, key) {
117
+ const { operations, env } = context;
118
+ const cancelWith = (text) => () => cancel(context, text);
119
+ switch (mode.kind) {
120
+ case "confirm-host-add":
121
+ answerYesNo(input, key, () => context.setMode({ ...mode, kind: "confirm-host-exposure" }), cancelWith("Install cancelled"));
122
+ return;
123
+ case "confirm-host-exposure":
124
+ answerYesNo(input, key, () => {
125
+ void execute(context, `Changing hosts of ${mode.skill.name}...`, () => runHostAddition(mode.prepared, operations, env));
126
+ }, cancelWith("Install cancelled"));
127
+ return;
128
+ case "confirm-host-remove":
129
+ answerYesNo(input, key, () => {
130
+ void execute(context, `Changing hosts of ${mode.skill.name}...`, () => runHostRemoval(mode.prepared, operations, env, mode.skill));
131
+ }, cancelWith("Uninstall cancelled"));
132
+ return;
133
+ case "confirm-uninstall":
134
+ answerYesNo(input, key, () => {
135
+ void execute(context, `Removing ${mode.skill.name}...`, async () => {
136
+ await runUninstall(mode.prepared, operations, env);
137
+ return `Removed ${mode.skill.name} (${mode.skill.path})`;
138
+ });
139
+ }, cancelWith("Uninstall cancelled"));
140
+ return;
141
+ }
142
+ }
143
+ /**
144
+ * Handles a key in one of the manage modes. Returns false when the mode is not
145
+ * a manage mode, so the host view can handle its own modes.
146
+ */
147
+ export function handleManageKey(context, mode, input, key) {
148
+ if (!isManageMode(mode))
149
+ return false;
150
+ if (handleUnmanagedKey(context, mode, input, key))
151
+ return true;
152
+ if (handleBulkKey(context, mode, input, key))
153
+ return true;
154
+ switch (mode.kind) {
155
+ case "remove-scope":
156
+ handleRemoveScopeKey(context, mode, input, key);
157
+ break;
158
+ case "hosts-add":
159
+ case "hosts-remove":
160
+ handleHostChecklistKey(context, mode, input, key);
161
+ break;
162
+ case "confirm-host-add":
163
+ case "confirm-host-exposure":
164
+ case "confirm-host-remove":
165
+ case "confirm-uninstall":
166
+ handleConfirmKey(context, mode, input, key);
167
+ break;
168
+ default:
169
+ break;
170
+ }
171
+ return true;
172
+ }
@@ -0,0 +1,59 @@
1
+ import { jsxs as _jsxs, Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { ChecklistLines, HostChecklist, PreviewConfirm, PreviewLines } from "./panel-parts.js";
4
+ import { theme } from "./theme.js";
5
+ const plural = (n) => `${n} ${n === 1 ? "item" : "items"}`;
6
+ function SkippedLines({ skipped }) {
7
+ return (_jsx(_Fragment, { children: skipped.map(({ id, label, reason }) => _jsxs(Text, { color: theme.warning, children: ["Skipped ", label, ": ", reason] }, id)) }));
8
+ }
9
+ /** One block per planned item, the skipped ones, then the y/n question. */
10
+ function PlanPreview({ entries, skipped, question }) {
11
+ return (_jsxs(Box, { flexDirection: "column", children: [entries.map((entry) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.group, children: entry.label }), _jsx(PreviewLines, { lines: entry.lines, warnFirst: entry.warnFirst })] }, entry.id))), _jsx(SkippedLines, { skipped: skipped }), _jsx(Text, { children: question })] }));
12
+ }
13
+ function UninstallPreview({ plan }) {
14
+ const entries = plan.items.map((item) => ({
15
+ id: item.id,
16
+ label: item.label,
17
+ lines: item.kind === "managed" ? item.prepared.plan.preview : item.prepared.preview,
18
+ warnFirst: item.kind === "unmanaged",
19
+ }));
20
+ return _jsx(PlanPreview, { entries: entries, skipped: plan.skipped, question: `Uninstall ${plural(plan.items.length)}? y/n` });
21
+ }
22
+ function HostsPreview({ plan }) {
23
+ const entries = plan.items.map((item) => ({ id: item.id, label: item.label, lines: item.prepared.preview, warnFirst: false }));
24
+ return _jsx(PlanPreview, { entries: entries, skipped: plan.skipped, question: `Add hosts to ${plural(plan.items.length)}? y/n` });
25
+ }
26
+ /** Renders the prompt of a manage mode: host lists, previews and confirmations. */
27
+ export function ManagePanel({ mode }) {
28
+ switch (mode.kind) {
29
+ case "remove-scope":
30
+ return _jsxs(Text, { children: ["Uninstall ", mode.skill.name, " (hosts: ", mode.hosts.join(", "), "): 1 all hosts 2 choose hosts (Esc cancel)"] });
31
+ case "hosts-add":
32
+ case "hosts-remove":
33
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [mode.kind === "hosts-add" ? "Add hosts to" : "Remove hosts from", " ", mode.skill.name, ". Host (space/1-", mode.choices.length, " toggle, j/k move, Enter continue, Esc cancel):"] }), _jsx(HostChecklist, { choices: mode.choices, cursor: mode.cursor, selected: mode.selected })] }));
34
+ case "confirm-host-add":
35
+ return _jsx(PreviewConfirm, { lines: mode.prepared.preview, question: `Add hosts to ${mode.skill.name}? y/n` });
36
+ case "confirm-host-exposure":
37
+ return (_jsxs(Text, { color: theme.warning, children: ["Adding hosts exposes the installed ", mode.skill.name, " to more hosts and needs separate confirmation (CLI: --confirm-additional-host). Confirm? y/n"] }));
38
+ case "confirm-host-remove":
39
+ return _jsx(PreviewConfirm, { lines: mode.prepared.preview, question: `Remove hosts from ${mode.skill.name}? y/n` });
40
+ case "confirm-uninstall":
41
+ return _jsx(PreviewConfirm, { lines: mode.prepared.plan.preview, question: `Uninstall ${mode.skill.name}? y/n` });
42
+ case "unmanaged-scope":
43
+ return _jsxs(Text, { children: ["Remove ", mode.group.name, " (", mode.group.locations.length, " locations): 1 all locations 2 choose locations (Esc cancel)"] });
44
+ case "unmanaged-pick":
45
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Remove locations of ", mode.group.name, ". Location (space/1-", mode.group.locations.length, " toggle, j/k move, Enter continue, Esc cancel):"] }), _jsx(ChecklistLines, { choices: mode.group.locations, cursor: mode.cursor, isSelected: (location) => mode.selected.includes(location.path), keyOf: (location) => location.path, label: (location) => `${location.path}${location.linkTarget === undefined ? "" : ` symlink → ${location.linkTarget}`}` })] }));
46
+ case "confirm-unmanaged":
47
+ return _jsx(PreviewConfirm, { lines: mode.prepared.preview, question: `Remove ${mode.group.name}? y/n`, warnFirst: true });
48
+ case "bulk-hosts":
49
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Add hosts to ", plural(mode.targets.length), ". Host (space/1-", mode.choices.length, " toggle, j/k move, Enter continue, Esc cancel):"] }), _jsx(HostChecklist, { choices: mode.choices, cursor: mode.cursor, selected: mode.selected }), _jsx(SkippedLines, { skipped: mode.skipped })] }));
50
+ case "confirm-bulk-uninstall":
51
+ return _jsx(UninstallPreview, { plan: mode.plan });
52
+ case "confirm-bulk-hosts":
53
+ return _jsx(HostsPreview, { plan: mode.plan });
54
+ case "confirm-bulk-exposure":
55
+ return (_jsxs(Text, { color: theme.warning, children: ["Adding hosts exposes the installed ", mode.plan.items.map((item) => item.label).join(", "), " to more hosts and needs separate confirmation (CLI: --confirm-additional-host). Confirm? y/n"] }));
56
+ case "busy":
57
+ return _jsx(Text, { children: mode.label });
58
+ }
59
+ }
@@ -0,0 +1,61 @@
1
+ import { isValidFixedVersion } from "../skill-install.js";
2
+ /** The cursor after a down/up key (arrows or j/k), or undefined when the key does not move it. */
3
+ export function moveCursor(cursor, length, input, key) {
4
+ if (key.downArrow || input === "j")
5
+ return Math.min(cursor + 1, length - 1);
6
+ if (key.upArrow || input === "k")
7
+ return Math.max(cursor - 1, 0);
8
+ return undefined;
9
+ }
10
+ /** `selected` with `choice` flipped, always in the order of `choices`. */
11
+ export function toggleChoice(choices, selected, choice) {
12
+ return choices.filter((candidate) => candidate === choice ? !selected.includes(candidate) : selected.includes(candidate));
13
+ }
14
+ /**
15
+ * Keys of a host checklist: Esc cancels, j/k or arrows move, space toggles the
16
+ * cursor row, a digit toggles that row, Enter submits a non-empty selection.
17
+ */
18
+ export function hostChecklistKey(state, choices, input, key) {
19
+ if (key.escape)
20
+ return { kind: "cancel" };
21
+ const cursor = moveCursor(state.cursor, choices.length, input, key);
22
+ if (cursor !== undefined)
23
+ return { kind: "update", state: { ...state, cursor } };
24
+ const flipped = input === " " ? choices[state.cursor] : choices[Number(input) - 1];
25
+ if (input === " " || flipped !== undefined) {
26
+ return { kind: "update", state: { ...state, selected: toggleChoice(choices, state.selected, flipped) } };
27
+ }
28
+ if (key.return)
29
+ return state.selected.length > 0 ? { kind: "submit", selected: state.selected } : { kind: "empty" };
30
+ return { kind: "ignore" };
31
+ }
32
+ /** Runs the handler for what a key did to a host checklist; an empty Enter shows the shared message. */
33
+ export function applyChecklistOutcome(outcome, handlers) {
34
+ if (outcome.kind === "cancel")
35
+ handlers.cancel();
36
+ else if (outcome.kind === "update")
37
+ handlers.update(outcome.state);
38
+ else if (outcome.kind === "submit")
39
+ handlers.submit(outcome.selected);
40
+ else if (outcome.kind === "empty")
41
+ handlers.setMessage({ kind: "error", text: "Select at least one host" });
42
+ }
43
+ /** The y/n confirmation keys: `y` confirms, `n` or Esc declines, anything else is ignored. */
44
+ export function answerYesNo(input, key, onYes, onNo) {
45
+ if (input === "y")
46
+ onYes();
47
+ else if (input === "n" || key.escape)
48
+ onNo();
49
+ }
50
+ /** Esc cancels, Enter submits a valid version, backspace deletes, any other plain key is typed. */
51
+ export function versionInputKey(value, input, key) {
52
+ if (key.escape)
53
+ return { kind: "cancel" };
54
+ if (key.return)
55
+ return isValidFixedVersion(value) ? { kind: "submit" } : { kind: "ignore" };
56
+ if (key.backspace || key.delete)
57
+ return { kind: "edit", value: value.slice(0, -1) };
58
+ if (input !== "" && !key.ctrl && !key.meta)
59
+ return { kind: "edit", value: value + input };
60
+ return { kind: "ignore" };
61
+ }
@@ -0,0 +1,23 @@
1
+ import { jsxs as _jsxs, Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { theme } from "./theme.js";
4
+ /** The `> [x] 1 name` rows of a checklist; the caller renders the header. */
5
+ export function ChecklistLines({ choices, cursor, isSelected, keyOf, label }) {
6
+ return (_jsx(_Fragment, { children: choices.map((choice, position) => (_jsxs(Text, { children: [position === cursor ? "> " : " ", "[", isSelected(choice) ? "x" : " ", "] ", position + 1, " ", label(choice)] }, keyOf(choice)))) }));
7
+ }
8
+ /** The host rows of a checklist. */
9
+ export function HostChecklist({ choices, cursor, selected }) {
10
+ return _jsx(ChecklistLines, { choices: choices, cursor: cursor, isSelected: (host) => selected.includes(host), keyOf: (host) => host, label: (host) => host });
11
+ }
12
+ /** Preview lines; `warnFirst` colors the first one as a warning. */
13
+ export function PreviewLines({ lines, warnFirst = false }) {
14
+ return (_jsx(_Fragment, { children: lines.map((line, position) => _jsx(Text, { color: warnFirst && position === 0 ? theme.warning : undefined, children: line }, position)) }));
15
+ }
16
+ /** Under a list: the window indicator, how many items are marked, and the last result. */
17
+ export function ListFooter({ indicator, selectedCount, message }) {
18
+ return (_jsxs(_Fragment, { children: [indicator ? _jsx(Text, { color: theme.muted, children: indicator }) : null, selectedCount > 0 ? _jsxs(Text, { color: theme.muted, children: [selectedCount, " selected"] }) : null, message ? _jsx(Text, { color: message.kind === "error" ? theme.error : theme.success, children: message.text }) : null] }));
19
+ }
20
+ /** A preview followed by a y/n question. */
21
+ export function PreviewConfirm({ lines, question, warnFirst }) {
22
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(PreviewLines, { lines: lines, warnFirst: warnFirst }), _jsx(Text, { children: question })] }));
23
+ }
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from "ink";
3
+ import { App } from "./app.js";
4
+ /** Renders the TUI and resolves once the user exits it. */
5
+ export async function renderTui(operations, environment) {
6
+ const instance = render(_jsx(App, { operations: operations, environment: environment }));
7
+ await instance.waitUntilExit();
8
+ }
@@ -0,0 +1,17 @@
1
+ export const initialMode = { kind: "browse" };
2
+ export function modeReducer(mode, event) {
3
+ switch (event.type) {
4
+ case "input":
5
+ return { kind: "input", value: "" };
6
+ case "edit":
7
+ return mode.kind === "input" ? { kind: "input", value: event.value } : mode;
8
+ case "confirm":
9
+ return event.action === "refresh"
10
+ ? { kind: "confirm", action: "refresh", sources: event.sources }
11
+ : { kind: "confirm", action: "remove", source: event.source };
12
+ case "busy":
13
+ return { kind: "busy" };
14
+ case "done":
15
+ return initialMode;
16
+ }
17
+ }