@iseoane/agent-depot 0.4.1 → 0.5.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.es.md +969 -0
  3. package/README.md +244 -23
  4. package/dist/src/app-flow.js +52 -51
  5. package/dist/src/app-recipe-removal.js +63 -0
  6. package/dist/src/cli.js +122 -11
  7. package/dist/src/executable-resolution.js +54 -0
  8. package/dist/src/git-source.js +129 -6
  9. package/dist/src/package-bundles.js +614 -0
  10. package/dist/src/package-cli.js +412 -0
  11. package/dist/src/package-content.js +158 -0
  12. package/dist/src/package-flow.js +636 -0
  13. package/dist/src/package-host-plans.js +240 -0
  14. package/dist/src/package-host-state.js +349 -0
  15. package/dist/src/package-model.js +33 -0
  16. package/dist/src/package-owned-skills.js +122 -0
  17. package/dist/src/package-state.js +404 -0
  18. package/dist/src/profile-cli.js +15 -9
  19. package/dist/src/profile-export.js +44 -9
  20. package/dist/src/profile-import.js +80 -17
  21. package/dist/src/profile.js +78 -3
  22. package/dist/src/skill-discovery.js +2 -2
  23. package/dist/src/skill-install.js +16 -0
  24. package/dist/src/sources.js +11 -0
  25. package/dist/src/tui/app-actions.js +1 -1
  26. package/dist/src/tui/app.js +5 -6
  27. package/dist/src/tui/catalog-browse.js +10 -3
  28. package/dist/src/tui/catalog-panel.js +7 -3
  29. package/dist/src/tui/catalog-state.js +57 -6
  30. package/dist/src/tui/catalog-tree.js +52 -10
  31. package/dist/src/tui/catalog-view.js +27 -4
  32. package/dist/src/tui/environment.js +8 -1
  33. package/dist/src/tui/installations-browse.js +20 -2
  34. package/dist/src/tui/installations-rows.js +2 -1
  35. package/dist/src/tui/installations-tree.js +14 -1
  36. package/dist/src/tui/installations-view.js +8 -2
  37. package/dist/src/tui/installations.js +12 -2
  38. package/dist/src/tui/manage-actions.js +2 -0
  39. package/dist/src/tui/manage-panel.js +5 -0
  40. package/dist/src/tui/package-actions.js +165 -0
  41. package/dist/src/tui/package-rows.js +87 -0
  42. package/dist/src/tui/profile-view.js +36 -14
  43. package/dist/src/tui/sources-mode.js +2 -0
  44. package/dist/src/tui/sources-recipes.js +116 -53
  45. package/dist/src/tui/sources-view.js +57 -19
  46. package/dist/src/tui/tree.js +3 -4
  47. package/dist/src/tui/updates-flow.js +4 -1
  48. package/dist/src/tui/updates-panel.js +2 -2
  49. package/dist/src/tui/updates.js +72 -4
  50. package/dist/src/user-global-skill-inventory.js +7 -2
  51. package/package.json +2 -1
  52. package/skills/agent-depot-apprecipe/SKILL.md +29 -5
@@ -0,0 +1,165 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { describeCoordinates } from "../package-model.js";
3
+ import { errorText } from "./batch.js";
4
+ import { answerYesNo } from "./mode-keys.js";
5
+ import { installedEvidenceLabel, packagePlanLines } from "./package-rows.js";
6
+ import { PreviewConfirm } from "./panel-parts.js";
7
+ /** The per-row keys of a Package; Enter is approval, handled beside these. */
8
+ export const PACKAGE_ACTION_KEYS = new Map([
9
+ ["i", "install"],
10
+ ["U", "update"],
11
+ ["u", "uninstall"],
12
+ ["f", "forget"],
13
+ ]);
14
+ export const PACKAGE_KINDS = ["package-approve", "package-confirm", "package-forget"];
15
+ /** Narrows the view's mode without asserting a partial mode is a Package mode. */
16
+ export function isPackageMode(mode) {
17
+ return PACKAGE_KINDS.some(kind => kind === mode.kind);
18
+ }
19
+ /** `install`, `update`, `uninstall` and `forget` plan a step; `approve` writes the install receipt. */
20
+ export async function startPackageAction(context, target, input) {
21
+ const { manage, packages } = context;
22
+ manage.setMessage(undefined);
23
+ try {
24
+ if (input === "approve") {
25
+ manage.setMode({ kind: "busy", label: `Preparing the declaration of ${target.name}...` });
26
+ const plan = await packages.planLifecycle(target.selection, "install");
27
+ if (manage.isMounted()) {
28
+ manage.setMode({ kind: "package-approve", target, lines: packagePlanLines(plan, "needs approval") });
29
+ }
30
+ return;
31
+ }
32
+ const action = PACKAGE_ACTION_KEYS.get(input);
33
+ if (action === "forget") {
34
+ manage.setMode({ kind: "busy", label: `Preparing to forget ${target.name}...` });
35
+ const inspection = await packages.inspect(target.selection);
36
+ if (manage.isMounted()) {
37
+ manage.setMode({
38
+ kind: "package-forget",
39
+ target,
40
+ lines: [
41
+ `Package: ${target.name} (${target.selection.host})`,
42
+ `Coordinates: ${describeCoordinates(target.selection)}`,
43
+ `Installed: ${installedEvidenceLabel(inspection.installed)}`,
44
+ "Forgetting drops the Agent Depot selection record only; the host install is left as it is and no host command runs.",
45
+ ],
46
+ });
47
+ }
48
+ return;
49
+ }
50
+ if (action !== undefined) {
51
+ manage.setMode({ kind: "busy", label: `Preparing the ${action} of ${target.name}...` });
52
+ const plan = await packages.planLifecycle(target.selection, action);
53
+ const approval = await packages.approvalStatus(target.selection, action);
54
+ if (manage.isMounted())
55
+ manage.setMode({ kind: "package-confirm", plan, approval });
56
+ return;
57
+ }
58
+ await manage.finish({ kind: "error", text: `The action ${JSON.stringify(input)} is not available for Packages` });
59
+ }
60
+ catch (error) {
61
+ await manage.finish({ kind: "error", text: errorText(error) });
62
+ }
63
+ }
64
+ /** The `y` of a lifecycle confirmation whose receipt is missing approves it first, then asks again. */
65
+ async function approveThenConfirm(context, mode) {
66
+ const { manage, packages } = context;
67
+ manage.setMode({ kind: "busy", label: `Approving the ${mode.plan.action} declaration of ${mode.plan.descriptor.name}...` });
68
+ try {
69
+ await packages.approve(mode.plan.selection, mode.plan.action);
70
+ if (manage.isMounted())
71
+ manage.setMode({ ...mode, approval: "approved" });
72
+ }
73
+ catch (error) {
74
+ if (manage.isMounted())
75
+ await manage.finish({ kind: "error", text: errorText(error) });
76
+ }
77
+ }
78
+ function resultLine(plan, result) {
79
+ const name = plan.descriptor.name;
80
+ switch (result.status) {
81
+ case "installed":
82
+ return { ok: true, text: `${name}: installed (${installedEvidenceLabel(result.verified)})` };
83
+ case "updated":
84
+ return { ok: true, text: `${name}: updated (${installedEvidenceLabel(result.previous)} -> ${installedEvidenceLabel(result.verified)})` };
85
+ case "removed":
86
+ return { ok: true, text: `${name}: removed` };
87
+ case "selected":
88
+ return { ok: true, text: `${name}: selected` };
89
+ case "forgotten":
90
+ return { ok: true, text: `${name}: forgotten` };
91
+ case "not confirmed":
92
+ return { ok: true, text: `${name}: not changed` };
93
+ case "stale plan":
94
+ return { ok: false, text: `${name}: ${plan.action} not run: ${result.reason}` };
95
+ case "manual required":
96
+ return { ok: false, text: `${name}: ${plan.action} needs a manual step: ${result.reason}` };
97
+ case "failed":
98
+ return { ok: false, text: `${name}: failed: ${result.reason}${result.output === undefined ? "" : ` (${result.output})`}` };
99
+ }
100
+ }
101
+ async function executePackagePlan(context, plan) {
102
+ const { manage, packages } = context;
103
+ manage.setMode({ kind: "busy", label: `Running the ${plan.action} of ${plan.descriptor.name}...` });
104
+ let result;
105
+ try {
106
+ result = await packages.executeLifecycle(plan, true);
107
+ }
108
+ catch (error) {
109
+ await manage.finish({ kind: "error", text: errorText(error) });
110
+ return;
111
+ }
112
+ const line = resultLine(plan, result);
113
+ await manage.finish({ kind: line.ok ? "ok" : "error", text: line.text });
114
+ }
115
+ async function forgetSelection(context, mode) {
116
+ const { manage, packages } = context;
117
+ manage.setMode({ kind: "busy", label: `Forgetting ${mode.target.name}...` });
118
+ try {
119
+ await packages.forget(mode.target.selection, true);
120
+ await manage.finish({ kind: "ok", text: `Forgot ${mode.target.name}; no host command ran` });
121
+ }
122
+ catch (error) {
123
+ await manage.finish({ kind: "error", text: errorText(error) });
124
+ }
125
+ }
126
+ /** Applies the y/n keys of every Package confirmation; `n` and Esc run nothing. */
127
+ export function handlePackageKey(context, mode, input, key) {
128
+ const { manage, packages } = context;
129
+ const cancel = (text) => {
130
+ manage.setMessage({ kind: "ok", text });
131
+ manage.browse();
132
+ };
133
+ switch (mode.kind) {
134
+ case "package-approve":
135
+ answerYesNo(input, key, () => {
136
+ manage.setMode({ kind: "busy", label: `Approving the install declaration of ${mode.target.name}...` });
137
+ void packages.approve(mode.target.selection, "install").then(() => manage.finish({ kind: "ok", text: `Approved the install declaration of ${mode.target.name}` }), error => manage.finish({ kind: "error", text: errorText(error) }));
138
+ }, () => cancel("Package action cancelled"));
139
+ return;
140
+ case "package-forget":
141
+ answerYesNo(input, key, () => void forgetSelection(context, mode), () => cancel("Package action cancelled"));
142
+ return;
143
+ case "package-confirm":
144
+ answerYesNo(input, key, () => {
145
+ if (mode.approval === "needs approval")
146
+ void approveThenConfirm(context, mode);
147
+ else
148
+ void executePackagePlan(context, mode.plan);
149
+ }, () => cancel("Package action cancelled"));
150
+ return;
151
+ }
152
+ }
153
+ /** Renders a Package prompt with the existing preview panel and y/n confirmation. */
154
+ export function PackagePanel({ mode }) {
155
+ switch (mode.kind) {
156
+ case "package-approve":
157
+ return _jsx(PreviewConfirm, { lines: mode.lines, question: `Approve this install declaration for ${mode.target.name}? y/n` });
158
+ case "package-forget":
159
+ return _jsx(PreviewConfirm, { lines: mode.lines, question: `Forget the selection record of ${mode.target.name}? y/n` });
160
+ case "package-confirm":
161
+ return (_jsx(PreviewConfirm, { lines: packagePlanLines(mode.plan, mode.approval), question: mode.approval === "needs approval"
162
+ ? "Approve this declaration and continue? y/n"
163
+ : `${mode.plan.action} ${mode.plan.descriptor.name}? y/n` }));
164
+ }
165
+ }
@@ -0,0 +1,87 @@
1
+ import { classifyUpdate } from "../package-host-plans.js";
2
+ import { describeCoordinates, } from "../package-model.js";
3
+ /** Version evidence as a short label; `unknown` when the host or the bundle states none. */
4
+ export function packageVersionLabel(evidence) {
5
+ if (evidence === undefined)
6
+ return "unknown";
7
+ return evidence.kind === "manifest-version" ? evidence.version : evidence.commit.slice(0, 7);
8
+ }
9
+ /** What the host reports about an install, as one phrase. Never a guess. */
10
+ export function installedEvidenceLabel(evidence) {
11
+ switch (evidence.kind) {
12
+ case "installed": {
13
+ if (evidence.version !== undefined)
14
+ return `installed ${evidence.version}`;
15
+ if (evidence.commit !== undefined)
16
+ return `installed at ${evidence.commit.slice(0, 7)}`;
17
+ return "installed with no version evidence";
18
+ }
19
+ case "absent": {
20
+ return "not installed";
21
+ }
22
+ case "unknown": {
23
+ return `unknown (${evidence.reason})`;
24
+ }
25
+ }
26
+ }
27
+ /** The shared update vocabulary; the Updates list prints `up to date`, not `current`. */
28
+ export function packageStatusLabel(status) {
29
+ if (status === "current")
30
+ return "up to date";
31
+ if (status === "unknown")
32
+ return "cannot assess";
33
+ return status;
34
+ }
35
+ /** What a selection record itself remembers, for lists that do not read host evidence. */
36
+ export function recordedVersionLabel(record) {
37
+ if (record.verified !== undefined)
38
+ return packageVersionLabel(record.verified);
39
+ if (record.lastDelegatedCommit !== undefined)
40
+ return record.lastDelegatedCommit.slice(0, 7);
41
+ return "unknown";
42
+ }
43
+ export function packageRow(record, inspection) {
44
+ return {
45
+ selection: record.selection,
46
+ name: inspection.descriptor?.name ?? describeCoordinates(record.selection),
47
+ installed: installedEvidenceLabel(inspection.installed),
48
+ available: packageVersionLabel(inspection.available),
49
+ status: classifyUpdate(inspection.installed, inspection.available),
50
+ ...(inspection.drift === undefined ? {} : { drift: inspection.drift }),
51
+ };
52
+ }
53
+ /** Host, evidence and status of one row, as the Installations line prints it. */
54
+ export function describePackageRow(row) {
55
+ const drift = row.drift === undefined ? "" : ` drift: ${row.drift}`;
56
+ return `${row.selection.host} ${row.installed} available ${row.available} ${packageStatusLabel(row.status)}${drift}`;
57
+ }
58
+ /** One component of the inventory; the executable effect and a host-only owner are called out. */
59
+ export function describePackageComponent(component) {
60
+ const risk = component.effect === "executable" ? " runs a host process" : "";
61
+ const ownership = component.ownership === "host-only" ? " host-only: Agent Depot never manages it" : "";
62
+ const paths = component.paths.length === 0 ? "" : ` ${component.paths.join(", ")}`;
63
+ return `${component.kind} ${component.ownership} ${component.effect}${risk}${ownership}${paths}`;
64
+ }
65
+ /** The preview lines of one frozen plan: origin, inventory, argv, blast radius and approval state. */
66
+ export function packagePlanLines(plan, approval) {
67
+ const { descriptor, selection } = plan;
68
+ return [
69
+ `Environment: ${plan.environment}`,
70
+ `Package: ${descriptor.name} (${selection.host}) ${plan.action}`,
71
+ `Coordinates: ${describeCoordinates(selection)}`,
72
+ `Origin: manifest ${plan.origin.manifestDigest.slice(0, 12)}${plan.origin.resolvedCommit === undefined ? "" : ` commit ${plan.origin.resolvedCommit.slice(0, 7)}`}`,
73
+ `Available: ${packageVersionLabel(descriptor.version)}`,
74
+ `Components (${descriptor.components.length}):`,
75
+ ...descriptor.components.map((component) => ` ${describePackageComponent(component)}`),
76
+ ...(plan.commands.length === 0
77
+ ? ["This action runs no host command."]
78
+ : plan.commands.flatMap((command, position) => [
79
+ `Step ${position + 1} (${command.purpose}): ${JSON.stringify([command.executable, ...command.args])}`,
80
+ `Executable: ${command.resolvedPath ?? command.executable}`,
81
+ ])),
82
+ `Blast radius: ${plan.affects.map(describeCoordinates).join(", ")}`,
83
+ `Working directory: ${plan.cwd}`,
84
+ `Approval: ${approval === "approved" ? "approved" : "needs approval"}`,
85
+ ...plan.warnings.map((warning) => `Warning: ${warning}`),
86
+ ];
87
+ }
@@ -3,9 +3,10 @@ import { readFile } from "node:fs/promises";
3
3
  import { Box, Text } from "ink";
4
4
  import { useEffect, useMemo, useRef, useState } from "react";
5
5
  import { createAppOperations } from "../app-flow.js";
6
- import { buildProfileExport, profileExportPreview, writeProfileExport, } from "../profile-export.js";
6
+ import { buildProfileExport, describeProfilePackage, profileExportPreview, writeProfileExport, } from "../profile-export.js";
7
7
  import { applyProfileImport, buildProfileImport, } from "../profile-import.js";
8
8
  import { PROFILE_FORMAT } from "../profile.js";
9
+ import { packageOperationsOf } from "./environment.js";
9
10
  import { useKeys } from "./keys.js";
10
11
  import { answerYesNo, moveCursor, toggleChoice } from "./mode-keys.js";
11
12
  import { ChecklistLines, PreviewConfirm, PreviewLines } from "./panel-parts.js";
@@ -21,6 +22,9 @@ function rowsFromExport(plan) {
21
22
  ...plan.profile.apps.map((value, index) => ({
22
23
  block: "apps", index, label: `Apps: ${value.name} (${value.platform ?? "all platforms"})`, selectable: true,
23
24
  })),
25
+ ...plan.profile.packages.map((value, index) => ({
26
+ block: "packages", index, label: `Packages: ${describeProfilePackage(value)}`, selectable: true,
27
+ })),
24
28
  ...plan.exclusions.map((item, index) => ({
25
29
  block: "excluded", index, label: `Excluded ${item.label}: ${item.reason}`, selectable: false,
26
30
  })),
@@ -29,21 +33,35 @@ function rowsFromExport(plan) {
29
33
  })),
30
34
  ];
31
35
  }
36
+ /** The four import blocks in one indexed list, so the checklist and the confirmation share positions. */
37
+ function planEntries(plan) {
38
+ return [...plan.items, ...plan.packages];
39
+ }
32
40
  function rowsFromPlan(plan) {
33
- return plan.items.map((item, index) => ({
41
+ return planEntries(plan).map((item, index) => ({
34
42
  block: item.block, index,
35
43
  label: `${item.status}: ${item.label}${item.difference ? `; ${item.difference}` : ""}`,
36
44
  selectable: item.status === "add",
37
45
  }));
38
46
  }
39
- function selectedPlanItems(plan, selected) {
40
- return plan.items.filter((item, index) => item.status !== "add" || selected.some(row => row.index === index));
47
+ function selectedPlanEntries(plan, selected) {
48
+ return planEntries(plan).filter((item, index) => item.status !== "add" || selected.some(row => row.index === index));
49
+ }
50
+ /** A plan whose items are the chosen subset, split back into the block the writer consumes. */
51
+ function chosenPlan(plan, selected) {
52
+ const entries = selectedPlanEntries(plan, selected);
53
+ return {
54
+ ...plan,
55
+ items: entries.filter((item) => item.block !== "packages"),
56
+ packages: entries.filter((item) => item.block === "packages"),
57
+ };
41
58
  }
42
59
  /** Collect choices, display shared-core previews and confirm portable Profile transfers. */
43
60
  export function ProfileView({ operations, environment = {}, onCapturingChange, listHeight }) {
44
61
  const apps = useMemo(() => createAppOperations({
45
62
  homeDirectory: environment.homeDirectory, ...environment.appEnvironment,
46
63
  }), [environment]);
64
+ const packages = useMemo(() => packageOperationsOf(environment), [environment]);
47
65
  const [mode, setModeState] = useState("browse");
48
66
  const modeRef = useRef("browse");
49
67
  const mounted = useRef(true);
@@ -113,6 +131,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
113
131
  sources: profile.sources.filter((_, index) => isChosen("sources", index)),
114
132
  skills: profile.skills.filter((_, index) => isChosen("skills", index)),
115
133
  apps: profile.apps.filter((_, index) => isChosen("apps", index)),
134
+ packages: profile.packages.filter((_, index) => isChosen("packages", index)),
116
135
  };
117
136
  };
118
137
  useKeys((input, key) => {
@@ -123,7 +142,9 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
123
142
  if (input === "e") {
124
143
  action.current = "export";
125
144
  void run(async () => {
126
- const exported = await buildProfileExport(operations, apps, { homeDirectory: environment.homeDirectory });
145
+ const exported = await buildProfileExport(operations, apps, {
146
+ homeDirectory: environment.homeDirectory, packageOperations: packages,
147
+ });
127
148
  if (!mounted.current)
128
149
  return;
129
150
  setProfile(exported.profile);
@@ -156,7 +177,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
156
177
  else
157
178
  void run(async () => {
158
179
  const raw = JSON.parse(await readFile(typed.current, "utf8"));
159
- const imported = await buildProfileImport(raw, operations, apps);
180
+ const imported = await buildProfileImport(raw, operations, apps, {}, packages);
160
181
  profileVersion.current = raw.agentDepotVersion;
161
182
  if (!mounted.current)
162
183
  return;
@@ -183,8 +204,8 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
183
204
  result = [`Exported profile to ${typed.current}`];
184
205
  }
185
206
  else {
186
- const confirmed = { ...plan, items: selectedPlanItems(plan, selectedRef.current) };
187
- const outcomes = await applyProfileImport(confirmed, operations, apps, true, () => undefined, { homeDirectory: environment.homeDirectory, ...environment.installationOptions });
207
+ const confirmed = chosenPlan(plan, selectedRef.current);
208
+ const outcomes = await applyProfileImport(confirmed, operations, apps, true, () => undefined, { homeDirectory: environment.homeDirectory, packageOperations: packages, ...environment.installationOptions });
188
209
  result = outcomes.map(outcome => `${outcome.status}: ${outcome.item.label}${outcome.detail ? `; ${outcome.detail}` : ""}`);
189
210
  }
190
211
  if (mounted.current) {
@@ -225,13 +246,14 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
225
246
  else
226
247
  void run(async () => {
227
248
  // Reuse the CLI's core preview for the exact selected subset, including argv and Source notes.
228
- const items = selectedPlanItems(plan, selectedRef.current);
249
+ const entries = selectedPlanEntries(plan, selectedRef.current);
229
250
  const preview = await buildProfileImport({
230
251
  format: PROFILE_FORMAT, agentDepotVersion: profileVersion.current,
231
- sources: items.filter(item => item.block === "sources").map(item => item.value),
232
- skills: items.filter(item => item.block === "skills").map(item => item.value),
233
- apps: items.filter(item => item.block === "apps").map(item => item.value),
234
- }, operations, apps);
252
+ sources: entries.filter(item => item.block === "sources").map(item => item.value),
253
+ skills: entries.filter(item => item.block === "skills").map(item => item.value),
254
+ apps: entries.filter(item => item.block === "apps").map(item => item.value),
255
+ packages: entries.filter(item => item.block === "packages").map(item => item.value),
256
+ }, operations, apps, {}, packages);
235
257
  if (mounted.current)
236
258
  showLines(preview.preview, "confirm");
237
259
  });
@@ -239,7 +261,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
239
261
  });
240
262
  const listing = mode === "export-list" || mode === "import-list";
241
263
  const window = computeWindow(listing ? rows.length : lines.length, cursor, height);
242
- return _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Portable user-global Profile \u00B7 e export \u00B7 i import" }), mode === "busy" ? _jsx(Text, { children: "Loading Profile\u2026" }) : null, mode.endsWith("-path") ? _jsxs(Text, { children: ["Profile path: ", text, "_"] }) : null, listing ? ["sources", "skills", "apps", "excluded"].map(block => {
264
+ return _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Portable user-global Profile \u00B7 e export \u00B7 i import" }), mode === "busy" ? _jsx(Text, { children: "Loading Profile\u2026" }) : null, mode.endsWith("-path") ? _jsxs(Text, { children: ["Profile path: ", text, "_"] }) : null, listing ? ["sources", "skills", "apps", "packages", "excluded"].map(block => {
243
265
  const choices = rows.slice(window.start, window.end).filter(row => row.block === block);
244
266
  if (!choices.length)
245
267
  return null;
@@ -1,6 +1,8 @@
1
1
  export const initialMode = { kind: "browse" };
2
2
  export function modeReducer(mode, event) {
3
3
  switch (event.type) {
4
+ case "add-kind":
5
+ return { kind: "add-kind" };
4
6
  case "input":
5
7
  return { kind: "input", value: "" };
6
8
  case "edit":
@@ -2,6 +2,7 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { createAppOperations } from "../app-flow.js";
5
+ import { planAppRecipeRemoval, removeAppRecipe } from "../app-recipe-removal.js";
5
6
  import { BUILT_IN_SOURCE } from "../sources.js";
6
7
  import { handleCatalogActionKey } from "./catalog-flow.js";
7
8
  import { ActionPanel } from "./catalog-panel.js";
@@ -11,13 +12,11 @@ import { errorText } from "./batch.js";
11
12
  import { useMounted } from "./view-state.js";
12
13
  import { PreviewConfirm } from "./panel-parts.js";
13
14
  import { answerYesNo } from "./mode-keys.js";
14
- import { rowStyle, theme } from "./theme.js";
15
- import { computeWindow, pageStep } from "./window.js";
15
+ import { theme } from "./theme.js";
16
16
  /**
17
- * Returns section focus, key dispatch (true when consumed), the recipe panel,
18
- * and the Git list's share of the common height budget. Sources owns key dispatch.
17
+ * Recipe loading and captured actions; Sources owns the single list and cursor.
19
18
  */
20
- export function useSourcesRecipes({ operations, environment, onCapturingChange, height, gitCount, }) {
19
+ export function useSourcesRecipes({ operations, environment, onCapturingChange, highlightedFile, }) {
21
20
  const homeDirectory = environment?.homeDirectory;
22
21
  const appEnvironment = environment?.appEnvironment;
23
22
  const apps = useMemo(() => createAppOperations({
@@ -27,12 +26,8 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
27
26
  const mounted = useMounted();
28
27
  const [rows, setRows] = useState([]);
29
28
  const [error, setError] = useState();
29
+ const [notice, setNotice] = useState();
30
30
  const [loadError, setLoadError] = useState();
31
- const [focused, setFocused] = useState(false);
32
- // Ref mirrors keep Tab and subsequent keys in a single burst on the same section.
33
- const focusRef = useRef(false);
34
- const [index, setIndex] = useState(0);
35
- const indexRef = useRef(0);
36
31
  const [mode, setModeState] = useState({ kind: "browse" });
37
32
  const modeRef = useRef(mode);
38
33
  const setMode = (next) => {
@@ -45,7 +40,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
45
40
  if (mounted.current)
46
41
  setMode({ kind: "browse" });
47
42
  };
48
- const preview = async (entry, status) => {
43
+ const preview = async (entry, status, removeAfterApproval = false) => {
49
44
  setError(undefined);
50
45
  setMode({ kind: "busy" });
51
46
  try {
@@ -53,7 +48,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
53
48
  if (mounted.current) {
54
49
  setMode({
55
50
  kind: "approve",
56
- entry,
51
+ entry, removeAfterApproval,
57
52
  lines: [
58
53
  `File: ${entry.file}`,
59
54
  `Platform: ${entry.recipe?.platform ?? "all"}`,
@@ -117,66 +112,142 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
117
112
  }
118
113
  }
119
114
  };
120
- /** Handles captured modes and browse keys only in the focused section. */
115
+ const startRemoval = async (entry) => {
116
+ setError(undefined);
117
+ setMode({ kind: "busy" });
118
+ try {
119
+ const approval = await apps.approvalStatus(entry);
120
+ if (approval.status === "needs approval") {
121
+ await preview(entry, approval.status, true);
122
+ return;
123
+ }
124
+ const removal = await planAppRecipeRemoval(apps, entry);
125
+ if (mounted.current)
126
+ setMode({ kind: removal.installed ? "uninstall-question" : "remove", removal });
127
+ }
128
+ catch (error) {
129
+ if (mounted.current) {
130
+ setError(errorText(error));
131
+ setMode({ kind: "browse" });
132
+ }
133
+ }
134
+ };
135
+ const uninstall = async (current) => {
136
+ setMode({ kind: "busy" });
137
+ try {
138
+ const result = current.kind === "uninstall-manual"
139
+ ? await apps.completeManual(current.plan, true)
140
+ : await apps.executeLifecycle(current.plan, true);
141
+ if (!mounted.current)
142
+ return;
143
+ if (result.status === "manual required")
144
+ setMode({ ...current, kind: "uninstall-manual" });
145
+ else if (result.status === "untracked")
146
+ setMode({ kind: "remove", removal: current.removal });
147
+ else {
148
+ setError(`Uninstall failed: ${result.reason ?? result.status}; recipe retained`);
149
+ setMode({ kind: "browse" });
150
+ }
151
+ }
152
+ catch (error) {
153
+ if (mounted.current) {
154
+ setError(`Uninstall failed: ${errorText(error)}; recipe retained`);
155
+ setMode({ kind: "browse" });
156
+ }
157
+ }
158
+ };
159
+ const startAdd = () => {
160
+ setError(undefined);
161
+ setMode({ kind: "guide" });
162
+ };
163
+ /** Captured actions take priority; browse actions use the unified cursor's recipe. */
121
164
  const handleKey = (input, key) => {
122
165
  const current = modeRef.current;
123
166
  if (current.kind === "busy")
124
167
  return true;
125
168
  if (current.kind === "install") {
126
- // Host/scope/version are selection prompts; version chooses latest or fixed
127
- // by number. Only the separate fixed mode accepts free-text version input.
128
- // n declines these selections; fixed input and in-flight installs are untouched.
129
169
  const decline = input === "n" && ["host", "scope", "version"].includes(current.action.kind);
130
170
  handleCatalogActionKey(flow, current.action, input, decline ? { ...key, escape: true } : key);
131
171
  return true;
132
172
  }
133
173
  if (current.kind === "guide" || current.kind === "manual" || current.kind === "prompt") {
134
- if (key.escape || input === "n")
174
+ if (key.escape || input === "n") {
135
175
  setMode({ kind: "browse" });
176
+ void reload();
177
+ }
136
178
  else if (current.kind === "guide" && input === "1")
137
179
  void startAI();
138
180
  else if (current.kind === "guide" && input === "2")
139
181
  setMode({ kind: "manual" });
140
182
  return true;
141
183
  }
184
+ const cancel = () => setMode({ kind: "browse" });
142
185
  if (current.kind === "approve") {
143
186
  answerYesNo(input, key, () => {
144
187
  setMode({ kind: "busy" });
145
- void apps.approve(current.entry).then(finish, error => {
188
+ void apps.approve(current.entry).then(async () => {
189
+ await reload();
190
+ if (!mounted.current)
191
+ return;
192
+ if (current.removeAfterApproval)
193
+ await startRemoval(current.entry);
194
+ else
195
+ setMode({ kind: "browse" });
196
+ }, error => {
146
197
  if (mounted.current) {
147
198
  setError(errorText(error));
148
- setMode({ kind: "browse" });
199
+ cancel();
149
200
  }
150
201
  });
151
- }, () => setMode({ kind: "browse" }));
202
+ }, cancel);
152
203
  return true;
153
204
  }
154
- if (key.tab) {
155
- setError(undefined);
156
- focusRef.current = !focusRef.current;
157
- setFocused(focusRef.current);
205
+ if (current.kind === "uninstall-question") {
206
+ answerYesNo(input, key, () => {
207
+ setMode({ kind: "busy" });
208
+ void apps.planLifecycle(current.removal.entry, "uninstall").then(plan => {
209
+ if (mounted.current)
210
+ setMode({ kind: !plan.argv && plan.manual ? "uninstall-manual" : "uninstall-confirm", removal: current.removal, plan });
211
+ }, error => { if (mounted.current) {
212
+ setError(errorText(error));
213
+ cancel();
214
+ } });
215
+ }, cancel);
158
216
  return true;
159
217
  }
160
- if (!focusRef.current)
161
- return false;
162
- if (key.downArrow || input === "j" || key.upArrow || input === "k" || key.pageDown || key.pageUp) {
163
- const step = key.pageDown || key.pageUp ? pageStep(recipeHeight) : 1;
164
- const down = key.downArrow || input === "j" || key.pageDown;
165
- indexRef.current = Math.min(Math.max(indexRef.current + (down ? step : -step), 0), Math.max(rows.length - 1, 0));
166
- setIndex(indexRef.current);
218
+ if (current.kind === "uninstall-confirm" || current.kind === "uninstall-manual") {
219
+ answerYesNo(input, key, () => { void uninstall(current); }, cancel);
220
+ return true;
167
221
  }
168
- else if (input === "n") {
169
- setError(undefined);
170
- setMode({ kind: "guide" });
222
+ if (current.kind === "remove") {
223
+ answerYesNo(input, key, () => {
224
+ setMode({ kind: "busy" });
225
+ void removeAppRecipe(apps, current.removal, true).then(async () => {
226
+ if (mounted.current)
227
+ setNotice(`Removed App recipe: ${current.removal.file}`);
228
+ await finish();
229
+ }, error => {
230
+ if (mounted.current) {
231
+ setError(errorText(error));
232
+ cancel();
233
+ }
234
+ });
235
+ }, cancel);
236
+ return true;
171
237
  }
172
- else if (input === "r") {
238
+ const row = rows.find(row => row.entry.file === highlightedFile());
239
+ if (!row)
240
+ return false;
241
+ if (input === "n")
242
+ startAdd();
243
+ else if (input === "r")
173
244
  void reload();
174
- }
175
- else if (key.return) {
176
- const row = rows[Math.min(indexRef.current, rows.length - 1)];
177
- if (row)
178
- void preview(row.entry, row.status);
179
- }
245
+ else if (key.return)
246
+ void preview(row.entry, row.status);
247
+ else if (input === "d")
248
+ void startRemoval(row.entry);
249
+ else
250
+ return false;
180
251
  return true;
181
252
  };
182
253
  /** Reloads parsing and approval receipts, never version/latest commands. */
@@ -201,16 +272,8 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
201
272
  }
202
273
  }, [apps, mounted]);
203
274
  useEffect(() => { void reload(); }, [reload]);
204
- // Give each nonempty section half the rows, then reclaim unused Git capacity.
205
- const budget = Math.max(height, gitCount > 0 && rows.length > 0 ? 2 : 1);
206
- const gitShare = Math.min(gitCount, Math.max(1, Math.floor(budget / 2)));
207
- const recipeHeight = Math.min(rows.length, budget - gitShare);
208
- const gitHeight = Math.max(1, budget - recipeHeight);
209
- const at = Math.min(index, rows.length - 1);
210
- const window = computeWindow(rows.length, at, recipeHeight);
211
- const panel = (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { ...rowStyle(focused && rows.length === 0), children: ["App recipes \u00B7 ", apps.directory, " \u00B7 Tab switches section"] }), focused ? _jsx(Text, { color: theme.muted, children: "j/k move \u00B7 Enter preview \u00B7 n add recipe \u00B7 r reload" }) : null, loadError ? _jsxs(Text, { color: theme.error, children: ["Error loading App recipes: ", loadError] }) : null, error ? _jsx(Text, { color: theme.error, children: error }) : null, rows.slice(window.start, window.end).map(({ entry, status }, offset) => {
212
- const selected = focused && window.start + offset === at;
213
- return (_jsxs(Text, { ...rowStyle(selected), children: [selected ? "> " : " ", entry.file.split(/[\\/]/u).pop(), " \u00B7 ", status] }, entry.file));
214
- }), window.indicator ? _jsx(Text, { color: theme.muted, children: window.indicator }) : null, mode.kind === "guide" ? (_jsxs(Text, { children: ["1 With the AI skill · install if missing, then copy the prompt\n", "2 Manual · write a recipe using the schema\nn or Esc closes"] })) : null, mode.kind === "manual" ? (_jsxs(Text, { children: ["Write a JSON recipe in ", apps.directory, "\n", "Schema: agent-depot app schema \u00B7 n or Esc closes"] })) : null, mode.kind === "prompt" ? (_jsxs(Text, { children: ["Use the agent-depot-apprecipe skill to create the recipe for <app>", "\n", "Recipes: ", apps.directory, " \u00B7 n or Esc closes"] })) : null, mode.kind === "install" ? _jsx(ActionPanel, { mode: mode.action }) : null, mode.kind === "busy" ? _jsx(Text, { children: "Working..." }) : null, mode.kind === "approve" ? (_jsx(PreviewConfirm, { lines: mode.lines, question: `Approve ${appName(mode.entry)}? y/n` })) : null] }));
215
- return { focused, handleKey, gitHeight, panel };
275
+ const panel = (_jsxs(Box, { flexDirection: "column", children: [loadError ? _jsxs(Text, { color: theme.error, children: ["Error loading App recipes: ", loadError] }) : null, error ? _jsx(Text, { color: theme.error, children: error }) : null, notice ? _jsx(Text, { color: theme.success, children: notice }) : null, mode.kind === "guide" ? (_jsxs(Text, { children: ["1 With the AI skill · install if missing, then copy the prompt\n", "2 Manual · write a recipe using the schema\nn or Esc closes"] })) : null, mode.kind === "manual" ? (_jsxs(Text, { children: ["Write a JSON recipe in ", apps.directory, "\n", "Schema: agent-depot app schema \u00B7 n or Esc closes"] })) : null, mode.kind === "prompt" ? (_jsxs(Text, { children: ["Use the agent-depot-apprecipe skill to create the recipe for <app>", "\n", "Recipes: ", apps.directory, " \u00B7 n or Esc closes"] })) : null, mode.kind === "install" ? _jsx(ActionPanel, { mode: mode.action }) : null, mode.kind === "busy" ? _jsx(Text, { children: "Working..." }) : null, mode.kind === "approve" ? (_jsx(PreviewConfirm, { lines: mode.lines, question: `Approve ${appName(mode.entry)}? y/n` })) : null, mode.kind === "uninstall-question" ? (_jsxs(Text, { children: ["App ", appName(mode.removal.entry), " is installed or tracked", mode.removal.installedVersion ? ` (${mode.removal.installedVersion})` : "", ". Uninstall it before removing the recipe? y/n (n cancels removal)"] })) : null, mode.kind === "uninstall-confirm" || mode.kind === "uninstall-manual" ? (_jsx(PreviewConfirm, { lines: appStepLines(mode.plan), question: mode.kind === "uninstall-manual"
276
+ ? "Complete the manual uninstall, then check now? y/n"
277
+ : `Uninstall ${appName(mode.removal.entry)}? y/n` })) : null, mode.kind === "remove" ? (_jsx(PreviewConfirm, { lines: [`Delete recipe: ${mode.removal.file}`, "Only this recipe file or symlink will be removed; App data and Host configuration are not deleted."], question: `Remove recipe for ${appName(mode.removal.entry)}? y/n` })) : null] }));
278
+ return { rows, handleKey, startAdd, reload, clearMessage: () => { setError(undefined); setNotice(undefined); }, panel, directory: apps.directory };
216
279
  }