@iseoane/agent-depot 0.5.2 → 0.6.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 (39) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.es.md +51 -29
  3. package/README.md +44 -25
  4. package/dist/src/app-flow.js +56 -24
  5. package/dist/src/profile-export.js +72 -9
  6. package/dist/src/profile-import.js +3 -2
  7. package/dist/src/profile.js +71 -7
  8. package/dist/src/tui/app-actions.js +82 -33
  9. package/dist/src/tui/app.js +19 -14
  10. package/dist/src/tui/catalog-browse.js +6 -1
  11. package/dist/src/tui/catalog-panel.js +11 -11
  12. package/dist/src/tui/catalog-state.js +28 -0
  13. package/dist/src/tui/catalog-tree.js +22 -5
  14. package/dist/src/tui/catalog-view.js +59 -16
  15. package/dist/src/tui/header-layout.js +33 -0
  16. package/dist/src/tui/installations-panel.js +3 -3
  17. package/dist/src/tui/installations-rows.js +20 -5
  18. package/dist/src/tui/installations-tree.js +4 -7
  19. package/dist/src/tui/installations-view.js +15 -6
  20. package/dist/src/tui/installations.js +9 -7
  21. package/dist/src/tui/manage-panel.js +7 -6
  22. package/dist/src/tui/mode-keys.js +9 -0
  23. package/dist/src/tui/package-rows.js +17 -5
  24. package/dist/src/tui/panel-parts.js +38 -6
  25. package/dist/src/tui/preview-window.js +48 -0
  26. package/dist/src/tui/profile-view.js +261 -42
  27. package/dist/src/tui/sources-mode.js +3 -1
  28. package/dist/src/tui/sources-recipes.js +50 -19
  29. package/dist/src/tui/sources-view.js +50 -14
  30. package/dist/src/tui/theme.js +14 -7
  31. package/dist/src/tui/updates-flow.js +2 -2
  32. package/dist/src/tui/updates-keys.js +34 -5
  33. package/dist/src/tui/updates-model.js +2 -0
  34. package/dist/src/tui/updates-panel.js +40 -15
  35. package/dist/src/tui/updates-preview.js +65 -0
  36. package/dist/src/tui/updates-view.js +17 -2
  37. package/dist/src/tui/updates.js +14 -5
  38. package/dist/src/tui/window.js +6 -4
  39. package/package.json +4 -3
@@ -3,7 +3,9 @@ import { parseAppRecipe } from "./app-recipes.js";
3
3
  import { canonicalizeGitSourceUrl } from "./git-source.js";
4
4
  import { selectionKey } from "./package-model.js";
5
5
  import { containsCredentialArgument, parseProjectManifest, ProjectManifestError } from "./project-manifest.js";
6
- export const PROFILE_FORMAT = "agent-depot-profile/v1";
6
+ export const PROFILE_FORMAT_V1 = "agent-depot-profile/v1";
7
+ export const PROFILE_FORMAT_V2 = "agent-depot-profile/v2";
8
+ export const PROFILE_FORMAT = PROFILE_FORMAT_V1;
7
9
  function object(value, keys, field) {
8
10
  if (!value || typeof value !== "object" || Array.isArray(value))
9
11
  throw new Error(`${field}: expected an object`);
@@ -127,9 +129,12 @@ function parseProfilePackage(value, field) {
127
129
  }
128
130
  /** Portable profile parsing shares the manifest and recipe safety contracts. */
129
131
  export function parseProfile(value) {
130
- const root = object(value, ["format", "agentDepotVersion", "sources", "skills", "apps", "packages"], "profile");
131
- if (root.format !== PROFILE_FORMAT)
132
+ if (!value || typeof value !== "object" || Array.isArray(value))
133
+ throw new Error("profile: expected an object");
134
+ const format = value.format;
135
+ if (format !== PROFILE_FORMAT_V1 && format !== PROFILE_FORMAT_V2)
132
136
  throw new Error("profile.format: unsupported profile version");
137
+ const root = object(value, ["format", "agentDepotVersion", "sources", "skills", "apps", "packages", "replicas"], "profile");
133
138
  if (typeof root.agentDepotVersion !== "string" || !/^\d+\.\d+\.\d+(?:[-+][\w.+-]+)?$/u.test(root.agentDepotVersion)) {
134
139
  throw new Error("profile.agentDepotVersion: expected a package version");
135
140
  }
@@ -179,8 +184,25 @@ export function parseProfile(value) {
179
184
  assertPortableText(selection, field);
180
185
  return selection;
181
186
  });
187
+ const replicas = root.replicas === undefined
188
+ ? []
189
+ : list(root.replicas, "replicas").map((value, index) => parseProfileReplica(value, `replicas[${index}]`));
190
+ if (format === PROFILE_FORMAT_V2 && root.replicas === undefined)
191
+ throw new Error("replicas: required for profile v2");
192
+ if (format === PROFILE_FORMAT_V1 && replicas.length > 0)
193
+ throw new Error("replicas: non-empty replica intent requires profile v2");
182
194
  rejectDuplicates(sources, source => source.url, "sources");
183
195
  rejectDuplicates(packages, selectionKey, "packages");
196
+ rejectDuplicates(replicas, replicaIdentity, "replicas");
197
+ replicas.forEach((replica, index) => {
198
+ const exists = replica.kind === "skill"
199
+ ? parsedSkills.some(skill => skillIdentity(skill) === JSON.stringify([replica.source, replica.path]))
200
+ : replica.kind === "app"
201
+ ? apps.some(app => app.name === replica.name && app.platform === replica.platform)
202
+ : packages.some(selection => selectionKey(selection) === selectionKey(replica.selection));
203
+ if (!exists)
204
+ throw new Error(`replicas[${index}]: does not reference a profile resource`);
205
+ });
184
206
  apps.forEach((app, index) => {
185
207
  if (apps.slice(0, index).some(previous => previous.name === app.name &&
186
208
  (previous.platform === undefined || app.platform === undefined || previous.platform === app.platform))) {
@@ -192,14 +214,56 @@ export function parseProfile(value) {
192
214
  const sortedSkills = [...parsedSkills].sort((a, b) => compare(JSON.stringify([a.source, a.path]), JSON.stringify([b.source, b.path])));
193
215
  apps.sort((a, b) => compare(a.name, b.name) || compare(a.platform ?? "", b.platform ?? ""));
194
216
  packages.sort((a, b) => compare(selectionKey(a), selectionKey(b)));
195
- return { format: PROFILE_FORMAT, agentDepotVersion: root.agentDepotVersion, sources, skills: sortedSkills, apps, packages };
217
+ replicas.sort((a, b) => compare(replicaIdentity(a), replicaIdentity(b)));
218
+ return { format, agentDepotVersion: root.agentDepotVersion, sources, skills: sortedSkills, apps, packages, replicas };
196
219
  }
197
220
  /**
198
221
  * An empty Package block is omitted, so an export with no Packages stays readable
199
222
  * by an Agent Depot version that predates the block.
200
223
  */
224
+ function skillIdentity(skill) {
225
+ return JSON.stringify([skill.source, skill.path]);
226
+ }
227
+ function replicaIdentity(replica) {
228
+ switch (replica.kind) {
229
+ case "skill": return JSON.stringify([replica.kind, replica.source, replica.path]);
230
+ case "app": return JSON.stringify([replica.kind, replica.name, replica.platform ?? null]);
231
+ case "package": return JSON.stringify([replica.kind, selectionKey(replica.selection)]);
232
+ }
233
+ }
234
+ function parseProfileReplica(value, field) {
235
+ if (!value || typeof value !== "object" || Array.isArray(value))
236
+ throw new Error(`${field}: expected an object`);
237
+ const kind = value.kind;
238
+ if (kind === "skill") {
239
+ const record = object(value, ["kind", "source", "path"], field);
240
+ const [skill] = parseProjectManifest({ version: 1, skills: [
241
+ { source: record.source, path: record.path, version: { policy: "latest" }, hosts: ["pi"] },
242
+ ] }, "profile").skills;
243
+ return { kind, source: skill.source, path: skill.path };
244
+ }
245
+ if (kind === "app") {
246
+ const record = object(value, ["kind", "name", "platform"], field);
247
+ if (typeof record.name !== "string" || record.name.length === 0)
248
+ throw new Error(`${field}.name: expected a non-empty string`);
249
+ const platform = record.platform;
250
+ if (platform !== undefined && platform !== "linux" && platform !== "windows" && platform !== "darwin") {
251
+ throw new Error(`${field}.platform: unsupported platform`);
252
+ }
253
+ return { kind, name: record.name, ...(platform === undefined ? {} : { platform }) };
254
+ }
255
+ if (kind === "package") {
256
+ const record = object(value, ["kind", "selection"], field);
257
+ return { kind, selection: parseProfilePackage(record.selection, `${field}.selection`) };
258
+ }
259
+ throw new Error(`${field}.kind: expected "skill", "app" or "package"`);
260
+ }
201
261
  export function serializeProfile(profile) {
202
- const parsed = parseProfile(profile);
203
- const { packages, ...rest } = parsed;
204
- return `${JSON.stringify(packages.length === 0 ? rest : parsed, null, 2)}\n`;
262
+ const format = profile.replicas.length ? PROFILE_FORMAT_V2 : PROFILE_FORMAT_V1;
263
+ const parsed = parseProfile({ ...profile, format });
264
+ const { packages, replicas, ...rest } = parsed;
265
+ const output = { ...rest, format,
266
+ ...(packages.length ? { packages } : {}),
267
+ ...(replicas.length ? { replicas } : {}) };
268
+ return `${JSON.stringify(output, null, 2)}\n`;
205
269
  }
@@ -1,27 +1,67 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text, useWindowSize } from "ink";
3
3
  import { PROJECT_HOSTS } from "../project-manifest.js";
4
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"];
5
+ import { hostChecklistKey, movePreviewCursor } from "./mode-keys.js";
6
+ import { BorderPanel, HostChecklist, PreviewConfirm } from "./panel-parts.js";
7
+ import { previewViewport } from "./preview-window.js";
8
+ export const APP_KINDS = ["app-busy", "app-approve", "app-hosts", "app-confirm", "app-manual"];
8
9
  /** Narrows the view's mode without asserting a partial mode is an App mode. */
9
10
  export function isAppMode(mode) {
10
11
  return APP_KINDS.some(kind => kind === mode.kind);
11
12
  }
12
13
  /** Recipe name, falling back to the file identity for invalid recipes. */
13
14
  export const appName = (entry) => entry.recipe?.name ?? entry.file;
15
+ function appPreviewChrome(mode) {
16
+ const title = mode.kind === "app-approve" ? "Recipe approval · does not install"
17
+ : mode.kind === "app-manual" ? "App manual step" : `App ${mode.plan.action} confirmation`;
18
+ const question = mode.kind === "app-approve" ? `Approve ${appName(mode.entry)}? y/n · recipe approval does not install the App.`
19
+ : mode.kind === "app-manual" ? "Manual step done, check now? y/n"
20
+ : `${mode.plan.action} ${appName(mode.plan.entry)}? y/n`;
21
+ return { title, questions: [question, "Scroll to end before y; n cancels."], footerRows: 2 };
22
+ }
14
23
  export function appStepLines(step) {
24
+ const argv = step.argv;
15
25
  return [
16
- ...(step.argv ? [`argv: ${JSON.stringify(step.argv)}`, `Executable: ${step.executable ?? `${JSON.stringify(step.argv[0])} not found in PATH`}`] : []),
26
+ ...(argv ? [
27
+ "argv: [",
28
+ ...argv.map((argument, index) => ` ${JSON.stringify(argument)}${index < argv.length - 1 ? "," : ""}`),
29
+ "]",
30
+ `Executable: ${step.executable ?? `${JSON.stringify(argv[0])} not found in PATH`}`,
31
+ ] : []),
17
32
  ...(step.manual ? [`Manual: ${step.manual}`] : []),
33
+ ...(step.declaration ? [step.declaration] : []),
34
+ ...(step.pattern ? [`Version pattern: ${step.pattern}`] : []),
35
+ ...(step.environment ? [`Environment: ${step.environment}`] : []),
18
36
  `Working directory: ${step.cwd}`,
19
37
  ...(step.warning ? [step.warning] : []),
20
38
  ];
21
39
  }
40
+ export function appRecipePreviewLines(entry, status, steps) {
41
+ const recipe = entry.recipe;
42
+ return [
43
+ `File: ${entry.file}`,
44
+ `Name: ${recipe?.name ?? entry.file}`,
45
+ `Platform: ${recipe?.platform ?? "all"}`,
46
+ `Status: ${status}`,
47
+ ...(recipe?.homepage ? [`Homepage: ${recipe.homepage}`] : []),
48
+ ...(recipe?.skills ? [`Owned Skills: ${JSON.stringify(recipe.skills)}`] : []),
49
+ ...steps.flatMap(step => [step.step, ...appStepLines(step)]),
50
+ ];
51
+ }
52
+ function appPreviewLines(mode) {
53
+ if (mode.kind === "app-approve")
54
+ return mode.lines;
55
+ return [
56
+ `App: ${appName(mode.plan.entry)} ${mode.plan.action}${mode.plan.host ? ` (${mode.plan.host})` : ""}`,
57
+ ...(mode.plan.previousVersion ? [`Previous version: ${mode.plan.previousVersion}`] : []),
58
+ ...(mode.plan.latestVersion ? [`Latest version: ${mode.plan.latestVersion}`] : []),
59
+ ...appStepLines(mode.plan),
60
+ ];
61
+ }
22
62
  async function prepareNext(context, requests, results = [], failures = 0) {
23
63
  const { manage, apps } = context;
24
- manage.setMode({ kind: "busy", label: "Preparing App step..." });
64
+ manage.setMode({ kind: "app-busy", label: "Preparing App step..." });
25
65
  // Independent planning failures never stop the remaining marked Apps.
26
66
  const [request, ...remaining] = requests;
27
67
  if (!request) {
@@ -33,7 +73,7 @@ async function prepareNext(context, requests, results = [], failures = 0) {
33
73
  if (manage.isMounted()) {
34
74
  manage.setMode({
35
75
  kind: !plan.argv && plan.manual ? "app-manual" : "app-confirm",
36
- plan, remaining, results, failures,
76
+ plan, remaining, results, failures, cursor: 0,
37
77
  });
38
78
  }
39
79
  }
@@ -52,10 +92,10 @@ export async function startAppAction(context, entries, input) {
52
92
  const entry = entries[0];
53
93
  if (!entry)
54
94
  throw new Error("No App selected");
55
- manage.setMode({ kind: "busy", label: "Preparing recipe approval..." });
56
- const steps = await apps.preview(entry);
95
+ manage.setMode({ kind: "app-busy", label: "Preparing recipe approval..." });
96
+ const [steps, approval] = await Promise.all([apps.preview(entry), apps.approvalStatus(entry)]);
57
97
  if (manage.isMounted()) {
58
- manage.setMode({ kind: "app-approve", entry, lines: steps.flatMap(step => [step.step, ...appStepLines(step)]) });
98
+ manage.setMode({ kind: "app-approve", entry, lines: appRecipePreviewLines(entry, approval.status, steps), cursor: 0 });
59
99
  }
60
100
  }
61
101
  else if (input === "h") {
@@ -78,7 +118,7 @@ export async function startAppAction(context, entries, input) {
78
118
  }
79
119
  async function execute(context, mode) {
80
120
  const { manage, apps } = context;
81
- manage.setMode({ kind: "busy", label: "Running App step..." });
121
+ manage.setMode({ kind: "app-busy", label: "Running App step..." });
82
122
  let manualRequired = false;
83
123
  const [outcome] = await runBatch([mode.plan], async (plan) => {
84
124
  const result = mode.kind === "app-manual"
@@ -102,6 +142,8 @@ async function execute(context, mode) {
102
142
  /** Applies checklist and y/n keys, continuing independent batch steps after cancellation or failure. */
103
143
  export function handleAppKey(context, mode, input, key) {
104
144
  const { manage, apps } = context;
145
+ if (mode.kind === "app-busy")
146
+ return;
105
147
  const cancel = () => {
106
148
  manage.browse();
107
149
  manage.setMessage({ kind: "ok", text: "App action cancelled" });
@@ -123,35 +165,42 @@ export function handleAppKey(context, mode, input, key) {
123
165
  void prepareNext(context, requests);
124
166
  }
125
167
  }
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
- }, () => {
168
+ else {
169
+ const lines = appPreviewLines(mode);
170
+ const viewport = previewViewport(lines, mode.cursor, context.terminalRows, context.terminalColumns, appPreviewChrome(mode));
171
+ const cursor = movePreviewCursor(mode.cursor, viewport.total, input, key, viewport.height);
172
+ if (cursor !== undefined) {
173
+ manage.setMode({ ...mode, cursor });
174
+ return;
175
+ }
176
+ if (input === "n" || key.escape) {
135
177
  if (mode.kind === "app-confirm" || mode.kind === "app-manual") {
136
178
  void prepareNext(context, mode.remaining, [...mode.results, `${appName(mode.plan.entry)}: cancelled`], mode.failures);
137
179
  }
138
180
  else
139
181
  cancel();
140
- });
182
+ return;
183
+ }
184
+ if (input !== "y" || !viewport.reachedEnd)
185
+ return;
186
+ if (mode.kind !== "app-approve") {
187
+ void execute(context, mode);
188
+ return;
189
+ }
190
+ manage.setMode({ kind: "app-busy", label: "Approving recipe..." });
191
+ void apps.approve(mode.entry).then(() => manage.finish({ kind: "ok", text: `Approved ${appName(mode.entry)}` }), error => manage.finish({ kind: "error", text: errorText(error) }));
192
+ }
141
193
  }
142
194
  /** Renders App prompts using the existing checklist and preview-confirm panels. */
143
195
  export function AppPanel({ mode }) {
196
+ const { rows, columns } = useWindowSize();
197
+ if (mode.kind === "app-busy")
198
+ return _jsx(BorderPanel, { title: "App lifecycle", children: _jsx(Text, { children: mode.label }) });
144
199
  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` });
200
+ return (_jsxs(BorderPanel, { title: "App Host steps", children: [_jsx(Text, { children: "Checked = setup. Unchecked = teardown. Wiring is not tracked." }), _jsxs(Text, { children: ["Space/1-", mode.choices.length, " toggle \u00B7 j/k move \u00B7 Enter continue \u00B7 Esc cancel"] }), _jsx(HostChecklist, { choices: mode.choices, cursor: mode.cursor, selected: mode.selected })] }));
149
201
  }
150
- return (_jsx(PreviewConfirm, { lines: [
151
- `Environment: ${mode.plan.environment}`,
152
- `App: ${appName(mode.plan.entry)} ${mode.plan.action}${mode.plan.host ? ` (${mode.plan.host})` : ""}`,
153
- ...appStepLines(mode.plan),
154
- ], question: mode.kind === "app-manual"
155
- ? "Manual step done, check now? y/n"
156
- : `${mode.plan.action} ${appName(mode.plan.entry)}? y/n` }));
202
+ const lines = appPreviewLines(mode);
203
+ const chrome = appPreviewChrome(mode);
204
+ const viewport = previewViewport(lines, mode.cursor, rows || 24, columns || 80, chrome);
205
+ return (_jsx(PreviewConfirm, { title: chrome.title, lines: lines, cursor: mode.cursor, question: viewport.reachedEnd ? chrome.questions[0] : chrome.questions[1], chrome: chrome }));
157
206
  }
@@ -1,21 +1,15 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useApp, useWindowSize } from "ink";
3
3
  import { useCallback, useRef, useState } from "react";
4
4
  import { AGENT_DEPOT_PACKAGE_VERSION } from "../project-manifest.js";
5
5
  import { CatalogView } from "./catalog-view.js";
6
6
  import { InstallationsView } from "./installations-view.js";
7
+ import { headerTabRows, TABS } from "./header-layout.js";
7
8
  import { useKeys } from "./keys.js";
8
9
  import { ProfileView } from "./profile-view.js";
9
10
  import { SourcesView, sourceRowHints } from "./sources-view.js";
10
- import { separatorLine, theme } from "./theme.js";
11
+ import { theme } from "./theme.js";
11
12
  import { UpdatesView } from "./updates-view.js";
12
- const TABS = [
13
- ["sources", "1 Sources"],
14
- ["catalog", "2 Catalog"],
15
- ["installations", "3 Installations"],
16
- ["updates", "4 Updates"],
17
- ["profile", "5 Import/Export"],
18
- ];
19
13
  /** Sources derives its own hints from the selected row, so it is excluded here. */
20
14
  function hintsOf(view, catalogFocusedOnSource) {
21
15
  switch (view) {
@@ -26,9 +20,13 @@ function hintsOf(view, catalogFocusedOnSource) {
26
20
  case "profile":
27
21
  return "e export · i import · q quit";
28
22
  case "updates":
29
- return "j/k move · space mark · a all · Enter preview · r check again · q quit";
23
+ return "j/k move · space mark · a all · Enter preview · u update · r check again · q quit";
30
24
  }
31
25
  }
26
+ export function Header({ view, columns }) {
27
+ const innerWidth = Math.max(columns - 4, 1);
28
+ return (_jsxs(Box, { borderStyle: "round", borderColor: theme.accent, paddingX: 1, flexDirection: "column", width: columns, children: [_jsxs(Box, { width: innerWidth, justifyContent: "space-between", children: [_jsx(Text, { color: theme.accent, bold: true, children: "AGENT DEPOT" }), AGENT_DEPOT_PACKAGE_VERSION === undefined ? null : _jsxs(Text, { color: theme.value, children: ["v", AGENT_DEPOT_PACKAGE_VERSION] })] }), _jsx(Box, { flexDirection: "column", children: headerTabRows(innerWidth).map((row, rowIndex) => (_jsx(Text, { wrap: "truncate-end", children: row.map(([name, label], tabIndex) => (_jsxs(Text, { color: view === name ? theme.accent : theme.inactive, children: [tabIndex === 0 ? "" : " ", view === name ? "▶ " : "", label] }, name))) }, rowIndex))) })] }));
29
+ }
32
30
  export function App({ operations, environment, onExit }) {
33
31
  const { exit } = useApp();
34
32
  const { columns, rows } = useWindowSize();
@@ -43,10 +41,15 @@ export function App({ operations, environment, onExit }) {
43
41
  const [catalogSourceId, setCatalogSourceId] = useState();
44
42
  const [sourcesHints, setSourcesHints] = useState(sourceRowHints(undefined));
45
43
  const showView = (next) => setView(next);
46
- useKeys((input) => {
44
+ useKeys((input, key) => {
47
45
  if (capturingRef.current)
48
46
  return;
49
- if (input === "q") {
47
+ if (key.tab) {
48
+ const current = TABS.findIndex(([name]) => name === view);
49
+ const direction = key.shift ? -1 : 1;
50
+ showView(TABS[(current + direction + TABS.length) % TABS.length][0]);
51
+ }
52
+ else if (input === "q") {
50
53
  onExit?.();
51
54
  exit();
52
55
  }
@@ -61,8 +64,10 @@ export function App({ operations, environment, onExit }) {
61
64
  else if (input === "5")
62
65
  showView("profile");
63
66
  });
64
- return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsxs(Text, { bold: true, color: theme.accent, children: ["Agent Depot", AGENT_DEPOT_PACKAGE_VERSION === undefined ? "" : ` v${AGENT_DEPOT_PACKAGE_VERSION}`] }), _jsx(Text, { children: TABS.map(([name, label], position) => (_jsxs(Text, { color: view === name ? theme.accent : theme.inactive, bold: view === name, children: [position > 0 ? " " : "", view === name ? `[${label}]` : ` ${label} `] }, name))) }), _jsx(Text, { color: theme.muted, children: separatorLine(columns) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: view === "sources" ? (_jsx(SourcesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange, onHintsChange: setSourcesHints, onOpenCatalog: (source) => {
67
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Header, { view: view, columns: columns }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: view === "sources" ? (_jsx(SourcesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange, onHintsChange: setSourcesHints, onOpenCatalog: (source) => {
65
68
  setCatalogSourceId(source.id);
66
69
  setView("catalog");
67
- } })) : view === "installations" ? (_jsx(InstallationsView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : view === "profile" ? (_jsx(ProfileView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : view === "updates" ? (_jsx(UpdatesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : (_jsx(CatalogView, { operations: operations, sourceId: catalogSourceId, environment: environment, onCapturingChange: onCapturingChange })) }), _jsx(Text, { color: theme.muted, children: capturing ? "Enter submit · Esc cancel · y/n confirm · space toggle" : view === "sources" ? sourcesHints : hintsOf(view, catalogSourceId !== undefined) })] }));
70
+ } })) : view === "installations" ? (_jsx(InstallationsView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : view === "profile" ? (_jsx(ProfileView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : view === "updates" ? (_jsx(UpdatesView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange })) : (_jsx(CatalogView, { operations: operations, sourceId: catalogSourceId, environment: environment, onCapturingChange: onCapturingChange })) }), _jsx(Text, { color: theme.muted, children: capturing
71
+ ? columns < 64 ? "Enter submit · Esc cancel" : "Enter submit · Esc cancel · y/n confirm · space toggle"
72
+ : view === "sources" ? sourcesHints : hintsOf(view, catalogSourceId !== undefined) })] }));
68
73
  }
@@ -47,6 +47,11 @@ function markRepository(context, row, visible) {
47
47
  /** `i`: installs the highlighted bundle, or the marked Skills that are listed, or the highlighted one. */
48
48
  function chooseHosts(context, row) {
49
49
  const data = row.node.data;
50
+ if (data.kind === "app") {
51
+ context.setMessage(undefined);
52
+ context.startApp(data.row.entry);
53
+ return;
54
+ }
50
55
  // A bundle row is not markable, so it acts on itself and never picks up a Skill mark.
51
56
  if (data.kind === "bundle") {
52
57
  context.setMessage(undefined);
@@ -87,6 +92,6 @@ export function handleCatalogBrowseKey(context, input, key) {
87
92
  markRow(context, row);
88
93
  else if (input === "a")
89
94
  markRepository(context, row, snapshot.visible);
90
- else if (input === "i" && context.ready && row)
95
+ else if (input === "i" && row && (context.ready || row.node.data.kind === "app"))
91
96
  chooseHosts(context, row);
92
97
  }
@@ -6,12 +6,12 @@ import { skillKey } from "./catalog-tree.js";
6
6
  import { hasUnmanagedCopy } from "./catalog-installs.js";
7
7
  import { describeRow, truncateDescription, UNMANAGED_MARKER } from "./catalog-tree.js";
8
8
  import { isPackageMode, PackagePanel } from "./package-actions.js";
9
- import { HostChecklist, PreviewLines } from "./panel-parts.js";
9
+ import { BorderPanel, DecisionPrompt, HostChecklist, PreviewLines } from "./panel-parts.js";
10
10
  import { rowStyle } from "./theme.js";
11
11
  import { theme } from "./theme.js";
12
12
  /** Scope and counts, the filter line, and why the list may be empty. */
13
- export function CatalogHeader({ scope, listed, installable, total, filter }) {
14
- return (_jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.muted, children: ["Scope: ", scope, " ", listed, " of ", installable] }), filter.editing || filter.query !== "" ? (_jsxs(Text, { children: ["Filter: ", filter.query, filter.editing ? "_" : ""] })) : null, total === 0 ? _jsx(Text, { children: "No skills" }) : null, total > 0 && installable === 0 ? _jsx(Text, { children: "All skills are installed" }) : null, installable > 0 && listed === 0 ? _jsx(Text, { children: "No matching skills" }) : null] }));
13
+ export function CatalogHeader({ scope, listed, installable, total, appCount, filter }) {
14
+ return (_jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.muted, children: ["Scope: ", scope, " ", listed, " of ", installable, " Skills \u00B7 ", appCount, " Apps"] }), filter.editing || filter.query !== "" ? (_jsxs(Text, { children: ["Filter: ", filter.query, filter.editing ? "_" : ""] })) : null, total === 0 ? _jsx(Text, { children: "No skills" }) : null, total > 0 && installable === 0 ? _jsx(Text, { children: "All skills are installed" }) : null, installable > 0 && listed === 0 ? _jsx(Text, { children: "No matching skills" }) : null] }));
15
15
  }
16
16
  /** One line of the Catalog tree: cursor, indent, mark box, label and the ownership markers. */
17
17
  export function CatalogRowLine({ row, selected, marked, installed }) {
@@ -20,7 +20,7 @@ export function CatalogRowLine({ row, selected, marked, installed }) {
20
20
  const ownedBy = data.kind === "skill" ? data.ownedBy : undefined;
21
21
  return (_jsxs(Text, { ...rowStyle(selected), color: isGroup ? theme.group : undefined, wrap: "truncate-end", children: [selected ? "> " : " ", " ".repeat(row.depth), data.kind === "skill" ? marked ? "[x] " : "[ ] " : "", data.kind === "skill" && ownedBy !== undefined
22
22
  ? _jsxs(_Fragment, { children: [data.skill.name, _jsxs(Text, { color: theme.marker, children: [" ", "[", packageOwnerBadge(ownedBy), "]"] }), " ", truncateDescription(data.skill.description)] })
23
- : describeRow(row), data.kind === "skill" && hasUnmanagedCopy(data.skill, installed)
23
+ : data.kind === "app" ? `App ${data.row.entry.recipe?.name ?? data.row.entry.file}` : describeRow(row), data.kind === "app" ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.legend, children: " " }), _jsx(Text, { color: data.row.inspection.status === "not installed" ? theme.warning : theme.value, children: data.row.inspection.status })] })) : null, data.kind === "skill" && hasUnmanagedCopy(data.skill, installed)
24
24
  ? _jsxs(Text, { color: theme.marker, children: [" ", UNMANAGED_MARKER] })
25
25
  : null] }));
26
26
  }
@@ -37,23 +37,23 @@ export function ActionPanel({ mode }) {
37
37
  case "browse":
38
38
  return null;
39
39
  case "host":
40
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Install ", describeSkills(mode.skills), ". Host (space/1-4 toggle, j/k move, Enter continue, Esc cancel):"] }), _jsx(HostChecklist, { choices: HOST_CHOICES, cursor: mode.cursor, selected: mode.selected })] }));
40
+ return (_jsxs(BorderPanel, { title: `Skill selection · ${mode.skills.length} selected`, children: [_jsxs(Text, { children: ["Install ", describeSkills(mode.skills), ". Host (space/1-4 toggle \u00B7 j/k move \u00B7 Enter continue \u00B7 Esc cancel)"] }), _jsx(HostChecklist, { choices: HOST_CHOICES, cursor: mode.cursor, selected: mode.selected })] }));
41
41
  case "scope":
42
- return _jsxs(Text, { children: ["Scope: ", SCOPE_CHOICES.map((scope, i) => `${i + 1} ${scope}`).join(" "), " (Esc cancel)"] });
42
+ return _jsx(BorderPanel, { title: "Skill installation scope", children: _jsxs(Text, { children: [SCOPE_CHOICES.map((scope, i) => `${i + 1} ${scope}`).join(" "), " (Esc cancel)"] }) });
43
43
  case "version":
44
- return _jsx(Text, { children: "Version: 1 latest 2 fixed (Esc cancel)" });
44
+ return _jsx(BorderPanel, { title: "Skill version policy", children: _jsx(Text, { children: "1 latest 2 fixed (Esc cancel)" }) });
45
45
  case "fixed":
46
- return _jsxs(Text, { children: ["Fixed version: ", mode.value, "_ (Enter confirm, Esc cancel)"] });
46
+ return _jsx(BorderPanel, { title: "Fixed Skill version", children: _jsxs(Text, { children: [mode.value, "_ (Enter confirm, Esc cancel)"] }) });
47
47
  case "confirm-install": {
48
48
  const ready = mode.items.filter((item) => item.prepared !== undefined);
49
49
  const single = mode.items.length === 1;
50
- return (_jsxs(Box, { flexDirection: "column", children: [mode.items.map(({ skill, prepared, error }) => prepared === undefined ? (_jsxs(Text, { color: theme.error, children: ["Cannot install ", skill.name, ": ", error] }, skillKey(skill))) : (_jsxs(Box, { flexDirection: "column", children: [single ? null : _jsx(Text, { color: theme.group, children: skill.name }), _jsx(PreviewLines, { lines: prepared.preview })] }, skillKey(skill)))), ready.some((item) => item.prepared?.runsExternalCommand) ? _jsx(Text, { color: theme.warning, children: "This install runs an external command (see above)." }) : null, _jsxs(Text, { children: ["Install ", describeSkills(ready.map((item) => item.skill)), "? y/n"] })] }));
50
+ return (_jsxs(BorderPanel, { title: `Skill install preview · ${ready.length} ready`, children: [mode.items.map(({ skill, prepared, error }) => prepared === undefined ? (_jsxs(Text, { color: theme.error, children: ["Cannot install ", skill.name, ": ", error] }, skillKey(skill))) : (_jsxs(Box, { flexDirection: "column", children: [single ? null : _jsx(Text, { color: theme.group, children: skill.name }), _jsx(PreviewLines, { lines: prepared.preview })] }, skillKey(skill)))), ready.some((item) => item.prepared?.runsExternalCommand) ? _jsx(Text, { color: theme.warning, children: "This install runs an external command (see above)." }) : null, _jsxs(DecisionPrompt, { children: ["Install ", describeSkills(ready.map((item) => item.skill)), "?"] })] }));
51
51
  }
52
52
  case "confirm-exposure": {
53
53
  const names = mode.items.filter((item) => item.prepared?.additionalHostExposure).map((item) => item.skill.name);
54
- return (_jsxs(Text, { color: theme.warning, children: [names.length === 1 ? `An identical ${names[0]} already exists` : `Identical copies of ${names.join(", ")} already exist`, "; adding the missing Host location needs separate confirmation (CLI: --confirm-additional-host). Confirm? y/n"] }));
54
+ return (_jsx(BorderPanel, { title: `Skill Host exposure · ${names.length} confirmation`, children: _jsxs(Text, { color: theme.warning, children: [names.length === 1 ? `An identical ${names[0]} already exists` : `Identical copies of ${names.join(", ")} already exist`, "; adding the missing Host location needs separate confirmation. ", _jsx(DecisionPrompt, { children: "Confirm?" })] }) }));
55
55
  }
56
56
  case "busy":
57
- return _jsx(Text, { children: mode.label });
57
+ return _jsx(BorderPanel, { title: "Skill install", children: _jsx(Text, { children: mode.label }) });
58
58
  }
59
59
  }
@@ -87,6 +87,34 @@ export function useCatalogSkills(operations, sourceId, all, env, reloadKey = 0)
87
87
  }, [operations, sourceId, all, env, reloadKey]);
88
88
  return state;
89
89
  }
90
+ export function useCatalogApps(apps) {
91
+ const [state, setState] = useState({ status: "loading" });
92
+ const generation = useRef(0);
93
+ const reload = useCallback(async () => {
94
+ const current = ++generation.current;
95
+ setState({ status: "loading" });
96
+ try {
97
+ const rows = await Promise.all((await apps.load()).filter(entry => entry.applicable).map(async (entry) => ({
98
+ entry,
99
+ inspection: await apps.inspect(entry),
100
+ })));
101
+ if (generation.current === current)
102
+ setState({
103
+ status: "ready",
104
+ rows: rows.filter(row => row.inspection.status === "not installed"),
105
+ });
106
+ }
107
+ catch (error) {
108
+ if (generation.current === current)
109
+ setState({ status: "ready", rows: [], error: error instanceof Error ? error.message : String(error) });
110
+ }
111
+ }, [apps]);
112
+ useEffect(() => {
113
+ void reload();
114
+ return () => { generation.current += 1; };
115
+ }, [reload]);
116
+ return { state, reload };
117
+ }
90
118
  /** The Skills already installed, loaded on mount and on demand. */
91
119
  export function useInstalledSkills(operations, env, mounted) {
92
120
  const [installed, setInstalled] = useState(NO_INSTALLED);
@@ -23,7 +23,7 @@ export function sourceLabel(source) {
23
23
  return url.hostname === "github.com" ? repository : `${url.host}/${repository}`;
24
24
  }
25
25
  /** Skills and their Source's Packages, in first-seen order; a Source with neither never appears. */
26
- export function buildTree(skills, sources = [], bundles = [], activeDescriptors = []) {
26
+ export function buildTree(skills, sources = [], bundles = [], activeDescriptors = [], apps = []) {
27
27
  const labels = new Map(sources.map((source) => [source.id, sourceLabel(source)]));
28
28
  const active = new Set(activeDescriptors);
29
29
  const bySource = new Map();
@@ -32,7 +32,7 @@ export function buildTree(skills, sources = [], bundles = [], activeDescriptors
32
32
  const bundlesBySource = new Map();
33
33
  for (const bundle of bundles)
34
34
  bundlesBySource.set(bundle.sourceId, [...(bundlesBySource.get(bundle.sourceId) ?? []), bundle]);
35
- return [...new Set([...bySource.keys(), ...bundlesBySource.keys()])].map((sourceId) => {
35
+ const sourcesTree = [...new Set([...bySource.keys(), ...bundlesBySource.keys()])].map((sourceId) => {
36
36
  const members = bySource.get(sourceId) ?? [];
37
37
  const sourceBundles = bundlesBySource.get(sourceId) ?? [];
38
38
  const owned = packageOwnedSkillMatcher(sourceBundles.map((entry) => entry.descriptor).filter((descriptor) => active.has(descriptor)));
@@ -52,13 +52,28 @@ export function buildTree(skills, sources = [], bundles = [], activeDescriptors
52
52
  ],
53
53
  };
54
54
  });
55
+ return apps.length === 0 ? sourcesTree : [...sourcesTree, {
56
+ id: "apps:user-global",
57
+ data: { kind: "group", label: `Apps (user-global) (${apps.length})` },
58
+ children: apps.map((row) => ({ id: `app:${row.entry.file}`, data: { kind: "app", row } })),
59
+ }];
55
60
  }
56
- function allSourceIds(roots) {
57
- return new Set(roots.filter((root) => (root.children?.length ?? 0) > 0).map((root) => root.id));
61
+ function allExpandableIds(roots) {
62
+ const ids = new Set();
63
+ const visit = (nodes) => {
64
+ for (const node of nodes) {
65
+ if ((node.children?.length ?? 0) === 0)
66
+ continue;
67
+ ids.add(node.id);
68
+ visit(node.children);
69
+ }
70
+ };
71
+ visit(roots);
72
+ return ids;
58
73
  }
59
74
  /** Filtering opens matching Sources; otherwise all groups start collapsed. */
60
75
  export function openByDefault(nodes, query) {
61
- return query === "" ? defaultExpanded() : allSourceIds(nodes);
76
+ return query === "" ? defaultExpanded() : allExpandableIds(nodes);
62
77
  }
63
78
  export function describeRow(row) {
64
79
  const { data } = row.node;
@@ -69,6 +84,8 @@ export function describeRow(row) {
69
84
  if (data.kind === "bundle") {
70
85
  return `Package ${data.entry.descriptor.name} (${data.entry.descriptor.host}) ${packageVersionLabel(data.entry.descriptor.version)}`;
71
86
  }
87
+ if (data.kind === "app")
88
+ return `App ${data.row.entry.recipe?.name ?? data.row.entry.file} ${data.row.inspection.status}`;
72
89
  return `${data.skill.name} ${truncateDescription(data.skill.description)}`;
73
90
  }
74
91
  /** The Skills under the tree's Sources, in display order. */