@iseoane/agent-depot 0.2.1 → 0.4.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 +32 -0
- package/README.md +186 -1
- package/dist/src/app-cli.js +172 -0
- package/dist/src/app-flow.js +464 -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 +117 -20
- package/dist/src/process-runner.js +10 -1
- package/dist/src/profile-cli.js +88 -0
- package/dist/src/profile-export.js +135 -0
- package/dist/src/profile-import.js +218 -0
- package/dist/src/profile.js +130 -0
- package/dist/src/project-manifest.js +6 -0
- package/dist/src/skill-install.js +12 -0
- package/dist/src/source-state.js +3 -28
- package/dist/src/tui/app-actions.js +157 -0
- package/dist/src/tui/app.js +10 -4
- package/dist/src/tui/catalog-installs.js +5 -1
- package/dist/src/tui/installations-browse.js +24 -2
- package/dist/src/tui/installations-tree.js +13 -1
- package/dist/src/tui/installations-view.js +46 -6
- package/dist/src/tui/installations.js +26 -3
- package/dist/src/tui/manage-actions.js +2 -0
- package/dist/src/tui/manage-panel.js +6 -0
- package/dist/src/tui/profile-view.js +249 -0
- package/dist/src/tui/sources-recipes.js +216 -0
- package/dist/src/tui/sources-view.js +16 -7
- package/dist/src/tui/unmanaged-actions.js +2 -2
- package/dist/src/tui/updates-flow.js +18 -3
- package/dist/src/tui/updates-keys.js +9 -0
- package/dist/src/tui/updates-model.js +3 -3
- package/dist/src/tui/updates-panel.js +4 -2
- package/dist/src/tui/updates-view.js +1 -1
- package/dist/src/tui/updates.js +76 -5
- package/dist/src/unmanaged-removal.js +3 -0
- package/dist/src/user-global-skill-inventory.js +7 -1
- package/package.json +4 -4
- package/skills/agent-depot-apprecipe/SKILL.md +44 -0
- package/skills/agent-depot-apprecipe/assets/example.md +25 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { PROJECT_HOSTS } from "../project-manifest.js";
|
|
4
|
+
import { errorText, runBatch } from "./batch.js";
|
|
5
|
+
import { answerYesNo, hostChecklistKey } from "./mode-keys.js";
|
|
6
|
+
import { HostChecklist, PreviewConfirm } from "./panel-parts.js";
|
|
7
|
+
export const APP_KINDS = ["app-approve", "app-hosts", "app-confirm", "app-manual"];
|
|
8
|
+
/** Narrows the view's mode without asserting a partial mode is an App mode. */
|
|
9
|
+
export function isAppMode(mode) {
|
|
10
|
+
return APP_KINDS.some(kind => kind === mode.kind);
|
|
11
|
+
}
|
|
12
|
+
/** Recipe name, falling back to the file identity for invalid recipes. */
|
|
13
|
+
export const appName = (entry) => entry.recipe?.name ?? entry.file;
|
|
14
|
+
export function appStepLines(step) {
|
|
15
|
+
return [
|
|
16
|
+
...(step.argv ? [`argv: ${JSON.stringify(step.argv)}`, `Executable: ${step.executable ?? "unavailable"}`] : []),
|
|
17
|
+
...(step.manual ? [`Manual: ${step.manual}`] : []),
|
|
18
|
+
`Working directory: ${step.cwd}`,
|
|
19
|
+
...(step.warning ? [step.warning] : []),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
async function prepareNext(context, requests, results = [], failures = 0) {
|
|
23
|
+
const { manage, apps } = context;
|
|
24
|
+
manage.setMode({ kind: "busy", label: "Preparing App step..." });
|
|
25
|
+
// Independent planning failures never stop the remaining marked Apps.
|
|
26
|
+
const [request, ...remaining] = requests;
|
|
27
|
+
if (!request) {
|
|
28
|
+
await manage.finish({ kind: failures > 0 ? "error" : "ok", text: results.join("\n") });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const plan = await apps.planLifecycle(request.entry, request.action, request.host);
|
|
33
|
+
if (manage.isMounted()) {
|
|
34
|
+
manage.setMode({
|
|
35
|
+
kind: !plan.argv && plan.manual ? "app-manual" : "app-confirm",
|
|
36
|
+
plan, remaining, results, failures,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (manage.isMounted()) {
|
|
42
|
+
await prepareNext(context, remaining, [...results, `${appName(request.entry)}: failed: ${errorText(error)}`], failures + 1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Opens approval, the Host checklist, or the first lifecycle preview for selected Apps. */
|
|
47
|
+
export async function startAppAction(context, entries, input) {
|
|
48
|
+
const { manage, apps } = context;
|
|
49
|
+
manage.setMessage(undefined);
|
|
50
|
+
try {
|
|
51
|
+
if (input === "approve") {
|
|
52
|
+
const entry = entries[0];
|
|
53
|
+
if (!entry)
|
|
54
|
+
throw new Error("No App selected");
|
|
55
|
+
manage.setMode({ kind: "busy", label: "Preparing recipe approval..." });
|
|
56
|
+
const steps = await apps.preview(entry);
|
|
57
|
+
if (manage.isMounted()) {
|
|
58
|
+
manage.setMode({ kind: "app-approve", entry, lines: steps.flatMap(step => [step.step, ...appStepLines(step)]) });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else if (input === "h") {
|
|
62
|
+
const choices = PROJECT_HOSTS.filter(host => entries.some(entry => entry.recipe?.setup?.[host] || entry.recipe?.teardown?.[host]));
|
|
63
|
+
if (!choices.length)
|
|
64
|
+
throw new Error("No declared Host steps");
|
|
65
|
+
// This is an action selector, not a claim about persisted Host wiring.
|
|
66
|
+
manage.setMode({
|
|
67
|
+
kind: "app-hosts", entries, choices, cursor: 0,
|
|
68
|
+
selected: choices.filter(host => entries.some(entry => entry.recipe?.teardown?.[host])),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
await prepareNext(context, entries.map(entry => ({ entry, action: input === "u" ? "uninstall" : "install" })));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
await manage.finish({ kind: "error", text: errorText(error) });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function execute(context, mode) {
|
|
80
|
+
const { manage, apps } = context;
|
|
81
|
+
manage.setMode({ kind: "busy", label: "Running App step..." });
|
|
82
|
+
let manualRequired = false;
|
|
83
|
+
const [outcome] = await runBatch([mode.plan], async (plan) => {
|
|
84
|
+
const result = mode.kind === "app-manual"
|
|
85
|
+
? await apps.completeManual(plan, true)
|
|
86
|
+
: await apps.executeLifecycle(plan, true);
|
|
87
|
+
if (result.status === "manual required") {
|
|
88
|
+
manualRequired = true;
|
|
89
|
+
if (manage.isMounted())
|
|
90
|
+
manage.setMode({ ...mode, kind: "app-manual" });
|
|
91
|
+
return "manual required";
|
|
92
|
+
}
|
|
93
|
+
if (result.status === "failed")
|
|
94
|
+
throw new Error(result.reason);
|
|
95
|
+
manage.unmark([`app:${plan.entry.file}`]);
|
|
96
|
+
return `${appName(plan.entry)}: ${result.status}${result.installedVersion ? ` ${result.installedVersion}` : ""}`;
|
|
97
|
+
}, (plan, error) => `${appName(plan.entry)}: failed: ${errorText(error)}`);
|
|
98
|
+
if (manage.isMounted() && outcome && !manualRequired) {
|
|
99
|
+
await prepareNext(context, mode.remaining, [...mode.results, outcome.line], mode.failures + (outcome.ok ? 0 : 1));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Applies checklist and y/n keys, continuing independent batch steps after cancellation or failure. */
|
|
103
|
+
export function handleAppKey(context, mode, input, key) {
|
|
104
|
+
const { manage, apps } = context;
|
|
105
|
+
const cancel = () => {
|
|
106
|
+
manage.browse();
|
|
107
|
+
manage.setMessage({ kind: "ok", text: "App action cancelled" });
|
|
108
|
+
};
|
|
109
|
+
if (mode.kind === "app-hosts") {
|
|
110
|
+
const outcome = hostChecklistKey(mode, mode.choices, input, key);
|
|
111
|
+
if (outcome.kind === "cancel")
|
|
112
|
+
cancel();
|
|
113
|
+
else if (outcome.kind === "update")
|
|
114
|
+
manage.setMode({ ...mode, ...outcome.state });
|
|
115
|
+
else if (key.return) {
|
|
116
|
+
const requests = mode.entries.flatMap(entry => mode.choices.flatMap(host => {
|
|
117
|
+
const action = mode.selected.includes(host) ? "setup" : "teardown";
|
|
118
|
+
return entry.recipe?.[action]?.[host] ? [{ entry, action, host }] : [];
|
|
119
|
+
}));
|
|
120
|
+
if (!requests.length)
|
|
121
|
+
manage.setMessage({ kind: "error", text: "No declared steps for this selection" });
|
|
122
|
+
else
|
|
123
|
+
void prepareNext(context, requests);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else
|
|
127
|
+
answerYesNo(input, key, () => {
|
|
128
|
+
if (mode.kind !== "app-approve") {
|
|
129
|
+
void execute(context, mode);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
manage.setMode({ kind: "busy", label: "Approving recipe..." });
|
|
133
|
+
void apps.approve(mode.entry).then(() => manage.finish({ kind: "ok", text: `Approved ${appName(mode.entry)}` }), error => manage.finish({ kind: "error", text: errorText(error) }));
|
|
134
|
+
}, () => {
|
|
135
|
+
if (mode.kind === "app-confirm" || mode.kind === "app-manual") {
|
|
136
|
+
void prepareNext(context, mode.remaining, [...mode.results, `${appName(mode.plan.entry)}: cancelled`], mode.failures);
|
|
137
|
+
}
|
|
138
|
+
else
|
|
139
|
+
cancel();
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/** Renders App prompts using the existing checklist and preview-confirm panels. */
|
|
143
|
+
export function AppPanel({ mode }) {
|
|
144
|
+
if (mode.kind === "app-hosts") {
|
|
145
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["App Host steps: checked = setup, unchecked = teardown. Wiring is not tracked. Space/1-", mode.choices.length, " toggle, j/k move, Enter continue, Esc cancel."] }), _jsx(HostChecklist, { choices: mode.choices, cursor: mode.cursor, selected: mode.selected })] }));
|
|
146
|
+
}
|
|
147
|
+
if (mode.kind === "app-approve") {
|
|
148
|
+
return _jsx(PreviewConfirm, { lines: mode.lines, question: `Approve ${appName(mode.entry)}? y/n` });
|
|
149
|
+
}
|
|
150
|
+
return (_jsx(PreviewConfirm, { lines: [
|
|
151
|
+
`Environment: ${mode.plan.environment}`,
|
|
152
|
+
`App: ${appName(mode.plan.entry)} ${mode.plan.action}${mode.plan.host ? ` (${mode.plan.host})` : ""}`,
|
|
153
|
+
...appStepLines(mode.plan),
|
|
154
|
+
], question: mode.kind === "app-manual"
|
|
155
|
+
? "Manual step done, check now? y/n"
|
|
156
|
+
: `${mode.plan.action} ${appName(mode.plan.entry)}? y/n` }));
|
|
157
|
+
}
|
package/dist/src/tui/app.js
CHANGED
|
@@ -4,25 +4,29 @@ import { useCallback, useRef, useState } from "react";
|
|
|
4
4
|
import { AGENT_DEPOT_PACKAGE_VERSION } from "../project-manifest.js";
|
|
5
5
|
import { CatalogView } from "./catalog-view.js";
|
|
6
6
|
import { InstallationsView } from "./installations-view.js";
|
|
7
|
+
import { useKeys } from "./keys.js";
|
|
8
|
+
import { ProfileView } from "./profile-view.js";
|
|
7
9
|
import { SourcesView } from "./sources-view.js";
|
|
8
10
|
import { separatorLine, theme } from "./theme.js";
|
|
9
11
|
import { UpdatesView } from "./updates-view.js";
|
|
10
|
-
import { useKeys } from "./keys.js";
|
|
11
12
|
const TABS = [
|
|
12
13
|
["sources", "1 Sources"],
|
|
13
14
|
["catalog", "2 Catalog"],
|
|
14
15
|
["installations", "3 Installations"],
|
|
15
16
|
["updates", "4 Updates"],
|
|
17
|
+
["profile", "5 Import/Export"],
|
|
16
18
|
];
|
|
17
19
|
/** Compact keys of the current view; the tab bar already shows how to switch views. */
|
|
18
20
|
function hintsOf(view, catalogFocusedOnSource) {
|
|
19
21
|
switch (view) {
|
|
20
22
|
case "sources":
|
|
21
|
-
return "j/k move · space mark · a all · Enter catalog · n add · r refresh · d remove · q quit";
|
|
23
|
+
return "Tab section · j/k move · space mark · a all · Enter catalog · n add · r refresh · d remove · q quit";
|
|
22
24
|
case "catalog":
|
|
23
25
|
return `j/k move · Enter expand · space mark · a all · i install · / filter${catalogFocusedOnSource ? " · s all sources" : ""} · q quit`;
|
|
24
26
|
case "installations":
|
|
25
27
|
return "j/k move · Enter expand · space mark · a all · u uninstall · h add hosts · A adopt · q quit";
|
|
28
|
+
case "profile":
|
|
29
|
+
return "e export · i import · q quit";
|
|
26
30
|
case "updates":
|
|
27
31
|
return "j/k move · space mark · a all · Enter preview · r check again · q quit";
|
|
28
32
|
}
|
|
@@ -55,9 +59,11 @@ export function App({ operations, environment, onExit }) {
|
|
|
55
59
|
showView("installations");
|
|
56
60
|
else if (input === "4")
|
|
57
61
|
showView("updates");
|
|
62
|
+
else if (input === "5")
|
|
63
|
+
showView("profile");
|
|
58
64
|
});
|
|
59
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: theme.accent, children: ["Agent Depot", AGENT_DEPOT_PACKAGE_VERSION === undefined ? "" : ` v${AGENT_DEPOT_PACKAGE_VERSION}`] }), _jsx(Text, { children: TABS.map(([name, label], position) => (_jsxs(Text, { color: view === name ? theme.accent : theme.inactive, bold: view === name, children: [position > 0 ? " " : "", view === name ? `[${label}]` : ` ${label} `] }, name))) }), _jsx(Text, { color: theme.muted, children: separatorLine(stdout.columns) }), view === "sources" ? (_jsx(SourcesView, { operations: operations, onCapturingChange: onCapturingChange, onOpenCatalog: (source) => {
|
|
65
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: theme.accent, children: ["Agent Depot", AGENT_DEPOT_PACKAGE_VERSION === undefined ? "" : ` v${AGENT_DEPOT_PACKAGE_VERSION}`] }), _jsx(Text, { children: TABS.map(([name, label], position) => (_jsxs(Text, { color: view === name ? theme.accent : theme.inactive, bold: view === name, children: [position > 0 ? " " : "", view === name ? `[${label}]` : ` ${label} `] }, name))) }), _jsx(Text, { color: theme.muted, children: separatorLine(stdout.columns) }), view === "sources" ? (_jsx(SourcesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange, onOpenCatalog: (source) => {
|
|
60
66
|
setCatalogSourceId(source.id);
|
|
61
67
|
setView("catalog");
|
|
62
|
-
} })) : view === "installations" ? (_jsx(InstallationsView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : view === "updates" ? (_jsx(UpdatesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : (_jsx(CatalogView, { operations: operations, sourceId: catalogSourceId, environment: environment, onCapturingChange: onCapturingChange })), _jsx(Text, { color: theme.muted, children: capturing ? "Enter submit · Esc cancel · y/n confirm · space toggle" : hintsOf(view, catalogSourceId !== undefined) })] }));
|
|
68
|
+
} })) : view === "installations" ? (_jsx(InstallationsView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : view === "profile" ? (_jsx(ProfileView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : view === "updates" ? (_jsx(UpdatesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : (_jsx(CatalogView, { operations: operations, sourceId: catalogSourceId, environment: environment, onCapturingChange: onCapturingChange })), _jsx(Text, { color: theme.muted, children: capturing ? "Enter submit · Esc cancel · y/n confirm · space toggle" : hintsOf(view, catalogSourceId !== undefined) })] }));
|
|
63
69
|
}
|
|
@@ -20,7 +20,11 @@ export async function loadInstalledSkills(operations, environment) {
|
|
|
20
20
|
manifestStoreOf(environment).load().then((manifest) => manifest.skills, () => []),
|
|
21
21
|
operations.listSources().catch(() => []),
|
|
22
22
|
]);
|
|
23
|
-
const unmanagedNames = await scanUserGlobalSkillInventory({
|
|
23
|
+
const unmanagedNames = await scanUserGlobalSkillInventory({
|
|
24
|
+
homeDirectory: homeOf(environment),
|
|
25
|
+
appEnvironment: environment.appEnvironment,
|
|
26
|
+
managedInstallations: global,
|
|
27
|
+
}).then((inventory) => new Set([...inventory.unmanaged, ...inventory.symlinks].map((entry) => entry.name)), () => new Set());
|
|
24
28
|
return { global, project, sources, unmanagedNames };
|
|
25
29
|
}
|
|
26
30
|
/** Whether an unmanaged copy with the Skill's directory name exists on disk (the name an install would use). */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { appName } from "./app-actions.js";
|
|
1
2
|
import { startBulkHostAddition, startBulkUninstall } from "./bulk-actions.js";
|
|
2
3
|
import { skillOf, sourceIdOf } from "./installations.js";
|
|
3
4
|
import { leavesOf, targetOf } from "./installations-tree.js";
|
|
@@ -86,7 +87,21 @@ function actOnRow(context, snapshot, row, input, key) {
|
|
|
86
87
|
context.setMessage(undefined);
|
|
87
88
|
if (item.kind === "unmanaged")
|
|
88
89
|
actOnUnmanaged(context, item.group, input, key);
|
|
89
|
-
else
|
|
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")
|
|
90
105
|
actOnInstallation(context, data, item, input, key);
|
|
91
106
|
}
|
|
92
107
|
/** Keys while browsing: navigation, marks, bulk actions and the per-row actions. */
|
|
@@ -96,7 +111,14 @@ export function handleBrowseKey(context, input, key) {
|
|
|
96
111
|
return;
|
|
97
112
|
const row = snapshot.visible[snapshot.at];
|
|
98
113
|
const rowAction = input === "A" || key.return || input === "u" || input === "h" || input === "i";
|
|
99
|
-
|
|
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 === " ")
|
|
100
122
|
markRow(context, row);
|
|
101
123
|
else if (input === "a")
|
|
102
124
|
markAllListed(context, snapshot.visible);
|
|
@@ -29,9 +29,17 @@ export function buildTree(data) {
|
|
|
29
29
|
});
|
|
30
30
|
roots.push({
|
|
31
31
|
id: "scope:unmanaged",
|
|
32
|
-
data: {
|
|
32
|
+
data: {
|
|
33
|
+
kind: "group",
|
|
34
|
+
label: `Unmanaged (user-global) (${data.unmanaged.length})${data.unmanagedWarnings?.length ? ` — ${data.unmanagedWarnings.join("; ")}` : ""}`,
|
|
35
|
+
},
|
|
33
36
|
children: data.unmanaged.map((group) => ({ id: `unmanaged:${group.name}`, data: { kind: "unmanaged", group } })),
|
|
34
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
|
+
});
|
|
35
43
|
return roots;
|
|
36
44
|
}
|
|
37
45
|
/** Version, policy and hosts of a leaf; `adopted` / `modified` only when true (the legend explains them). */
|
|
@@ -59,6 +67,10 @@ export function describeNode(row) {
|
|
|
59
67
|
return `${row.expandable ? (row.expanded ? "▾" : "▸") : " "} ${data.label}`;
|
|
60
68
|
if (data.kind === "installation")
|
|
61
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
|
+
}
|
|
62
74
|
return `${data.group.name} [${data.group.hosts.join(", ")}]`;
|
|
63
75
|
}
|
|
64
76
|
/** `symlink → <target>` for the locations that are links, one entry per distinct target. */
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
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";
|
|
4
6
|
import { loadInstallations } from "./installations.js";
|
|
5
7
|
import { BROWSE } from "./installations-mode.js";
|
|
6
8
|
import { errorText } from "./batch.js";
|
|
@@ -22,6 +24,7 @@ const LEGEND = "adopted: tracked in place · modified: changed on disk";
|
|
|
22
24
|
export function InstallationsView({ operations, environment, onCapturingChange, listHeight }) {
|
|
23
25
|
const height = useListHeight(listHeight, 7);
|
|
24
26
|
const env = environment ?? NO_ENVIRONMENT;
|
|
27
|
+
const apps = useMemo(() => createAppOperations({ homeDirectory: env.homeDirectory, ...env.appEnvironment }), [env]);
|
|
25
28
|
const [state, setState] = useState({ status: "loading" });
|
|
26
29
|
const [mode, setModeState] = useState(BROWSE);
|
|
27
30
|
// Mirrors the mode synchronously so later keystrokes and the shell never see stale state.
|
|
@@ -34,17 +37,19 @@ export function InstallationsView({ operations, environment, onCapturingChange,
|
|
|
34
37
|
onCapturingChange?.(next.kind !== "browse");
|
|
35
38
|
setModeState(next);
|
|
36
39
|
};
|
|
40
|
+
const loadGeneration = useRef(0);
|
|
37
41
|
const reload = useCallback(async () => {
|
|
42
|
+
const generation = ++loadGeneration.current;
|
|
38
43
|
try {
|
|
39
|
-
const data = await loadInstallations(operations, env);
|
|
40
|
-
if (mounted.current)
|
|
44
|
+
const data = await loadInstallations(operations, env, apps);
|
|
45
|
+
if (mounted.current && generation === loadGeneration.current)
|
|
41
46
|
setState({ status: "ready", data });
|
|
42
47
|
}
|
|
43
48
|
catch (error) {
|
|
44
|
-
if (mounted.current)
|
|
49
|
+
if (mounted.current && generation === loadGeneration.current)
|
|
45
50
|
setState({ status: "error", message: errorText(error) });
|
|
46
51
|
}
|
|
47
|
-
}, [operations, env, mounted]);
|
|
52
|
+
}, [operations, env, apps, mounted]);
|
|
48
53
|
useEffect(() => {
|
|
49
54
|
void reload();
|
|
50
55
|
}, [reload]);
|
|
@@ -52,6 +57,35 @@ export function InstallationsView({ operations, environment, onCapturingChange,
|
|
|
52
57
|
const roots = useMemo(() => (data ? buildTree(data) : []), [data]);
|
|
53
58
|
const navigation = useTreeNavigation(roots, defaultExpanded);
|
|
54
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]);
|
|
55
89
|
const installed = useMemo(() => ({
|
|
56
90
|
global: (data?.global ?? []).map((row) => row.selection),
|
|
57
91
|
project: (data?.project ?? []).map((row) => row.selection),
|
|
@@ -68,6 +102,7 @@ export function InstallationsView({ operations, environment, onCapturingChange,
|
|
|
68
102
|
isMounted: () => mounted.current,
|
|
69
103
|
unmark: (ids) => marks.setMarked(new Set([...marks.markedRef.current].filter((id) => !ids.includes(id)))),
|
|
70
104
|
finish: async (result) => {
|
|
105
|
+
checked.current.clear();
|
|
71
106
|
await reload();
|
|
72
107
|
if (!mounted.current)
|
|
73
108
|
return;
|
|
@@ -92,11 +127,14 @@ export function InstallationsView({ operations, environment, onCapturingChange,
|
|
|
92
127
|
manage,
|
|
93
128
|
startAdoption: (group) => void startAdoption(adoption, group),
|
|
94
129
|
setMessage,
|
|
130
|
+
startApps: (entries, input) => void startAppAction({ apps, manage }, entries, input),
|
|
95
131
|
};
|
|
96
132
|
useKeys((input, key) => {
|
|
97
133
|
const current = modeRef.current;
|
|
98
134
|
if (current.kind === "browse")
|
|
99
135
|
handleBrowseKey(browse, input, key);
|
|
136
|
+
else if (isAppMode(current))
|
|
137
|
+
handleAppKey({ apps, manage }, current, input, key);
|
|
100
138
|
else if (!handleManageKey(manage, current, input, key))
|
|
101
139
|
handleAdoptionKey(adoption, current, input, key);
|
|
102
140
|
});
|
|
@@ -107,8 +145,10 @@ export function InstallationsView({ operations, environment, onCapturingChange,
|
|
|
107
145
|
const ready = state.data;
|
|
108
146
|
const window = computeWindow(rows.length, index, height);
|
|
109
147
|
const listed = rows.slice(window.start, window.end);
|
|
110
|
-
const
|
|
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;
|
|
111
151
|
// The legend explains the markers, so it shows only while a rendered row carries one.
|
|
112
152
|
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 })] }));
|
|
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 })] }));
|
|
114
154
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { createAppOperations } from "../app-flow.js";
|
|
3
4
|
import { defaultProjectManifestPath, ProjectManifestStore, } from "../project-manifest.js";
|
|
4
5
|
import { formatVersionPolicy } from "../skill-install.js";
|
|
5
6
|
import { inspectSkillRemovals, userGlobalRemovalOptions } from "../skill-removal.js";
|
|
6
7
|
import { scanUserGlobalSkillInventory, } from "../user-global-skill-inventory.js";
|
|
8
|
+
import { errorText } from "./batch.js";
|
|
7
9
|
import { prepareInstall } from "./catalog-installs.js";
|
|
8
10
|
/** Hosts in display order; the Claude root serves Claude Code, the shared root serves the others. */
|
|
9
11
|
const HOST_DISPLAY_ORDER = ["claude", "pi", "codex", "opencode"];
|
|
@@ -69,8 +71,23 @@ async function toRow(scope, selection, inspectionOptions) {
|
|
|
69
71
|
};
|
|
70
72
|
}
|
|
71
73
|
/** Loads both scopes and the unmanaged user-global inventory the CLI's `update check` uses. */
|
|
72
|
-
export async function loadInstallations(operations, environment) {
|
|
74
|
+
export async function loadInstallations(operations, environment, appOperations = createAppOperations({ homeDirectory: homeOf(environment), ...environment.appEnvironment })) {
|
|
73
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
|
+
}
|
|
74
91
|
const globalRecords = operations.listUserGlobalInstallations
|
|
75
92
|
? await operations.listUserGlobalInstallations()
|
|
76
93
|
: undefined;
|
|
@@ -94,11 +111,17 @@ export async function loadInstallations(operations, environment) {
|
|
|
94
111
|
...environment.installationOptions,
|
|
95
112
|
};
|
|
96
113
|
const project = await Promise.all(projectRecords.map((record) => toRow("project", record, projectOptions)));
|
|
114
|
+
const unmanagedWarnings = [];
|
|
97
115
|
const unmanaged = globalRecords
|
|
98
|
-
? groupUnmanaged(await scanUserGlobalSkillInventory({
|
|
116
|
+
? groupUnmanaged(await scanUserGlobalSkillInventory({
|
|
117
|
+
homeDirectory: home,
|
|
118
|
+
managedInstallations: globalRecords,
|
|
119
|
+
appEnvironment: environment.appEnvironment,
|
|
120
|
+
reportWarning: warning => unmanagedWarnings.push(warning),
|
|
121
|
+
}))
|
|
99
122
|
: [];
|
|
100
123
|
const sources = await operations.listSources().catch(() => []);
|
|
101
|
-
return { global, project, ...(projectError === undefined ? {} : { projectError }), unmanaged, sources };
|
|
124
|
+
return { unmanagedWarnings, global, project, ...(projectError === undefined ? {} : { projectError }), unmanaged, sources, apps, ...(appsError === undefined ? {} : { appsError }) };
|
|
102
125
|
}
|
|
103
126
|
/** Source id of an installation's Source, when that Source is still registered. */
|
|
104
127
|
export function sourceIdOf(selection, sources) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { APP_KINDS } from "./app-actions.js";
|
|
1
2
|
import { globalRecord, missingHosts, prepareHostAddition, prepareHostRemoval, prepareUninstall, runHostAddition, runHostRemoval, runUninstall, UNINSTALL_REFUSALS, } from "./catalog-installs.js";
|
|
2
3
|
import { errorText } from "./batch.js";
|
|
3
4
|
import { BULK_KINDS, handleBulkKey } from "./bulk-actions.js";
|
|
@@ -11,6 +12,7 @@ const MANAGE_KINDS = new Set([
|
|
|
11
12
|
"confirm-host-exposure",
|
|
12
13
|
"confirm-host-remove",
|
|
13
14
|
"confirm-uninstall",
|
|
15
|
+
...APP_KINDS,
|
|
14
16
|
...UNMANAGED_KINDS,
|
|
15
17
|
...BULK_KINDS,
|
|
16
18
|
"busy",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsxs as _jsxs, Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
+
import { AppPanel } from "./app-actions.js";
|
|
3
4
|
import { ChecklistLines, HostChecklist, PreviewConfirm, PreviewLines } from "./panel-parts.js";
|
|
4
5
|
import { theme } from "./theme.js";
|
|
5
6
|
const plural = (n) => `${n} ${n === 1 ? "item" : "items"}`;
|
|
@@ -26,6 +27,11 @@ function HostsPreview({ plan }) {
|
|
|
26
27
|
/** Renders the prompt of a manage mode: host lists, previews and confirmations. */
|
|
27
28
|
export function ManagePanel({ mode }) {
|
|
28
29
|
switch (mode.kind) {
|
|
30
|
+
case "app-approve":
|
|
31
|
+
case "app-hosts":
|
|
32
|
+
case "app-confirm":
|
|
33
|
+
case "app-manual":
|
|
34
|
+
return _jsx(AppPanel, { mode: mode });
|
|
29
35
|
case "remove-scope":
|
|
30
36
|
return _jsxs(Text, { children: ["Uninstall ", mode.skill.name, " (hosts: ", mode.hosts.join(", "), "): 1 all hosts 2 choose hosts (Esc cancel)"] });
|
|
31
37
|
case "hosts-add":
|