@iseoane/agent-depot 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +45 -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 +150 -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 +645 -0
  13. package/dist/src/package-host-plans.js +243 -0
  14. package/dist/src/package-host-state.js +473 -0
  15. package/dist/src/package-model.js +33 -0
  16. package/dist/src/package-owned-skills.js +124 -0
  17. package/dist/src/package-state.js +404 -0
  18. package/dist/src/process-runner.js +9 -1
  19. package/dist/src/profile-cli.js +15 -9
  20. package/dist/src/profile-export.js +44 -9
  21. package/dist/src/profile-import.js +80 -17
  22. package/dist/src/profile.js +78 -3
  23. package/dist/src/skill-discovery.js +7 -2
  24. package/dist/src/skill-install.js +16 -0
  25. package/dist/src/sources.js +11 -0
  26. package/dist/src/tui/app-actions.js +1 -1
  27. package/dist/src/tui/app.js +10 -11
  28. package/dist/src/tui/catalog-browse.js +46 -13
  29. package/dist/src/tui/catalog-ownership.js +12 -0
  30. package/dist/src/tui/catalog-panel.js +16 -4
  31. package/dist/src/tui/catalog-state.js +62 -6
  32. package/dist/src/tui/catalog-tree.js +58 -12
  33. package/dist/src/tui/catalog-view.js +53 -7
  34. package/dist/src/tui/environment.js +8 -1
  35. package/dist/src/tui/installations-browse.js +20 -2
  36. package/dist/src/tui/installations-rows.js +2 -1
  37. package/dist/src/tui/installations-tree.js +14 -1
  38. package/dist/src/tui/installations-view.js +8 -2
  39. package/dist/src/tui/installations.js +12 -2
  40. package/dist/src/tui/manage-actions.js +2 -0
  41. package/dist/src/tui/manage-panel.js +5 -0
  42. package/dist/src/tui/package-actions.js +165 -0
  43. package/dist/src/tui/package-rows.js +91 -0
  44. package/dist/src/tui/profile-view.js +36 -14
  45. package/dist/src/tui/render.js +1 -1
  46. package/dist/src/tui/sources-mode.js +2 -0
  47. package/dist/src/tui/sources-recipes.js +116 -53
  48. package/dist/src/tui/sources-view.js +57 -19
  49. package/dist/src/tui/tree.js +3 -4
  50. package/dist/src/tui/updates-flow.js +4 -1
  51. package/dist/src/tui/updates-panel.js +2 -2
  52. package/dist/src/tui/updates.js +72 -4
  53. package/dist/src/tui/window.js +3 -4
  54. package/dist/src/user-global-skill-inventory.js +7 -2
  55. package/package.json +2 -1
  56. package/skills/agent-depot-apprecipe/SKILL.md +29 -5
@@ -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
  }
@@ -6,6 +6,14 @@ import { rowStyle, theme } from "./theme.js";
6
6
  import { computeWindow, pageStep, useListHeight } from "./window.js";
7
7
  import { useKeys } from "./keys.js";
8
8
  import { useSourcesRecipes } from "./sources-recipes.js";
9
+ /** Footer keys for the selected row; App seeds the footer before the view reports a selection. */
10
+ export function sourceRowHints(row) {
11
+ if (row === undefined)
12
+ return "n add · q quit";
13
+ return row.kind === "app"
14
+ ? "j/k move · Enter preview/approve · n add · r refresh · d remove · q quit"
15
+ : "j/k move · space mark · a all · Enter catalog · n add · r refresh · d remove · q quit";
16
+ }
9
17
  /** The highlighted index: by stable id while that source exists, otherwise the bounded fallback index. */
10
18
  function resolveIndex(sources, selection) {
11
19
  const found = sources.findIndex((source) => source.id === selection.id);
@@ -29,7 +37,7 @@ async function refreshEach(operations, targets) {
29
37
  function errorText(error) {
30
38
  return error instanceof Error ? error.message : String(error);
31
39
  }
32
- export function SourcesView({ operations, onCapturingChange, onOpenCatalog, listHeight, environment }) {
40
+ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, listHeight, environment, onHintsChange }) {
33
41
  const height = useListHeight(listHeight, 9);
34
42
  const [state, setState] = useState({ status: "loading" });
35
43
  // The highlight follows the source id; the index is only the fallback once that source is gone.
@@ -95,12 +103,28 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
95
103
  const sources = allSources.filter((source) => source.kind === "git");
96
104
  const sourcesRef = useRef(sources);
97
105
  sourcesRef.current = sources;
98
- const selected = resolveIndex(sources, selection);
106
+ const rowsRef = useRef([]);
99
107
  const recipes = useSourcesRecipes({
100
- operations, environment, onCapturingChange, height, gitCount: sources.length,
108
+ operations, environment, onCapturingChange,
109
+ highlightedFile: () => {
110
+ const rows = rowsRef.current;
111
+ const row = rows[resolveIndex(rows, selectionRef.current)];
112
+ return row?.kind === "app" ? row.recipe.entry.file : undefined;
113
+ },
101
114
  });
115
+ const rows = [
116
+ ...sources.map((source) => ({ id: source.id, kind: "skills", source })),
117
+ ...recipes.rows.map((recipe) => ({ id: `recipe:${recipe.entry.file}`, kind: "app", recipe })),
118
+ ];
119
+ rowsRef.current = rows;
120
+ const selected = resolveIndex(rows, selection);
121
+ const selectedRow = rows[selected];
122
+ const hints = sourceRowHints(selectedRow);
123
+ useEffect(() => { onHintsChange?.(hints); }, [hints, onHintsChange]);
102
124
  const select = (index) => {
103
- const listed = sourcesRef.current;
125
+ setMessage(undefined);
126
+ recipes.clearMessage();
127
+ const listed = rowsRef.current;
104
128
  const bounded = Math.min(Math.max(index, 0), Math.max(listed.length - 1, 0));
105
129
  setSelection({ id: listed[bounded]?.id, index: bounded });
106
130
  };
@@ -164,6 +188,19 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
164
188
  const mode = modeRef.current;
165
189
  if (mode.kind === "busy")
166
190
  return;
191
+ if (mode.kind === "add-kind") {
192
+ if (key.escape || input === "n")
193
+ dispatch({ type: "done" });
194
+ else if (input === "1") {
195
+ typed.current = "";
196
+ dispatch({ type: "input" });
197
+ }
198
+ else if (input === "2") {
199
+ dispatch({ type: "done" });
200
+ recipes.startAdd();
201
+ }
202
+ return;
203
+ }
167
204
  if (mode.kind === "input") {
168
205
  if (key.escape)
169
206
  dispatch({ type: "done" });
@@ -205,14 +242,13 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
205
242
  dispatch({ type: "done" });
206
243
  return;
207
244
  }
208
- if (recipes.handleKey(input, key)) {
209
- if (key.tab)
210
- setMessage(undefined);
245
+ if (recipes.handleKey(input, key))
211
246
  return;
212
- }
247
+ const rows = rowsRef.current;
248
+ const at = resolveIndex(rows, selectionRef.current);
249
+ const row = rows[at];
213
250
  const listed = sourcesRef.current;
214
- const at = resolveIndex(listed, selectionRef.current);
215
- const highlighted = listed[at];
251
+ const highlighted = row?.kind === "skills" ? row.source : undefined;
216
252
  if (key.downArrow || input === "j") {
217
253
  select(at + 1);
218
254
  }
@@ -220,24 +256,23 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
220
256
  select(at - 1);
221
257
  }
222
258
  else if (key.pageDown) {
223
- select(at + pageStep(recipes.gitHeight));
259
+ select(at + pageStep(height));
224
260
  }
225
261
  else if (key.pageUp) {
226
- select(at - pageStep(recipes.gitHeight));
262
+ select(at - pageStep(height));
227
263
  }
228
264
  else if (key.return && highlighted) {
229
265
  onOpenCatalog?.(highlighted);
230
266
  }
231
267
  else if (input === "n") {
232
268
  setMessage(undefined);
233
- typed.current = "";
234
- dispatch({ type: "input" });
269
+ dispatch({ type: "add-kind" });
235
270
  }
236
271
  else if (input === " " && highlighted) {
237
272
  setMessage(undefined);
238
273
  toggle([highlighted.id]);
239
274
  }
240
- else if (input === "a") {
275
+ else if (input === "a" && row?.kind !== "app") {
241
276
  if (listed.length === 0)
242
277
  stop("There are no Git sources to select");
243
278
  else {
@@ -245,7 +280,7 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
245
280
  toggle(listed.map((source) => source.id));
246
281
  }
247
282
  }
248
- else if (input === "r") {
283
+ else if (input === "r" && row?.kind !== "app") {
249
284
  const targets = markedRef.current.size > 0
250
285
  ? listed.filter((source) => markedRef.current.has(source.id))
251
286
  : highlighted ? [highlighted] : [];
@@ -264,13 +299,16 @@ export function SourcesView({ operations, onCapturingChange, onOpenCatalog, list
264
299
  return _jsx(Text, { children: "Loading sources..." });
265
300
  if (state.status === "error")
266
301
  return _jsxs(Text, { color: theme.error, children: ["Error: ", state.message] });
267
- const window = computeWindow(sources.length, selected, recipes.gitHeight);
268
- return (_jsxs(Box, { flexDirection: "column", children: [builtin ? _jsxs(Text, { color: theme.muted, children: [" ", builtin.id, " \u00B7 included"] }) : null, sources.length === 0 ? _jsx(Text, { children: "No Git sources \u00B7 n to add" }) : null, sources.length > 0 ? _jsxs(Text, { color: theme.muted, children: [" ", "ID KIND URL"] }) : null, sources.slice(window.start, window.end).map((source, offset) => {
302
+ const window = computeWindow(rows.length, selected, height);
303
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: theme.muted, children: [" ", "ID KIND LOCATION / STATUS"] }), builtin ? _jsxs(Text, { color: theme.muted, children: [" ", builtin.id, " skills included"] }) : null, rows.length === 0 ? _jsx(Text, { children: "No sources or App recipes \u00B7 n to add" }) : null, rows.slice(window.start, window.end).map((row, offset) => {
269
304
  const index = window.start + offset;
270
- return (_jsxs(Text, { ...rowStyle(!recipes.focused && index === selected), children: [!recipes.focused && index === selected ? "> " : " ", marked.has(source.id) ? "[x] " : "[ ] ", source.id, " ", source.kind, " ", source.url] }, source.id));
305
+ const id = row.kind === "skills" ? row.source.id : row.recipe.entry.recipe?.name ?? row.recipe.entry.file.split(/[\\/]/u).pop();
306
+ return (_jsxs(Text, { ...rowStyle(index === selected), children: [index === selected ? "> " : " ", row.kind === "skills" ? (marked.has(row.id) ? "[x] " : "[ ] ") : " ", id, " ", row.kind, " ", row.kind === "skills" ? row.source.url : `${row.recipe.entry.file} · ${row.recipe.status}`] }, row.id));
271
307
  }), window.indicator ? _jsx(Text, { color: theme.muted, children: window.indicator }) : null, selectedCount > 0 ? _jsxs(Text, { children: [selectedCount, " selected"] }) : null, recipes.panel, _jsx(Prompt, { mode: mode }), message ? _jsx(Text, { color: message.kind === "error" ? theme.error : theme.success, children: message.text }) : null] }));
272
308
  }
273
309
  function Prompt({ mode }) {
310
+ if (mode.kind === "add-kind")
311
+ return _jsx(Text, { children: "Add: 1 Git Source (skills + Packages) \u00B7 2 App recipe (Esc cancel)" });
274
312
  if (mode.kind === "input")
275
313
  return _jsxs(Text, { children: ["URL: ", mode.value] });
276
314
  if (mode.kind === "busy")
@@ -1,7 +1,6 @@
1
- /** Initially only the first group with children is open; every other group starts collapsed. */
2
- export function defaultExpanded(roots) {
3
- const first = roots.find((root) => (root.children?.length ?? 0) > 0);
4
- return new Set(first ? [first.id] : []);
1
+ /** Every group starts collapsed until the user expands it. */
2
+ export function defaultExpanded() {
3
+ return new Set();
5
4
  }
6
5
  /** The visible nodes in display order: children appear only under expanded parents. */
7
6
  export function flattenVisible(roots, expanded) {
@@ -81,9 +81,12 @@ export function useUpdatesFlow(inputs) {
81
81
  }
82
82
  if (!mounted.current)
83
83
  return;
84
- setMessage(result);
85
84
  // The sources were fetched for the check that was just applied; only the installations changed.
85
+ // The summary lands with the refreshed list, so no check is still writing when the view reports done.
86
86
  await load(false);
87
+ if (!mounted.current)
88
+ return;
89
+ setMessage(result);
87
90
  };
88
91
  return { state, load, startPreview, apply };
89
92
  }
@@ -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))), 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))] }));
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, data.packageError ? _jsxs(Text, { color: theme.error, children: ["Error (Packages): ", data.packageError] }) : 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 }) {
@@ -35,7 +35,7 @@ export function ModePanel({ mode }) {
35
35
  case "busy":
36
36
  return mode.label === REFRESHING || mode.label === CHECKING ? null : _jsx(Text, { children: mode.label });
37
37
  case "preview":
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
38
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(PreviewLines, { lines: mode.prepared.lines }), _jsx(PreviewLines, { lines: mode.prepared.appFailures }), _jsx(PreviewLines, { lines: mode.prepared.packageFailures }), 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
39
39
  ? _jsx(Text, { children: "Nothing can be applied. Enter/Esc to go back" })
40
40
  : _jsxs(Text, { children: ["Apply ", mode.prepared.applicable.length, " ", mode.prepared.applicable.length === 1 ? "update" : "updates", "? y/n"] })] }));
41
41
  case "confirm-paths":
@@ -1,7 +1,10 @@
1
1
  import { checkAppUpdates, createAppOperations } from "../app-flow.js";
2
+ import { describeCoordinates, selectionKey } from "../package-model.js";
2
3
  import { formatVersionPolicy } from "../skill-install.js";
3
4
  import { applyConfirmedUpdates, describeUpdatePreview, loadUpdates, previewUpdateCandidates, readScopeInstallations, } from "../update-flow.js";
5
+ import { packageOperationsOf } from "./environment.js";
4
6
  import { formatVersionEvidence } from "./installations.js";
7
+ import { installedEvidenceLabel, packagePlanLines, packageStatusLabel, packageVersionLabel, recordedVersionLabel } from "./package-rows.js";
5
8
  /** Every scope the view can assess, in display order. */
6
9
  export const UPDATE_SCOPES = ["project", "user-global"];
7
10
  const APP_STATUS_LABEL = {
@@ -68,6 +71,7 @@ async function refreshSources(operations, selections) {
68
71
  */
69
72
  export async function loadUpdateRows(operations, environment, options) {
70
73
  const apps = createAppOperations({ homeDirectory: environment.homeDirectory, ...environment.appEnvironment });
74
+ const packages = packageOperationsOf(environment);
71
75
  let refresh = { attempted: false, refreshed: [], failed: [], unreadable: [] };
72
76
  if (options.refresh) {
73
77
  options.onPhase?.("refreshing");
@@ -121,7 +125,29 @@ export async function loadUpdateRows(operations, environment, options) {
121
125
  reason: check.reason ?? check.inspection.reason ?? check.inspection.status,
122
126
  });
123
127
  }
124
- return { results, rows, refresh, apps, appError, checkedAt: (options.now ?? Date.now)() };
128
+ let packageChecks = [];
129
+ let packageError;
130
+ try {
131
+ packageChecks = await packages.checkUpdates();
132
+ }
133
+ catch (error) {
134
+ packageError = errorText(error);
135
+ }
136
+ for (const check of packageChecks) {
137
+ rows.push({
138
+ kind: "package",
139
+ key: `package:${selectionKey(check.installed.selection)}`,
140
+ scope: "user-global",
141
+ check,
142
+ path: `Package: ${describeCoordinates(check.installed.selection)}`,
143
+ policy: formatVersionPolicy(check.installed.selection.version),
144
+ installed: recordedVersionLabel(check.installed),
145
+ available: check.available === undefined ? "?" : packageVersionLabel(check.available),
146
+ status: packageStatusLabel(check.status),
147
+ reason: check.reason ?? "the host reports no usable version evidence",
148
+ });
149
+ }
150
+ return { results, rows, refresh, apps, packages, appError, packageError, checkedAt: (options.now ?? Date.now)() };
125
151
  }
126
152
  /** `just now`, `42s ago`, `5 min ago`, `2 h ago`. */
127
153
  export function formatAgo(elapsedMs) {
@@ -140,10 +166,12 @@ export async function prepareUpdates(data, selected) {
140
166
  const groups = [];
141
167
  const appPlans = [];
142
168
  const appFailures = [];
169
+ const packagePlans = [];
170
+ const packageFailures = [];
143
171
  const lines = [];
144
172
  const failures = [];
145
173
  for (const { scope, loaded } of data.results) {
146
- const items = selected.filter((row) => row.kind !== "app" && row.scope === scope).map((row) => row.item);
174
+ const items = selected.filter((row) => row.kind !== "app" && row.kind !== "package" && row.scope === scope).map((row) => row.item);
147
175
  if (!loaded || items.length === 0)
148
176
  continue;
149
177
  const { context } = loaded;
@@ -164,16 +192,32 @@ export async function prepareUpdates(data, selected) {
164
192
  try {
165
193
  const plan = await data.apps.planLifecycle(row.check.entry, "update", undefined, row.check);
166
194
  appPlans.push(plan);
167
- lines.push(`Environment: ${plan.environment}`, row.path, `Version: ${plan.previousVersion} -> ${plan.latestVersion ?? "unknown"}`, ...(plan.argv ? [`argv: ${JSON.stringify(plan.argv)}`, `Executable: ${plan.executable ?? "unavailable"}`] : []), ...(plan.manual ? [`Manual: ${plan.manual}`] : []), `Working directory: ${plan.cwd}`, ...(plan.warning ? [plan.warning] : []));
195
+ lines.push(`Environment: ${plan.environment}`, row.path, `Version: ${plan.previousVersion} -> ${plan.latestVersion ?? "unknown"}`, ...(plan.argv ? [`argv: ${JSON.stringify(plan.argv)}`, `Executable: ${plan.executable ?? `${JSON.stringify(plan.argv[0])} not found in PATH`}`] : []), ...(plan.manual ? [`Manual: ${plan.manual}`] : []), `Working directory: ${plan.cwd}`, ...(plan.warning ? [plan.warning] : []));
168
196
  }
169
197
  catch (error) {
170
198
  appFailures.push(`Failed ${row.path}: preview failed: ${errorText(error)}`);
171
199
  }
172
200
  }
201
+ for (const row of selected) {
202
+ if (row.kind !== "package" || !data.packages)
203
+ continue;
204
+ try {
205
+ const plan = await data.packages.planLifecycle(row.check.installed.selection, "update");
206
+ const approval = await data.packages.approvalStatus(row.check.installed.selection, "update");
207
+ packagePlans.push({ plan, approval });
208
+ lines.push(...packagePlanLines(plan, approval));
209
+ if (approval === "needs approval")
210
+ lines.push("This apply approves the declaration above.");
211
+ }
212
+ catch (error) {
213
+ packageFailures.push(`Failed ${row.path}: preview failed: ${errorText(error)}`);
214
+ }
215
+ }
173
216
  return {
174
217
  groups, apps: data.apps, appPlans, appFailures,
218
+ packages: data.packages, packagePlans, packageFailures,
175
219
  lines,
176
- applicable: [...groups.flatMap((group) => group.applicable), ...appPlans],
220
+ applicable: [...groups.flatMap((group) => group.applicable), ...appPlans, ...packagePlans.map(({ plan }) => plan)],
177
221
  failures,
178
222
  nonCanonicalPaths: groups.flatMap((group) => group.nonCanonicalPaths.map((location) => ({ scope: group.scope, path: location }))),
179
223
  };
@@ -220,7 +264,31 @@ export async function runUpdates(prepared, confirmedPaths, confirmManual) {
220
264
  failedLines.push(`Failed ${name}: ${errorText(error)}`);
221
265
  }
222
266
  }
267
+ for (const { plan, approval } of prepared.packagePlans ?? []) {
268
+ const name = `Package: ${plan.descriptor.name} (${plan.selection.host})`;
269
+ try {
270
+ const packages = prepared.packages;
271
+ if (!packages)
272
+ throw new Error("Package operations unavailable; check again");
273
+ if (approval === "needs approval")
274
+ await packages.approve(plan.selection, plan.action);
275
+ const result = await packages.executeLifecycle(plan, true);
276
+ if (result.status === "updated") {
277
+ updated.push(`Updated ${name}: ${installedEvidenceLabel(result.previous)} -> ${installedEvidenceLabel(result.verified)}`);
278
+ }
279
+ else if (result.status === "failed" || result.status === "stale plan" || result.status === "manual required") {
280
+ failedLines.push(`Failed ${name}: ${result.reason}`);
281
+ }
282
+ else {
283
+ failedLines.push(`Failed ${name}: unexpected result ${JSON.stringify(result.status)}`);
284
+ }
285
+ }
286
+ catch (error) {
287
+ failedLines.push(`Failed ${name}: ${errorText(error)}`);
288
+ }
289
+ }
223
290
  failedLines.push(...prepared.appFailures);
291
+ failedLines.push(...prepared.packageFailures);
224
292
  const previewFailed = prepared.failures.map(({ item, message }) => `Failed ${item.selection.path}: preview failed: ${message}`);
225
293
  const failed = failedLines.length + previewFailed.length;
226
294
  return {
@@ -1,4 +1,4 @@
1
- import { useStdout } from "ink";
1
+ import { useWindowSize } from "ink";
2
2
  /** Rows assumed when the terminal size is unknown (for example under test renderers). */
3
3
  export const FALLBACK_TERMINAL_ROWS = 24;
4
4
  /** Lines the shell itself uses: title, tab bar, separator and footer hint. */
@@ -23,9 +23,8 @@ export function pageStep(height) {
23
23
  * view's own `reserved` lines. `override` fixes the height for tests.
24
24
  */
25
25
  export function useListHeight(override, reserved) {
26
- const { stdout } = useStdout();
26
+ const { rows } = useWindowSize();
27
27
  if (override !== undefined)
28
28
  return Math.max(override, 1);
29
- const rows = stdout.rows ?? FALLBACK_TERMINAL_ROWS;
30
- return Math.max(rows - APP_CHROME_ROWS - reserved, MINIMUM_ROWS);
29
+ return Math.max((rows || FALLBACK_TERMINAL_ROWS) - APP_CHROME_ROWS - reserved, MINIMUM_ROWS);
31
30
  }