@iseoane/agent-depot 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/README.md +117 -3
- package/dist/src/app-cli.js +172 -0
- package/dist/src/app-flow.js +457 -0
- package/dist/src/app-latest.js +63 -0
- package/dist/src/app-owned-skills.js +42 -0
- package/dist/src/app-recipes.js +116 -0
- package/dist/src/app-schema.js +45 -0
- package/dist/src/app-version.js +46 -0
- package/dist/src/atomic-file.js +26 -0
- package/dist/src/cli.js +1256 -0
- package/dist/src/git-source.js +409 -0
- package/dist/src/path-safety.js +31 -0
- package/dist/src/process-runner.js +41 -0
- package/dist/src/project-installation.js +1411 -0
- package/dist/src/project-manifest.js +511 -0
- package/dist/src/skill-adoption.js +77 -0
- package/dist/src/skill-discovery.js +467 -0
- package/dist/src/skill-hosts.js +103 -0
- package/dist/src/skill-install.js +374 -0
- package/dist/src/skill-removal.js +119 -0
- package/dist/src/skill-update.js +244 -0
- package/dist/src/source-state.js +240 -0
- package/dist/src/sources.js +391 -0
- package/dist/src/tui/adoption-actions.js +144 -0
- package/dist/src/tui/app-actions.js +157 -0
- package/dist/src/tui/app.js +63 -0
- package/dist/src/tui/batch.js +17 -0
- package/dist/src/tui/bulk-actions.js +243 -0
- package/dist/src/tui/catalog-actions.js +9 -0
- package/dist/src/tui/catalog-browse.js +59 -0
- package/dist/src/tui/catalog-filter.js +20 -0
- package/dist/src/tui/catalog-flow.js +107 -0
- package/dist/src/tui/catalog-installs.js +193 -0
- package/dist/src/tui/catalog-panel.js +47 -0
- package/dist/src/tui/catalog-state.js +92 -0
- package/dist/src/tui/catalog-tree.js +35 -0
- package/dist/src/tui/catalog-view.js +89 -0
- package/dist/src/tui/environment.js +1 -0
- package/dist/src/tui/installations-browse.js +136 -0
- package/dist/src/tui/installations-mode.js +1 -0
- package/dist/src/tui/installations-panel.js +27 -0
- package/dist/src/tui/installations-rows.js +11 -0
- package/dist/src/tui/installations-tree.js +80 -0
- package/dist/src/tui/installations-view.js +154 -0
- package/dist/src/tui/installations.js +172 -0
- package/dist/src/tui/keys.js +31 -0
- package/dist/src/tui/manage-actions.js +174 -0
- package/dist/src/tui/manage-panel.js +65 -0
- package/dist/src/tui/mode-keys.js +61 -0
- package/dist/src/tui/panel-parts.js +23 -0
- package/dist/src/tui/render.js +8 -0
- package/dist/src/tui/sources-mode.js +17 -0
- package/dist/src/tui/sources-view.js +276 -0
- package/dist/src/tui/theme.js +29 -0
- package/dist/src/tui/tree-navigation.js +68 -0
- package/dist/src/tui/tree.js +53 -0
- package/dist/src/tui/unmanaged-actions.js +178 -0
- package/dist/src/tui/updates-flow.js +89 -0
- package/dist/src/tui/updates-keys.js +104 -0
- package/dist/src/tui/updates-mode.js +1 -0
- package/dist/src/tui/updates-model.js +48 -0
- package/dist/src/tui/updates-panel.js +44 -0
- package/dist/src/tui/updates-view.js +101 -0
- package/dist/src/tui/updates.js +230 -0
- package/dist/src/tui/view-state.js +42 -0
- package/dist/src/tui/window.js +31 -0
- package/dist/src/unmanaged-removal.js +60 -0
- package/dist/src/update-batch.js +376 -0
- package/dist/src/update-flow.js +135 -0
- package/dist/src/usage-error.js +7 -0
- package/dist/src/user-global-skill-inventory.js +565 -0
- package/dist/src/user-method.js +14 -0
- package/package.json +7 -4
- package/skills/agent-depot-apprecipe/SKILL.md +44 -0
- package/skills/agent-depot-apprecipe/assets/example.md +25 -0
- package/skills/doctor-md-agents/scripts/test_collect_sessions.py +0 -780
- package/skills/doctor-md-agents/scripts/test_extract_rules.py +0 -530
- package/skills/doctor-md-agents/scripts/test_render_report.py +0 -383
- package/skills/doctor-md-agents/scripts/test_scan_corrections.py +0 -343
- package/skills/doctor-md-agents/scripts/test_verify_citations.py +0 -236
- package/skills/doctor-md-agents/scripts/testdata/old_extract_rules_pre_t1.py.txt +0 -507
- package/skills/doctor-md-skill/scripts/test_collect_sessions.py +0 -593
- package/skills/doctor-md-skill/scripts/test_detect_machine_sessions.py +0 -181
- package/skills/doctor-md-skill/scripts/test_render_report.py +0 -241
- package/skills/doctor-md-skill/scripts/test_verify_citations.py +0 -196
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { appName } from "./app-actions.js";
|
|
2
|
+
import { startBulkHostAddition, startBulkUninstall } from "./bulk-actions.js";
|
|
3
|
+
import { skillOf, sourceIdOf } from "./installations.js";
|
|
4
|
+
import { leavesOf, targetOf } from "./installations-tree.js";
|
|
5
|
+
import { startHostAddition, startUninstall } from "./manage-actions.js";
|
|
6
|
+
import { startUnmanagedRemoval } from "./unmanaged-actions.js";
|
|
7
|
+
import { navigateTree } from "./tree-navigation.js";
|
|
8
|
+
import { toggleNode } from "./tree.js";
|
|
9
|
+
import { toggledMark } from "./view-state.js";
|
|
10
|
+
/** Why a key does nothing on a group row. */
|
|
11
|
+
const GROUP_ROW_MESSAGE = {
|
|
12
|
+
A: "Adoption is not available for group rows",
|
|
13
|
+
u: "Uninstall is not available for group rows",
|
|
14
|
+
h: "Host changes are not available for group rows",
|
|
15
|
+
i: "Host changes are not available for group rows",
|
|
16
|
+
};
|
|
17
|
+
/** The marked leaves in display order; marks whose rows no longer exist are ignored. */
|
|
18
|
+
function markedTargets(context) {
|
|
19
|
+
const roots = context.navigation.latestRoots();
|
|
20
|
+
return leavesOf(roots).filter((node) => context.marks.markedRef.current.has(node.id)).flatMap((node) => targetOf(node) ?? []);
|
|
21
|
+
}
|
|
22
|
+
function markRow(context, row) {
|
|
23
|
+
if (!row)
|
|
24
|
+
return;
|
|
25
|
+
if (row.node.data.kind === "group") {
|
|
26
|
+
context.setMessage({ kind: "error", text: "Mark skills, not group rows" });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
context.setMessage(undefined);
|
|
30
|
+
context.marks.setMarked(toggledMark(context.marks.markedRef.current, row.node.id));
|
|
31
|
+
}
|
|
32
|
+
/** `a`: marks every listed leaf, or unmarks them when they are all marked already. */
|
|
33
|
+
function markAllListed(context, visible) {
|
|
34
|
+
const { markedRef, setMarked } = context.marks;
|
|
35
|
+
const leaves = visible.filter((candidate) => candidate.node.data.kind !== "group").map((candidate) => candidate.node.id);
|
|
36
|
+
if (leaves.length === 0) {
|
|
37
|
+
context.setMessage({ kind: "error", text: "There are no skills to select" });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
context.setMessage(undefined);
|
|
41
|
+
const all = leaves.every((id) => markedRef.current.has(id));
|
|
42
|
+
setMarked(all
|
|
43
|
+
? new Set([...markedRef.current].filter((id) => !leaves.includes(id)))
|
|
44
|
+
: new Set([...markedRef.current, ...leaves]));
|
|
45
|
+
}
|
|
46
|
+
function actOnUnmanaged(context, group, input, key) {
|
|
47
|
+
if (input === "u")
|
|
48
|
+
startUnmanagedRemoval(context.manage, group);
|
|
49
|
+
else if (input === "A" || key.return)
|
|
50
|
+
context.startAdoption(group);
|
|
51
|
+
else
|
|
52
|
+
context.setMessage({ kind: "error", text: "Host changes are not available for unmanaged skills" });
|
|
53
|
+
}
|
|
54
|
+
function actOnInstallation(context, data, item, input, key) {
|
|
55
|
+
if (input === "A" || key.return) {
|
|
56
|
+
context.setMessage({ kind: "error", text: "Adoption applies to unmanaged skills; u uninstalls and h adds hosts here" });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (item.row.scope === "project") {
|
|
60
|
+
context.setMessage({ kind: "error", text: input === "u" ? "Project uninstall is not supported" : "Project installations are managed with the CLI" });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const sourceId = sourceIdOf(item.row.selection, data.sources);
|
|
64
|
+
if (sourceId === undefined) {
|
|
65
|
+
context.setMessage({ kind: "error", text: "The Source of this installation is no longer registered" });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const skill = skillOf(item.row, sourceId);
|
|
69
|
+
if (input === "u")
|
|
70
|
+
startUninstall(context.manage, skill);
|
|
71
|
+
else
|
|
72
|
+
startHostAddition(context.manage, skill);
|
|
73
|
+
}
|
|
74
|
+
/** `A`, Enter, `u`, `h` and `i` on the highlighted row. */
|
|
75
|
+
function actOnRow(context, snapshot, row, input, key) {
|
|
76
|
+
const { data } = context;
|
|
77
|
+
if (!row || !data)
|
|
78
|
+
return;
|
|
79
|
+
const item = row.node.data;
|
|
80
|
+
if (item.kind === "group") {
|
|
81
|
+
if (key.return)
|
|
82
|
+
context.navigation.setExpanded(toggleNode(snapshot.open, row));
|
|
83
|
+
else
|
|
84
|
+
context.setMessage({ kind: "error", text: GROUP_ROW_MESSAGE[input] });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
context.setMessage(undefined);
|
|
88
|
+
if (item.kind === "unmanaged")
|
|
89
|
+
actOnUnmanaged(context, item.group, input, key);
|
|
90
|
+
else if (item.kind === "app") {
|
|
91
|
+
if (input === "A")
|
|
92
|
+
context.setMessage({ kind: "error", text: "Adoption applies to unmanaged Skills, not Apps" });
|
|
93
|
+
else if (key.return && item.row.inspection.status !== "needs approval") {
|
|
94
|
+
const { entry, inspection } = item.row;
|
|
95
|
+
const approved = inspection.status === "installed" || inspection.status === "not installed";
|
|
96
|
+
context.setMessage({
|
|
97
|
+
kind: approved ? "ok" : "error",
|
|
98
|
+
text: approved ? `${appName(entry)} is already approved` : inspection.reason ?? inspection.status,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
else
|
|
102
|
+
context.startApps([item.row.entry], key.return ? "approve" : input);
|
|
103
|
+
}
|
|
104
|
+
else if (item.kind === "installation")
|
|
105
|
+
actOnInstallation(context, data, item, input, key);
|
|
106
|
+
}
|
|
107
|
+
/** Keys while browsing: navigation, marks, bulk actions and the per-row actions. */
|
|
108
|
+
export function handleBrowseKey(context, input, key) {
|
|
109
|
+
const snapshot = context.navigation.latest();
|
|
110
|
+
if (navigateTree(context.navigation, snapshot, context.height, input, key))
|
|
111
|
+
return;
|
|
112
|
+
const row = snapshot.visible[snapshot.at];
|
|
113
|
+
const rowAction = input === "A" || key.return || input === "u" || input === "h" || input === "i";
|
|
114
|
+
const appMarks = leavesOf(context.navigation.latestRoots()).filter(node => context.marks.markedRef.current.has(node.id) && node.data.kind === "app");
|
|
115
|
+
if ((input === "u" || input === "h" || input === "i") && appMarks.length) {
|
|
116
|
+
if (markedTargets(context).length)
|
|
117
|
+
context.setMessage({ kind: "error", text: "Select Apps or Skills separately for bulk actions" });
|
|
118
|
+
else
|
|
119
|
+
context.startApps(appMarks.flatMap(node => node.data.kind === "app" ? [node.data.row.entry] : []), input);
|
|
120
|
+
}
|
|
121
|
+
else if (input === " ")
|
|
122
|
+
markRow(context, row);
|
|
123
|
+
else if (input === "a")
|
|
124
|
+
markAllListed(context, snapshot.visible);
|
|
125
|
+
else if ((input === "u" || input === "h" || input === "i") && context.data && markedTargets(context).length > 0) {
|
|
126
|
+
// Marks, including those on collapsed rows, turn the action into a bulk one.
|
|
127
|
+
context.setMessage(undefined);
|
|
128
|
+
const targets = markedTargets(context);
|
|
129
|
+
if (input === "u")
|
|
130
|
+
void startBulkUninstall(context.manage, targets);
|
|
131
|
+
else
|
|
132
|
+
startBulkHostAddition(context.manage, targets);
|
|
133
|
+
}
|
|
134
|
+
else if (rowAction)
|
|
135
|
+
actOnRow(context, snapshot, row, input, key);
|
|
136
|
+
}
|
|
@@ -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,80 @@
|
|
|
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: {
|
|
33
|
+
kind: "group",
|
|
34
|
+
label: `Unmanaged (user-global) (${data.unmanaged.length})${data.unmanagedWarnings?.length ? ` — ${data.unmanagedWarnings.join("; ")}` : ""}`,
|
|
35
|
+
},
|
|
36
|
+
children: data.unmanaged.map((group) => ({ id: `unmanaged:${group.name}`, data: { kind: "unmanaged", group } })),
|
|
37
|
+
});
|
|
38
|
+
if (data.apps?.length || data.appsError)
|
|
39
|
+
roots.push({
|
|
40
|
+
id: "scope:apps", data: { kind: "group", label: `Apps (user-global) (${data.apps?.length ?? 0})${data.appsError ? ` — ${data.appsError}` : ""}` },
|
|
41
|
+
children: (data.apps ?? []).map(row => ({ id: `app:${row.entry.file}`, data: { kind: "app", row } })),
|
|
42
|
+
});
|
|
43
|
+
return roots;
|
|
44
|
+
}
|
|
45
|
+
/** Version, policy and hosts of a leaf; `adopted` / `modified` only when true (the legend explains them). */
|
|
46
|
+
export function describeDetails({ selection, policy, installed }) {
|
|
47
|
+
return `${installed} (${policy}) [${selection.hosts.join(", ")}]`;
|
|
48
|
+
}
|
|
49
|
+
export function markersOf({ adopted, modified }) {
|
|
50
|
+
return [adopted ? "adopted" : "", modified ? "modified" : ""].filter((flag) => flag !== "").join(" ");
|
|
51
|
+
}
|
|
52
|
+
/** Every leaf of the tree in display order, whether or not its group is open. */
|
|
53
|
+
export function leavesOf(roots) {
|
|
54
|
+
return roots.flatMap((node) => (node.children === undefined ? (node.data.kind === "group" ? [] : [node]) : leavesOf(node.children)));
|
|
55
|
+
}
|
|
56
|
+
export function targetOf(node) {
|
|
57
|
+
const { data } = node;
|
|
58
|
+
if (data.kind === "installation")
|
|
59
|
+
return { id: node.id, leaf: { kind: "installation", row: data.row } };
|
|
60
|
+
if (data.kind === "unmanaged")
|
|
61
|
+
return { id: node.id, leaf: { kind: "unmanaged", group: data.group } };
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
export function describeNode(row) {
|
|
65
|
+
const { data } = row.node;
|
|
66
|
+
if (data.kind === "group")
|
|
67
|
+
return `${row.expandable ? (row.expanded ? "▾" : "▸") : " "} ${data.label}`;
|
|
68
|
+
if (data.kind === "installation")
|
|
69
|
+
return data.row.selection.path;
|
|
70
|
+
if (data.kind === "app") {
|
|
71
|
+
const { entry, inspection } = data.row;
|
|
72
|
+
return `${entry.recipe?.name ?? entry.file} ${inspection.status === "invalid" ? `invalid recipe: ${inspection.reason}` : inspection.installedVersion ?? inspection.status}`;
|
|
73
|
+
}
|
|
74
|
+
return `${data.group.name} [${data.group.hosts.join(", ")}]`;
|
|
75
|
+
}
|
|
76
|
+
/** `symlink → <target>` for the locations that are links, one entry per distinct target. */
|
|
77
|
+
export function describeLinks(group) {
|
|
78
|
+
const targets = [...new Set(group.locations.flatMap((location) => location.linkTarget === undefined ? [] : [location.linkTarget]))];
|
|
79
|
+
return targets.length === 0 ? "" : `symlink → ${targets.join(", ")}`;
|
|
80
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
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 { createAppOperations } from "../app-flow.js";
|
|
5
|
+
import { handleAppKey, isAppMode, startAppAction } from "./app-actions.js";
|
|
6
|
+
import { loadInstallations } from "./installations.js";
|
|
7
|
+
import { BROWSE } from "./installations-mode.js";
|
|
8
|
+
import { errorText } from "./batch.js";
|
|
9
|
+
import { handleAdoptionKey, startAdoption } from "./adoption-actions.js";
|
|
10
|
+
import { handleBrowseKey } from "./installations-browse.js";
|
|
11
|
+
import { handleManageKey } from "./manage-actions.js";
|
|
12
|
+
import { ModePanel } from "./installations-panel.js";
|
|
13
|
+
import { TreeRowLine } from "./installations-rows.js";
|
|
14
|
+
import { buildTree, leavesOf, markersOf } from "./installations-tree.js";
|
|
15
|
+
import { ListFooter } from "./panel-parts.js";
|
|
16
|
+
import { useTreeNavigation } from "./tree-navigation.js";
|
|
17
|
+
import { defaultExpanded } from "./tree.js";
|
|
18
|
+
import { theme } from "./theme.js";
|
|
19
|
+
import { useMarks, useMounted } from "./view-state.js";
|
|
20
|
+
import { computeWindow, useListHeight } from "./window.js";
|
|
21
|
+
import { useKeys } from "./keys.js";
|
|
22
|
+
const NO_ENVIRONMENT = {};
|
|
23
|
+
const LEGEND = "adopted: tracked in place · modified: changed on disk";
|
|
24
|
+
export function InstallationsView({ operations, environment, onCapturingChange, listHeight }) {
|
|
25
|
+
const height = useListHeight(listHeight, 7);
|
|
26
|
+
const env = environment ?? NO_ENVIRONMENT;
|
|
27
|
+
const apps = useMemo(() => createAppOperations({ homeDirectory: env.homeDirectory, ...env.appEnvironment }), [env]);
|
|
28
|
+
const [state, setState] = useState({ status: "loading" });
|
|
29
|
+
const [mode, setModeState] = useState(BROWSE);
|
|
30
|
+
// Mirrors the mode synchronously so later keystrokes and the shell never see stale state.
|
|
31
|
+
const modeRef = useRef(BROWSE);
|
|
32
|
+
const marks = useMarks();
|
|
33
|
+
const mounted = useMounted();
|
|
34
|
+
const [message, setMessage] = useState();
|
|
35
|
+
const setMode = (next) => {
|
|
36
|
+
modeRef.current = next;
|
|
37
|
+
onCapturingChange?.(next.kind !== "browse");
|
|
38
|
+
setModeState(next);
|
|
39
|
+
};
|
|
40
|
+
const loadGeneration = useRef(0);
|
|
41
|
+
const reload = useCallback(async () => {
|
|
42
|
+
const generation = ++loadGeneration.current;
|
|
43
|
+
try {
|
|
44
|
+
const data = await loadInstallations(operations, env, apps);
|
|
45
|
+
if (mounted.current && generation === loadGeneration.current)
|
|
46
|
+
setState({ status: "ready", data });
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (mounted.current && generation === loadGeneration.current)
|
|
50
|
+
setState({ status: "error", message: errorText(error) });
|
|
51
|
+
}
|
|
52
|
+
}, [operations, env, apps, mounted]);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
void reload();
|
|
55
|
+
}, [reload]);
|
|
56
|
+
const data = state.status === "ready" ? state.data : undefined;
|
|
57
|
+
const roots = useMemo(() => (data ? buildTree(data) : []), [data]);
|
|
58
|
+
const navigation = useTreeNavigation(roots, defaultExpanded);
|
|
59
|
+
const { rows, index } = navigation;
|
|
60
|
+
const checked = useRef(new Set());
|
|
61
|
+
const highlighted = rows[index]?.node.data;
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (highlighted?.kind !== "app")
|
|
64
|
+
return;
|
|
65
|
+
const { entry } = highlighted.row;
|
|
66
|
+
const identity = `${entry.file}:${entry.hash}`;
|
|
67
|
+
if (checked.current.has(identity))
|
|
68
|
+
return;
|
|
69
|
+
checked.current.add(identity);
|
|
70
|
+
const generation = loadGeneration.current;
|
|
71
|
+
void apps.inspect(entry).then(inspection => {
|
|
72
|
+
if (!mounted.current || generation !== loadGeneration.current)
|
|
73
|
+
return;
|
|
74
|
+
setState(previous => previous.status !== "ready" ? previous : {
|
|
75
|
+
...previous,
|
|
76
|
+
data: {
|
|
77
|
+
...previous.data,
|
|
78
|
+
apps: previous.data.apps?.map(row => row.entry.file === entry.file && row.entry.hash === entry.hash
|
|
79
|
+
? { entry, inspection } : row),
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}).catch(error => {
|
|
83
|
+
if (!mounted.current || generation !== loadGeneration.current)
|
|
84
|
+
return;
|
|
85
|
+
checked.current.delete(identity);
|
|
86
|
+
setMessage({ kind: "error", text: errorText(error) });
|
|
87
|
+
});
|
|
88
|
+
}, [highlighted, apps, mounted]);
|
|
89
|
+
const installed = useMemo(() => ({
|
|
90
|
+
global: (data?.global ?? []).map((row) => row.selection),
|
|
91
|
+
project: (data?.project ?? []).map((row) => row.selection),
|
|
92
|
+
sources: data?.sources ?? [],
|
|
93
|
+
unmanagedNames: new Set(),
|
|
94
|
+
}), [data]);
|
|
95
|
+
const manage = {
|
|
96
|
+
operations,
|
|
97
|
+
env,
|
|
98
|
+
installed,
|
|
99
|
+
setMode,
|
|
100
|
+
browse: () => setMode(BROWSE),
|
|
101
|
+
setMessage,
|
|
102
|
+
isMounted: () => mounted.current,
|
|
103
|
+
unmark: (ids) => marks.setMarked(new Set([...marks.markedRef.current].filter((id) => !ids.includes(id)))),
|
|
104
|
+
finish: async (result) => {
|
|
105
|
+
checked.current.clear();
|
|
106
|
+
await reload();
|
|
107
|
+
if (!mounted.current)
|
|
108
|
+
return;
|
|
109
|
+
setMessage(result);
|
|
110
|
+
setMode(BROWSE);
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
const adoption = {
|
|
114
|
+
operations,
|
|
115
|
+
env,
|
|
116
|
+
setMode,
|
|
117
|
+
browse: manage.browse,
|
|
118
|
+
setMessage,
|
|
119
|
+
isMounted: manage.isMounted,
|
|
120
|
+
reload,
|
|
121
|
+
};
|
|
122
|
+
const browse = {
|
|
123
|
+
navigation,
|
|
124
|
+
height,
|
|
125
|
+
marks,
|
|
126
|
+
data,
|
|
127
|
+
manage,
|
|
128
|
+
startAdoption: (group) => void startAdoption(adoption, group),
|
|
129
|
+
setMessage,
|
|
130
|
+
startApps: (entries, input) => void startAppAction({ apps, manage }, entries, input),
|
|
131
|
+
};
|
|
132
|
+
useKeys((input, key) => {
|
|
133
|
+
const current = modeRef.current;
|
|
134
|
+
if (current.kind === "browse")
|
|
135
|
+
handleBrowseKey(browse, input, key);
|
|
136
|
+
else if (isAppMode(current))
|
|
137
|
+
handleAppKey({ apps, manage }, current, input, key);
|
|
138
|
+
else if (!handleManageKey(manage, current, input, key))
|
|
139
|
+
handleAdoptionKey(adoption, current, input, key);
|
|
140
|
+
});
|
|
141
|
+
if (state.status === "loading")
|
|
142
|
+
return _jsx(Text, { children: "Loading installations..." });
|
|
143
|
+
if (state.status === "error")
|
|
144
|
+
return _jsxs(Text, { color: theme.error, children: ["Error: ", state.message] });
|
|
145
|
+
const ready = state.data;
|
|
146
|
+
const window = computeWindow(rows.length, index, height);
|
|
147
|
+
const listed = rows.slice(window.start, window.end);
|
|
148
|
+
const markedLeaves = leavesOf(roots).filter(node => marks.marked.has(node.id));
|
|
149
|
+
const selectedCount = markedLeaves.length;
|
|
150
|
+
const markedApps = markedLeaves.filter(node => node.data.kind === "app").length;
|
|
151
|
+
// The legend explains the markers, so it shows only while a rendered row carries one.
|
|
152
|
+
const legendNeeded = listed.some((row) => row.node.data.kind === "installation" && markersOf(row.node.data.row) !== "");
|
|
153
|
+
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 }), markedApps > 0 ? _jsxs(Text, { color: theme.muted, children: [markedApps, " Apps marked"] }) : null, legendNeeded ? _jsx(Text, { color: theme.muted, children: LEGEND }) : null, _jsx(ModePanel, { mode: mode })] }));
|
|
154
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createAppOperations } from "../app-flow.js";
|
|
4
|
+
import { defaultProjectManifestPath, ProjectManifestStore, } from "../project-manifest.js";
|
|
5
|
+
import { formatVersionPolicy } from "../skill-install.js";
|
|
6
|
+
import { inspectSkillRemovals, userGlobalRemovalOptions } from "../skill-removal.js";
|
|
7
|
+
import { scanUserGlobalSkillInventory, } from "../user-global-skill-inventory.js";
|
|
8
|
+
import { errorText } from "./batch.js";
|
|
9
|
+
import { prepareInstall } from "./catalog-installs.js";
|
|
10
|
+
/** Hosts in display order; the Claude root serves Claude Code, the shared root serves the others. */
|
|
11
|
+
const HOST_DISPLAY_ORDER = ["claude", "pi", "codex", "opencode"];
|
|
12
|
+
const ROOT_HOSTS = {
|
|
13
|
+
agents: ["pi", "codex", "opencode"],
|
|
14
|
+
claude: ["claude"],
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Groups the unmanaged directories, the unmanaged symlinks and the Claude links that
|
|
18
|
+
* expose an unmanaged canonical directory by Skill name. Hosts come from the root kinds.
|
|
19
|
+
*/
|
|
20
|
+
export function groupUnmanaged(inventory) {
|
|
21
|
+
const byName = new Map();
|
|
22
|
+
const add = (name, location) => byName.set(name, [...(byName.get(name) ?? []), location]);
|
|
23
|
+
for (const entry of inventory.unmanaged)
|
|
24
|
+
add(entry.name, { path: entry.path, root: entry.root, entry });
|
|
25
|
+
for (const link of inventory.symlinks)
|
|
26
|
+
add(link.name, { path: link.path, root: link.root, linkTarget: link.target });
|
|
27
|
+
const unmanagedPaths = new Set(inventory.unmanaged.map((entry) => path.resolve(entry.path)));
|
|
28
|
+
for (const exposure of inventory.exposures) {
|
|
29
|
+
if (unmanagedPaths.has(path.resolve(exposure.target))) {
|
|
30
|
+
add(exposure.name, { path: exposure.path, root: exposure.root, linkTarget: exposure.target });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return [...byName]
|
|
34
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
35
|
+
.map(([name, locations]) => {
|
|
36
|
+
const ordered = [...locations].sort((left, right) => Number(left.root === "claude") - Number(right.root === "claude"));
|
|
37
|
+
const hosts = HOST_DISPLAY_ORDER.filter((host) => ordered.some((location) => ROOT_HOSTS[location.root].includes(host)));
|
|
38
|
+
return { name, locations: ordered, hosts };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export function homeOf(environment) {
|
|
42
|
+
return path.resolve(environment.homeDirectory ?? homedir());
|
|
43
|
+
}
|
|
44
|
+
function projectRootOf(environment) {
|
|
45
|
+
return path.resolve(environment.projectRoot ?? process.cwd());
|
|
46
|
+
}
|
|
47
|
+
function sourceLabel(source) {
|
|
48
|
+
if (source.kind === "builtin")
|
|
49
|
+
return source.id;
|
|
50
|
+
return "url" in source ? `${source.url}${source.ref === undefined ? "" : `@${source.ref}`}` : source.path;
|
|
51
|
+
}
|
|
52
|
+
/** Short label of version evidence: the package version or the first commit characters. */
|
|
53
|
+
export function formatVersionEvidence(evidence) {
|
|
54
|
+
if (!evidence)
|
|
55
|
+
return "unknown";
|
|
56
|
+
return evidence.kind === "builtin-package" ? evidence.version : evidence.commit.slice(0, 7);
|
|
57
|
+
}
|
|
58
|
+
function installedVersion(selection) {
|
|
59
|
+
return formatVersionEvidence(selection.installation?.resolvedVersion);
|
|
60
|
+
}
|
|
61
|
+
async function toRow(scope, selection, inspectionOptions) {
|
|
62
|
+
const modified = await inspectSkillRemovals([selection], inspectionOptions).then((plan) => plan.inspections[0]?.modified, () => undefined);
|
|
63
|
+
return {
|
|
64
|
+
scope,
|
|
65
|
+
selection,
|
|
66
|
+
source: sourceLabel(selection.source),
|
|
67
|
+
policy: formatVersionPolicy(selection.version),
|
|
68
|
+
installed: installedVersion(selection),
|
|
69
|
+
adopted: selection.installation?.adopted === true,
|
|
70
|
+
...(modified === undefined ? {} : { modified }),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** Loads both scopes and the unmanaged user-global inventory the CLI's `update check` uses. */
|
|
74
|
+
export async function loadInstallations(operations, environment, appOperations = createAppOperations({ homeDirectory: homeOf(environment), ...environment.appEnvironment })) {
|
|
75
|
+
const home = homeOf(environment);
|
|
76
|
+
let apps = [];
|
|
77
|
+
let appsError;
|
|
78
|
+
try {
|
|
79
|
+
const tracked = await appOperations.trackedApps();
|
|
80
|
+
apps = await Promise.all((await appOperations.load()).filter(entry => entry.applicable).map(async (entry) => ({
|
|
81
|
+
entry,
|
|
82
|
+
inspection: await appOperations.inspect(entry, {
|
|
83
|
+
installedVersion: tracked.find(record => record.name === entry.recipe?.name
|
|
84
|
+
&& record.recipeFile === entry.canonicalFile)?.installedVersion,
|
|
85
|
+
}),
|
|
86
|
+
})));
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
appsError = errorText(error);
|
|
90
|
+
}
|
|
91
|
+
const globalRecords = operations.listUserGlobalInstallations
|
|
92
|
+
? await operations.listUserGlobalInstallations()
|
|
93
|
+
: undefined;
|
|
94
|
+
const globalOptions = userGlobalRemovalOptions(home, environment.installationOptions);
|
|
95
|
+
const global = globalRecords
|
|
96
|
+
? await Promise.all(globalRecords.map((record) => toRow("user-global", record, globalOptions)))
|
|
97
|
+
: undefined;
|
|
98
|
+
const store = environment.projectManifestStore ??
|
|
99
|
+
new ProjectManifestStore(defaultProjectManifestPath(projectRootOf(environment)));
|
|
100
|
+
let projectRecords = [];
|
|
101
|
+
let projectError;
|
|
102
|
+
try {
|
|
103
|
+
projectRecords = (await store.load()).skills;
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
projectError = error instanceof Error ? error.message : String(error);
|
|
107
|
+
}
|
|
108
|
+
const projectOptions = {
|
|
109
|
+
projectRoot: projectRootOf(environment),
|
|
110
|
+
sourceAccess: { readSkillTree: async () => [] },
|
|
111
|
+
...environment.installationOptions,
|
|
112
|
+
};
|
|
113
|
+
const project = await Promise.all(projectRecords.map((record) => toRow("project", record, projectOptions)));
|
|
114
|
+
const unmanagedWarnings = [];
|
|
115
|
+
const unmanaged = globalRecords
|
|
116
|
+
? groupUnmanaged(await scanUserGlobalSkillInventory({
|
|
117
|
+
homeDirectory: home,
|
|
118
|
+
managedInstallations: globalRecords,
|
|
119
|
+
appEnvironment: environment.appEnvironment,
|
|
120
|
+
reportWarning: warning => unmanagedWarnings.push(warning),
|
|
121
|
+
}))
|
|
122
|
+
: [];
|
|
123
|
+
const sources = await operations.listSources().catch(() => []);
|
|
124
|
+
return { unmanagedWarnings, global, project, ...(projectError === undefined ? {} : { projectError }), unmanaged, sources, apps, ...(appsError === undefined ? {} : { appsError }) };
|
|
125
|
+
}
|
|
126
|
+
/** Source id of an installation's Source, when that Source is still registered. */
|
|
127
|
+
export function sourceIdOf(selection, sources) {
|
|
128
|
+
const source = selection.source;
|
|
129
|
+
if (source.kind === "builtin")
|
|
130
|
+
return source.id;
|
|
131
|
+
if (!("url" in source))
|
|
132
|
+
return undefined;
|
|
133
|
+
return sources.find((candidate) => candidate.kind === "git" && candidate.url === source.url)?.id;
|
|
134
|
+
}
|
|
135
|
+
/** The Skill of an installation, as the shared manage flows expect it. */
|
|
136
|
+
export function skillOf(row, sourceId) {
|
|
137
|
+
return { sourceId, path: row.selection.path, name: path.posix.basename(row.selection.path), description: "" };
|
|
138
|
+
}
|
|
139
|
+
/** Source Skills whose directory name equals the unmanaged Skill's, across every registered Source. */
|
|
140
|
+
export async function findAdoptionCandidates(operations, entry) {
|
|
141
|
+
if (!operations.discoverSkills)
|
|
142
|
+
throw new Error("Discovery is not supported by the configured operations");
|
|
143
|
+
const ids = (await operations.listSources()).map((source) => source.id);
|
|
144
|
+
return (await operations.discoverSkills(ids)).filter((skill) => path.posix.basename(skill.path) === entry.name);
|
|
145
|
+
}
|
|
146
|
+
/** Hosts that already read the unmanaged Skill's location, so adopting it exposes nothing new. */
|
|
147
|
+
export function defaultAdoptionHosts(entry) {
|
|
148
|
+
return entry.host === "claude" ? ["claude"] : ["pi", "codex", "opencode"];
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Adoption keeps the existing location, so it only applies when that exact
|
|
152
|
+
* location's content is identical to the selected Source Skill (the CLI's rule).
|
|
153
|
+
*/
|
|
154
|
+
export function adoptionVerdict(prepared, entry) {
|
|
155
|
+
const { inspection } = prepared.plan;
|
|
156
|
+
const location = path.resolve(entry.path);
|
|
157
|
+
const identical = inspection.locations.some((candidate) => candidate.status === "identical" && path.resolve(candidate.path) === location);
|
|
158
|
+
if (identical)
|
|
159
|
+
return { adoptable: true };
|
|
160
|
+
const collision = inspection.collision ?? inspection.locations.find((candidate) => candidate.collision)?.collision;
|
|
161
|
+
return {
|
|
162
|
+
adoptable: false,
|
|
163
|
+
reason: collision
|
|
164
|
+
? `Cannot adopt ${entry.path}: it does not match the selected Source Skill (${collision.reason} at ${collision.path}); nothing was changed`
|
|
165
|
+
: `Cannot adopt ${entry.path}: no identical existing copy was found, so an install would create a new copy instead; nothing was changed`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/** Plan step: the shared install plan (refreshing the Source) plus the adoption verdict. Changes nothing. */
|
|
169
|
+
export async function prepareAdoption(operations, environment, entry, skill, choice) {
|
|
170
|
+
const prepared = await prepareInstall(operations, environment, skill, { ...choice, scope: "user-global" });
|
|
171
|
+
return { prepared, verdict: adoptionVerdict(prepared, entry) };
|
|
172
|
+
}
|
|
@@ -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
|
+
}
|