@iseoane/agent-depot 0.2.1 → 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 +22 -0
- package/README.md +109 -0
- 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 +112 -20
- package/dist/src/process-runner.js +10 -1
- 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/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/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
|
+
function stepLines(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, ...stepLines(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
|
+
...stepLines(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
|
+
}
|
|
@@ -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":
|
|
@@ -36,7 +36,7 @@ export async function prepareUnmanagedRemoval(operations, environment, group, lo
|
|
|
36
36
|
if (!operations.listUserGlobalInstallations)
|
|
37
37
|
throw new Error("Uninstall is not supported by the configured operations");
|
|
38
38
|
const installations = await operations.listUserGlobalInstallations();
|
|
39
|
-
const options = { homeDirectory: homeOf(environment), managedInstallations: installations };
|
|
39
|
+
const options = { homeDirectory: homeOf(environment), appEnvironment: environment.appEnvironment, managedInstallations: installations };
|
|
40
40
|
const items = [];
|
|
41
41
|
const preview = [`WARNING: agent-depot did not create these files (unmanaged skill ${group.name})`];
|
|
42
42
|
// A chosen directory takes the links that point at it along, so none is left dangling.
|
|
@@ -70,7 +70,7 @@ export async function prepareUnmanagedRemoval(operations, environment, group, lo
|
|
|
70
70
|
}
|
|
71
71
|
/** Execute step: each location is rechecked and removed on its own, so one failure does not stop the others. */
|
|
72
72
|
export async function runUnmanagedRemoval(prepared, operations, environment) {
|
|
73
|
-
const context = { operations, homeDirectory: homeOf(environment) };
|
|
73
|
+
const context = { operations, homeDirectory: homeOf(environment), appEnvironment: environment.appEnvironment };
|
|
74
74
|
const lines = [];
|
|
75
75
|
let failed = false;
|
|
76
76
|
const notRemoved = new Set();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useRef, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { errorText } from "./batch.js";
|
|
3
3
|
import { BROWSE } from "./updates-mode.js";
|
|
4
4
|
import { CHECKING, emptyData, PHASE_LABEL, REFRESHING } from "./updates-model.js";
|
|
@@ -7,6 +7,10 @@ export function useUpdatesFlow(inputs) {
|
|
|
7
7
|
const { operations, env, clock, mounted, checkedRef, latest, setChecked, setCursor, setMode, setMessage } = inputs;
|
|
8
8
|
const [state, setState] = useState({ status: "loading", label: REFRESHING });
|
|
9
9
|
const loadToken = useRef(0);
|
|
10
|
+
const pendingAnswer = useRef(undefined);
|
|
11
|
+
useEffect(() => () => {
|
|
12
|
+
pendingAnswer.current?.(false);
|
|
13
|
+
}, []);
|
|
10
14
|
const load = useCallback(async (refresh) => {
|
|
11
15
|
const token = ++loadToken.current;
|
|
12
16
|
const phase = (next) => {
|
|
@@ -45,7 +49,7 @@ export function useUpdatesFlow(inputs) {
|
|
|
45
49
|
try {
|
|
46
50
|
const prepared = await prepareUpdates(current, selected);
|
|
47
51
|
if (mounted.current)
|
|
48
|
-
setMode({ kind: "preview",
|
|
52
|
+
setMode({ kind: "preview", prepared });
|
|
49
53
|
}
|
|
50
54
|
catch (error) {
|
|
51
55
|
if (!mounted.current)
|
|
@@ -58,7 +62,18 @@ export function useUpdatesFlow(inputs) {
|
|
|
58
62
|
setMode({ kind: "busy", label: "Applying updates..." });
|
|
59
63
|
let result;
|
|
60
64
|
try {
|
|
61
|
-
const outcome = await runUpdates(prepared, confirmedPaths)
|
|
65
|
+
const outcome = await runUpdates(prepared, confirmedPaths, (plan, reason) => new Promise(resolve => {
|
|
66
|
+
if (!mounted.current) {
|
|
67
|
+
resolve(false);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const answer = (done) => {
|
|
71
|
+
pendingAnswer.current = undefined;
|
|
72
|
+
resolve(done);
|
|
73
|
+
};
|
|
74
|
+
pendingAnswer.current = answer;
|
|
75
|
+
setMode({ kind: "manual", plan, reason, answer });
|
|
76
|
+
}));
|
|
62
77
|
result = { kind: outcome.failed === 0 ? "ok" : "error", lines: outcome.lines };
|
|
63
78
|
}
|
|
64
79
|
catch (error) {
|
|
@@ -80,6 +80,15 @@ function handlePreviewKey(context, mode, input, key) {
|
|
|
80
80
|
/** Handles a key in the current Updates mode; `busy` ignores keys. */
|
|
81
81
|
export function handleUpdatesKey(context, mode, input, key) {
|
|
82
82
|
switch (mode.kind) {
|
|
83
|
+
case "manual":
|
|
84
|
+
answerYesNo(input, key, () => {
|
|
85
|
+
context.setMode({ kind: "busy", label: "Checking App version..." });
|
|
86
|
+
mode.answer(true);
|
|
87
|
+
}, () => {
|
|
88
|
+
context.setMode({ kind: "busy", label: "Continuing updates..." });
|
|
89
|
+
mode.answer(false);
|
|
90
|
+
});
|
|
91
|
+
return;
|
|
83
92
|
case "browse":
|
|
84
93
|
handleBrowseKey(context, input, key);
|
|
85
94
|
return;
|
|
@@ -15,9 +15,9 @@ const matchesScope = (row, filter) => filter === "all" || row.scope === filter;
|
|
|
15
15
|
/** The rows the list shows for a scope filter and fold state, and the cursor positions they make up. */
|
|
16
16
|
export function viewOf(data, scope, showUnknown) {
|
|
17
17
|
const shown = (data?.rows ?? []).filter((row) => matchesScope(row, scope));
|
|
18
|
-
const updatable = shown.filter((row) => row.
|
|
19
|
-
const unknown = shown.filter((row) => row.
|
|
20
|
-
const upToDate = shown.filter((row) => row.
|
|
18
|
+
const updatable = shown.filter((row) => row.status === "update available");
|
|
19
|
+
const unknown = shown.filter((row) => row.status === "cannot assess");
|
|
20
|
+
const upToDate = shown.filter((row) => row.status === "up to date").length;
|
|
21
21
|
const entries = [
|
|
22
22
|
...updatable.map((row) => ({ kind: "update", row })),
|
|
23
23
|
...(unknown.length > 0 ? [{ kind: "summary" }] : []),
|
|
@@ -7,7 +7,7 @@ import { PreviewLines } from "./panel-parts.js";
|
|
|
7
7
|
/** What the last check found out: its age, how the sources were refreshed, and what failed. */
|
|
8
8
|
export function CheckStatus({ data, now }) {
|
|
9
9
|
const errors = data.results.filter((result) => result.error !== undefined);
|
|
10
|
-
return (_jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.muted, children: ["checked ", formatAgo(now - data.checkedAt), " \u00B7 ", describeRefresh(data)] }), data.refresh.failed.map((failure) => (_jsxs(Text, { color: theme.warning, children: ["refresh failed for ", failure.source, ": ", failure.reason] }, failure.source))), errors.map((result) => _jsxs(Text, { color: theme.error, children: ["Error (", result.scope, "): ", result.error] }, result.scope))] }));
|
|
10
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.muted, children: ["checked ", formatAgo(now - data.checkedAt), " \u00B7 ", describeRefresh(data)] }), data.refresh.failed.map((failure) => (_jsxs(Text, { color: theme.warning, children: ["refresh failed for ", failure.source, ": ", failure.reason] }, failure.source))), data.appError ? _jsxs(Text, { color: theme.error, children: ["Error (Apps): ", data.appError] }) : null, errors.map((result) => _jsxs(Text, { color: theme.error, children: ["Error (", result.scope, "): ", result.error] }, result.scope))] }));
|
|
11
11
|
}
|
|
12
12
|
/** One line of the list: an update, the fold of unassessable items, or one of those when unfolded. */
|
|
13
13
|
export function EntryLine({ entry, selected, checked, view, showUnknown }) {
|
|
@@ -28,12 +28,14 @@ export function UpdatesFooter({ indicator, upToDate, updatableCount, checkedCoun
|
|
|
28
28
|
/** The prompt of the preview and confirmation modes; browsing and the check itself show nothing. */
|
|
29
29
|
export function ModePanel({ mode }) {
|
|
30
30
|
switch (mode.kind) {
|
|
31
|
+
case "manual":
|
|
32
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["App: ", mode.plan.entry.recipe.name] }), _jsxs(Text, { children: ["Environment: ", mode.plan.environment] }), _jsxs(Text, { children: ["Working directory: ", mode.plan.cwd] }), _jsxs(Text, { children: ["Manual: ", mode.plan.manual] }), mode.reason ? _jsx(Text, { color: theme.warning, children: mode.reason }) : null, _jsx(Text, { children: "Manual step done, check now? y/n" })] }));
|
|
31
33
|
case "browse":
|
|
32
34
|
return null;
|
|
33
35
|
case "busy":
|
|
34
36
|
return mode.label === REFRESHING || mode.label === CHECKING ? null : _jsx(Text, { children: mode.label });
|
|
35
37
|
case "preview":
|
|
36
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(PreviewLines, { lines: mode.prepared.lines }), mode.prepared.failures.map(({ item, message }) => (_jsxs(Text, { color: theme.error, children: ["Preview failed for ", JSON.stringify(item.id), ": ", message] }, item.id))), mode.prepared.applicable.length === 0
|
|
38
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(PreviewLines, { lines: mode.prepared.lines }), _jsx(PreviewLines, { lines: mode.prepared.appFailures }), mode.prepared.failures.map(({ item, message }) => (_jsxs(Text, { color: theme.error, children: ["Preview failed for ", JSON.stringify(item.id), ": ", message] }, item.id))), mode.prepared.applicable.length === 0
|
|
37
39
|
? _jsx(Text, { children: "Nothing can be applied. Enter/Esc to go back" })
|
|
38
40
|
: _jsxs(Text, { children: ["Apply ", mode.prepared.applicable.length, " ", mode.prepared.applicable.length === 1 ? "update" : "updates", "? y/n"] })] }));
|
|
39
41
|
case "confirm-paths":
|
|
@@ -96,6 +96,6 @@ export function UpdatesView({ operations, environment, onCapturingChange, listHe
|
|
|
96
96
|
useKeys((input, key) => handleUpdatesKey(keyContext, modeRef.current, input, key));
|
|
97
97
|
const window = computeWindow(entries.length, index, height);
|
|
98
98
|
const view = { shown, updatable, unknown, upToDate, entries };
|
|
99
|
-
const hasErrors = (data?.results ?? []).some((result) => result.error !== undefined);
|
|
99
|
+
const hasErrors = data?.appError !== undefined || (data?.results ?? []).some((result) => result.error !== undefined);
|
|
100
100
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: theme.accent, bold: true, children: ["Updates (scope: ", scope, ") t all \u00B7 p project \u00B7 g user-global"] }), state.status === "loading" ? _jsx(Text, { children: state.label }) : null, data ? _jsx(CheckStatus, { data: data, now: clock() }) : null, data && !hasErrors && shown.length === 0 ? _jsx(Text, { children: "No installations" }) : null, entries.slice(window.start, window.end).map((entry, offset) => (_jsx(EntryLine, { entry: entry, selected: window.start + offset === index, checked: entry.kind === "update" && checked.includes(entry.row.key), view: view, showUnknown: showUnknown }, entry.kind === "summary" ? "summary" : entry.row.key))), _jsx(UpdatesFooter, { indicator: window.indicator, upToDate: upToDate, updatableCount: updatable.length, checkedCount: checked.length, message: message }), _jsx(ModePanel, { mode: mode })] }));
|
|
101
101
|
}
|