@iseoane/agent-depot 0.5.2 → 0.6.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 (44) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.es.md +137 -34
  3. package/README.md +120 -29
  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/file-picker-model.js +87 -0
  16. package/dist/src/tui/file-picker.js +142 -0
  17. package/dist/src/tui/header-layout.js +33 -0
  18. package/dist/src/tui/installations-panel.js +3 -3
  19. package/dist/src/tui/installations-rows.js +20 -5
  20. package/dist/src/tui/installations-tree.js +4 -7
  21. package/dist/src/tui/installations-view.js +15 -6
  22. package/dist/src/tui/installations.js +9 -7
  23. package/dist/src/tui/manage-panel.js +7 -6
  24. package/dist/src/tui/mode-keys.js +9 -0
  25. package/dist/src/tui/package-rows.js +17 -5
  26. package/dist/src/tui/panel-parts.js +119 -6
  27. package/dist/src/tui/preview-window.js +51 -0
  28. package/dist/src/tui/profile-view.js +394 -136
  29. package/dist/src/tui/sources-mode.js +3 -1
  30. package/dist/src/tui/sources-recipes.js +50 -19
  31. package/dist/src/tui/sources-view.js +50 -14
  32. package/dist/src/tui/text-wrap.js +53 -0
  33. package/dist/src/tui/theme.js +14 -7
  34. package/dist/src/tui/updates-flow.js +2 -2
  35. package/dist/src/tui/updates-keys.js +34 -5
  36. package/dist/src/tui/updates-model.js +2 -0
  37. package/dist/src/tui/updates-panel.js +40 -15
  38. package/dist/src/tui/updates-preview.js +65 -0
  39. package/dist/src/tui/updates-view.js +17 -2
  40. package/dist/src/tui/updates.js +14 -5
  41. package/dist/src/tui/window.js +8 -6
  42. package/package.json +6 -6
  43. package/skills/doctor-md-agents/README.md +3 -1
  44. package/skills/doctor-md-skill/README.md +2 -2
@@ -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. */
@@ -1,17 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useWindowSize } from "ink";
3
3
  import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { createAppOperations } from "../app-flow.js";
4
5
  import { BROWSE } from "./catalog-actions.js";
5
6
  import { handleCatalogBrowseKey } from "./catalog-browse.js";
6
7
  import { filterSkills } from "./catalog-filter.js";
7
8
  import { handleCatalogActionKey } from "./catalog-flow.js";
8
9
  import { isInstalled } from "./catalog-installs.js";
10
+ import { AppPanel, handleAppKey, startAppAction } from "./app-actions.js";
9
11
  import { ActionPanel, CatalogHeader, CatalogRowLine, CatalogSelectionHelp } from "./catalog-panel.js";
10
12
  import { packageOwnedSkillNotice } from "./catalog-ownership.js";
11
13
  import { packageOperationsOf } from "./environment.js";
12
14
  import { handlePackageKey, isPackageMode, startPackageAction } from "./package-actions.js";
13
15
  import { ListFooter } from "./panel-parts.js";
14
- import { useCatalogFilter, useCatalogSkills, useInstalledSkills } from "./catalog-state.js";
16
+ import { useCatalogApps, useCatalogFilter, useCatalogSkills, useInstalledSkills } from "./catalog-state.js";
15
17
  import { buildTree, leavesOf, openByDefault, skillKey } from "./catalog-tree.js";
16
18
  import { theme } from "./theme.js";
17
19
  import { useTreeNavigation } from "./tree-navigation.js";
@@ -38,7 +40,7 @@ function wrappedLineCount(text, columns) {
38
40
  return lines;
39
41
  }
40
42
  export function CatalogView({ operations, sourceId, onCapturingChange, environment, listHeight }) {
41
- const { columns } = useWindowSize();
43
+ const { columns, rows: terminalRows } = useWindowSize();
42
44
  const env = environment ?? NO_ENVIRONMENT;
43
45
  const [all, setAll] = useState(sourceId === undefined);
44
46
  const mounted = useMounted();
@@ -52,7 +54,17 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
52
54
  const filter = useCatalogFilter(actionRef, onCapturingChange);
53
55
  const state = useCatalogSkills(operations, sourceId, all, env, reloadKey);
54
56
  const packages = useMemo(() => packageOperationsOf(env), [env]);
57
+ const apps = useMemo(() => createAppOperations({ homeDirectory: env.homeDirectory, ...env.appEnvironment }), [env]);
58
+ const appCatalogState = useCatalogApps(apps);
59
+ const appCatalog = appCatalogState.state;
55
60
  const { installed, installedReady, reloadInstalled } = useInstalledSkills(operations, env, mounted);
61
+ const [appMode, setAppMode] = useState();
62
+ const appModeRef = useRef(undefined);
63
+ const setAppAction = (mode) => {
64
+ appModeRef.current = mode;
65
+ onCapturingChange?.(mode !== undefined || filter.filterRef.current.editing || actionRef.current.kind !== "browse");
66
+ setAppMode(mode);
67
+ };
56
68
  const setAction = (next) => {
57
69
  actionRef.current = next;
58
70
  onCapturingChange?.(filter.filterRef.current.editing || next.kind !== "browse");
@@ -61,7 +73,13 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
61
73
  const skills = state.status === "ready" ? state.skills : [];
62
74
  const installable = useMemo(() => skills.filter((skill) => !isInstalled(skill, installed)), [state, installed]);
63
75
  const visible = useMemo(() => filterSkills(installable, filter.filter.query), [installable, filter.filter.query]);
64
- const roots = useMemo(() => buildTree(visible, state.status === "ready" ? state.sources : [], state.status === "ready" ? state.bundles : [], state.status === "ready" ? state.activeBundles : []), [visible, state]);
76
+ const visibleApps = useMemo(() => {
77
+ if (appCatalog.status !== "ready")
78
+ return [];
79
+ const query = filter.filter.query.trim().toLowerCase();
80
+ return query === "" ? appCatalog.rows : appCatalog.rows.filter(row => (row.entry.recipe?.name ?? row.entry.file).toLowerCase().includes(query));
81
+ }, [appCatalog, filter.filter.query]);
82
+ const roots = useMemo(() => buildTree(visible, state.status === "ready" ? state.sources : [], state.status === "ready" ? state.bundles : [], state.status === "ready" ? state.activeBundles : [], visibleApps), [visible, state, visibleApps]);
65
83
  const navigation = useTreeNavigation(roots, (nodes) => openByDefault(nodes, filter.queryRef.current));
66
84
  // Discovering again starts the list over.
67
85
  const { restart } = navigation;
@@ -86,6 +104,23 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
86
104
  setAction(BROWSE);
87
105
  },
88
106
  };
107
+ const appManage = {
108
+ setMode: setAppAction,
109
+ browse: () => setAppAction(undefined),
110
+ setMessage,
111
+ isMounted: () => mounted.current,
112
+ unmark: ids => marks.setMarked(new Set([...marks.markedRef.current].filter(id => !ids.includes(id)))),
113
+ finish: async (result) => {
114
+ await reloadInstalled();
115
+ if (!mounted.current)
116
+ return;
117
+ await appCatalogState.reload();
118
+ if (!mounted.current)
119
+ return;
120
+ setMessage(result);
121
+ setAppAction(undefined);
122
+ },
123
+ };
89
124
  const packageHost = {
90
125
  setMode: setAction,
91
126
  browse: () => setAction(BROWSE),
@@ -107,29 +142,37 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
107
142
  marks,
108
143
  filter,
109
144
  canWiden: sourceId !== undefined,
110
- ready: state.status === "ready",
145
+ ready: state.status === "ready" && appCatalog.status === "ready",
111
146
  toggleAll: () => setAll((value) => !value),
112
147
  startPackage: (target) => void startPackageAction(packageContext, target, "i"),
148
+ startApp: (entry) => void startAppAction({ apps, manage: appManage, terminalRows: terminalRows || 24, terminalColumns: columns || 80 }, [entry], "i"),
113
149
  setMessage,
114
150
  setAction,
115
151
  };
116
152
  useKeys((input, key) => {
117
- const mode = actionRef.current;
118
- if (isPackageMode(mode))
119
- handlePackageKey(packageContext, mode, input, key);
120
- else if (mode.kind !== "browse")
121
- handleCatalogActionKey(flow, mode, input, key);
122
- else if (filter.filterRef.current.editing)
123
- filter.handleEditKey(input, key, navigation.restart);
124
- else
125
- handleCatalogBrowseKey(browse, input, key);
153
+ const runningAppMode = appModeRef.current;
154
+ if (runningAppMode !== undefined)
155
+ handleAppKey({ apps, manage: appManage, terminalRows: terminalRows || 24, terminalColumns: columns || 80 }, runningAppMode, input, key);
156
+ else {
157
+ const mode = actionRef.current;
158
+ if (isPackageMode(mode))
159
+ handlePackageKey(packageContext, mode, input, key);
160
+ else if (mode.kind !== "browse")
161
+ handleCatalogActionKey(flow, mode, input, key);
162
+ else if (filter.filterRef.current.editing)
163
+ filter.handleEditKey(input, key, navigation.restart);
164
+ else
165
+ handleCatalogBrowseKey(browse, input, key);
166
+ }
126
167
  });
127
168
  const scope = all || sourceId === undefined ? "all sources" : sourceId;
128
- if (state.status === "loading" || (state.status === "ready" && !installedReady))
129
- return _jsx(Text, { children: "Loading skills..." });
169
+ if (state.status === "loading" || appCatalog.status === "loading" || (state.status === "ready" && !installedReady))
170
+ return _jsx(Text, { children: "Loading catalog..." });
130
171
  if (state.status === "error")
131
172
  return _jsxs(Text, { color: theme.error, children: ["Error: ", state.message] });
132
173
  const window = computeWindow(rows.length, index, height);
133
174
  const selectedCount = leavesOf(roots).filter((skill) => marks.marked.has(skillKey(skill))).length;
134
- return (_jsxs(Box, { flexDirection: "column", children: [state.warnings.map((warning) => _jsx(Text, { color: theme.warning, children: warning }, warning)), _jsx(CatalogHeader, { scope: scope, listed: visible.length, installable: installable.length, total: skills.length, filter: filter.filter }), rows.slice(window.start, window.end).map((row, offset) => (_jsx(CatalogRowLine, { row: row, selected: window.start + offset === index, marked: marks.marked.has(row.node.id), installed: installed }, row.node.id))), _jsx(CatalogSelectionHelp, { row: rows[index], height: helpLines }), _jsx(ListFooter, { indicator: window.indicator, selectedCount: selectedCount, message: message }), _jsx(ActionPanel, { mode: action })] }));
175
+ if (appMode !== undefined)
176
+ return _jsx(AppPanel, { mode: appMode });
177
+ return (_jsxs(Box, { flexDirection: "column", children: [state.warnings.map((warning) => _jsx(Text, { color: theme.warning, children: warning }, warning)), appCatalog.status === "ready" && appCatalog.error ? _jsxs(Text, { color: theme.error, children: ["Apps unavailable: ", appCatalog.error] }) : null, _jsx(CatalogHeader, { scope: scope, listed: visible.length, installable: installable.length, total: skills.length, appCount: visibleApps.length, filter: filter.filter }), rows.slice(window.start, window.end).map((row, offset) => (_jsx(CatalogRowLine, { row: row, selected: window.start + offset === index, marked: marks.marked.has(row.node.id), installed: installed }, row.node.id))), _jsx(CatalogSelectionHelp, { row: rows[index], height: helpLines }), _jsx(ListFooter, { indicator: window.indicator, selectedCount: selectedCount, message: message }), appMode ? _jsx(AppPanel, { mode: appMode }) : _jsx(ActionPanel, { mode: action })] }));
135
178
  }
@@ -0,0 +1,87 @@
1
+ import { lstat, readdir, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /** The portable Profile artifact extension; import shows only these files until `f`. */
4
+ export const PROFILE_EXTENSION = ".json";
5
+ /** The pre-filled export file name; `Ctrl+U` clears it and a missing extension gains `.json`. */
6
+ export const DEFAULT_PROFILE_FILE_NAME = "agent-depot-profile.json";
7
+ /** The directory an export or import browser opens in. Tests inject `startDirectory`. */
8
+ export function pickerStartDirectory(environment) {
9
+ return path.resolve(environment.startDirectory ?? environment.projectRoot ?? process.cwd());
10
+ }
11
+ export function isProfileFileName(name) {
12
+ return path.extname(name).toLowerCase() === PROFILE_EXTENSION;
13
+ }
14
+ /** Directories first, then files, each in code-point order, so the list is stable across platforms. */
15
+ export function sortDirectoryEntries(entries) {
16
+ return [...entries].sort((left, right) => {
17
+ if (left.kind !== right.kind)
18
+ return left.kind === "directory" ? -1 : 1;
19
+ return left.name < right.name ? -1 : left.name > right.name ? 1 : 0;
20
+ });
21
+ }
22
+ /** Read one directory into the picker's listing shape; symlinked directories are followed. */
23
+ export async function readDirectoryListing(directory) {
24
+ const dirents = await readdir(directory, { withFileTypes: true });
25
+ const entries = [];
26
+ for (const dirent of dirents) {
27
+ let isDirectory = dirent.isDirectory();
28
+ if (!isDirectory && dirent.isSymbolicLink()) {
29
+ try {
30
+ isDirectory = (await stat(path.join(directory, dirent.name))).isDirectory();
31
+ }
32
+ catch {
33
+ isDirectory = false;
34
+ }
35
+ }
36
+ entries.push({ name: dirent.name, kind: isDirectory ? "directory" : "file" });
37
+ }
38
+ const parent = path.dirname(directory);
39
+ return { directory, parent: parent === directory ? undefined : parent, entries: sortDirectoryEntries(entries) };
40
+ }
41
+ /** The rows a listing shows: folders for both purposes, files only for `file`, filtered by `f`. */
42
+ export function pickerEntries(listing, purpose, showAllFiles) {
43
+ return listing.entries.filter(entry => {
44
+ if (entry.kind === "directory")
45
+ return true;
46
+ if (purpose === "directory")
47
+ return false;
48
+ return showAllFiles || isProfileFileName(entry.name);
49
+ });
50
+ }
51
+ /**
52
+ * Validate a typed file name and give it a JSON extension when it has none.
53
+ * Separators and the dot directories are refused so the name stays inside the chosen folder.
54
+ */
55
+ export function resolveExportFileName(input) {
56
+ if (input === "")
57
+ return { kind: "invalid", message: "Enter a file name." };
58
+ if (input === "." || input === "..")
59
+ return { kind: "invalid", message: "A file name cannot be . or .." };
60
+ if (input.includes("/") || input.includes("\\"))
61
+ return { kind: "invalid", message: "A file name cannot contain / or \\." };
62
+ if (path.extname(input) === "")
63
+ return { kind: "ok", fileName: `${input}${PROFILE_EXTENSION}`, appendedExtension: true };
64
+ return { kind: "ok", fileName: input, appendedExtension: false };
65
+ }
66
+ /** The full destination path, shown live in the name editor and in the confirmation. */
67
+ export function exportDestination(directory, fileName) {
68
+ return path.join(directory, fileName);
69
+ }
70
+ /** A note when the name keeps an extension other than `.json`; `undefined` when nothing needs explaining. */
71
+ export function fileNameNotice(fileName) {
72
+ const extension = path.extname(fileName);
73
+ if (extension === "" || extension.toLowerCase() === PROFILE_EXTENSION)
74
+ return undefined;
75
+ return `Keeping the ${extension} extension; import lists ${PROFILE_EXTENSION} files by default; press f to show all files.`;
76
+ }
77
+ export async function pathExists(target) {
78
+ try {
79
+ await lstat(target);
80
+ return true;
81
+ }
82
+ catch (error) {
83
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
84
+ return false;
85
+ throw error;
86
+ }
87
+ }
@@ -0,0 +1,142 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import path from "node:path";
3
+ import { Text, useWindowSize } from "ink";
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
+ import { pickerEntries, readDirectoryListing, PROFILE_EXTENSION } from "./file-picker-model.js";
6
+ import { useKeys } from "./keys.js";
7
+ import { moveCursor } from "./mode-keys.js";
8
+ import { BorderPanel } from "./panel-parts.js";
9
+ import { clipText, wrapText } from "./text-wrap.js";
10
+ import { theme } from "./theme.js";
11
+ import { useMirrored, useMounted } from "./view-state.js";
12
+ import { computeWindow, useListHeight } from "./window.js";
13
+ /**
14
+ * A picker callback changes the enclosing view's stage. Defer it so the view's own key
15
+ * handler, which runs in the same event, still sees the picker stage and ignores the key.
16
+ */
17
+ function settle(action) {
18
+ queueMicrotask(action);
19
+ }
20
+ /**
21
+ * A keyboard-only directory and file browser. It owns its keys while mounted, so the
22
+ * enclosing view must ignore its own bindings in the picker's stages. Each read carries a
23
+ * request generation, so a slow listing never overwrites a newer directory or an unmount.
24
+ */
25
+ export function FilePicker({ purpose, startDirectory, notice, onCancel, onChoose, listHeight, readDirectory = readDirectoryListing }) {
26
+ const [state, setState] = useState({ status: "loading", directory: startDirectory });
27
+ const [cursor, cursorRef, cursorTo] = useMirrored(0);
28
+ const [showAllFiles, showAllFilesRef, setShowAllFiles] = useMirrored(false);
29
+ const [showFullPath, setShowFullPath] = useState(false);
30
+ const requestId = useRef(0);
31
+ const mounted = useMounted();
32
+ const { columns } = useWindowSize();
33
+ const panelWidth = Math.max((columns || 80) - 4, 1);
34
+ const navigate = useCallback((directory) => {
35
+ const id = ++requestId.current;
36
+ cursorTo(0);
37
+ setState({ status: "loading", directory });
38
+ void readDirectory(directory).then(listing => {
39
+ if (!mounted.current || requestId.current !== id)
40
+ return;
41
+ setState({ status: "ready", directory: listing.directory, parent: listing.parent, entries: listing.entries });
42
+ }, (error) => {
43
+ if (!mounted.current || requestId.current !== id)
44
+ return;
45
+ setState({ status: "error", directory, message: error instanceof Error ? error.message : String(error) });
46
+ });
47
+ }, [cursorTo, mounted, readDirectory]);
48
+ useEffect(() => { navigate(startDirectory); }, [navigate, startDirectory]);
49
+ const entries = state.status === "ready" ? pickerEntries(state, purpose, showAllFiles) : [];
50
+ const rows = useMemo(() => {
51
+ const parent = state.status === "ready" && state.parent !== undefined ? [{ kind: "parent" }] : [];
52
+ const listing = entries.map(entry => ({ kind: "entry", entry }));
53
+ const useFolder = state.status === "ready" && purpose === "directory" ? [{ kind: "use-folder" }] : [];
54
+ return [...useFolder, ...parent, ...listing];
55
+ }, [entries, purpose, state]);
56
+ const choose = (row) => {
57
+ if (!row || state.status !== "ready")
58
+ return;
59
+ if (row.kind === "use-folder") {
60
+ settle(() => onChoose({ kind: "directory", directory: state.directory }));
61
+ return;
62
+ }
63
+ if (row.kind === "parent") {
64
+ if (state.parent !== undefined)
65
+ navigate(state.parent);
66
+ return;
67
+ }
68
+ const target = path.join(state.directory, row.entry.name);
69
+ if (row.entry.kind === "directory")
70
+ navigate(target);
71
+ else
72
+ settle(() => onChoose({ kind: "file", file: target, directory: state.directory }));
73
+ };
74
+ useKeys((input, key) => {
75
+ if (key.escape) {
76
+ settle(onCancel);
77
+ return;
78
+ }
79
+ if (showFullPath) {
80
+ if (input === "p")
81
+ setShowFullPath(false);
82
+ return;
83
+ }
84
+ if (state.status !== "ready") {
85
+ const fallbackParent = path.dirname(state.directory);
86
+ if ((key.backspace || key.leftArrow) && fallbackParent !== state.directory)
87
+ navigate(fallbackParent);
88
+ return;
89
+ }
90
+ if (key.backspace || key.leftArrow) {
91
+ if (state.parent !== undefined)
92
+ navigate(state.parent);
93
+ return;
94
+ }
95
+ if (input === "f" && purpose === "file") {
96
+ setShowAllFiles(!showAllFilesRef.current);
97
+ cursorTo(0);
98
+ return;
99
+ }
100
+ if (input === "p") {
101
+ setShowFullPath(true);
102
+ return;
103
+ }
104
+ const next = moveCursor(cursorRef.current, rows.length, input, key);
105
+ if (next !== undefined) {
106
+ cursorTo(Math.max(next, 0));
107
+ return;
108
+ }
109
+ if (key.return)
110
+ choose(rows[Math.min(cursorRef.current, rows.length - 1)]);
111
+ });
112
+ const directory = state.directory;
113
+ const wrappedDirectory = wrapText(`Directory: ${directory}`, panelWidth);
114
+ const directoryLines = showFullPath || wrappedDirectory.length <= 3
115
+ ? wrappedDirectory
116
+ : [wrappedDirectory[0], ...wrapText(`… ${wrappedDirectory.length - 2} lines · press p for full path`, panelWidth), wrappedDirectory.at(-1)];
117
+ const ordinaryFooterLines = (purpose === "directory"
118
+ ? ["j/k move · Enter open/use · ←", "parent · p full path · Esc back"]
119
+ : ["j/k move · Enter open · ← parent", `f ${showAllFiles ? "json only" : "all files"} · p full path · Esc back`])
120
+ .flatMap(line => wrapText(line, panelWidth));
121
+ const noticeLines = notice === undefined ? [] : wrapText(notice, panelWidth);
122
+ const filterNoteLines = !showFullPath && purpose === "file" && state.status === "ready" && !showAllFiles
123
+ ? wrapText(`Showing ${PROFILE_EXTENSION} only · f shows all files`, panelWidth)
124
+ : [];
125
+ const statusLineCount = !showFullPath && (state.status === "loading" || state.status === "error" || (state.status === "ready" && rows.length === 0)) ? 1 : 0;
126
+ const visibleNoticeLines = showFullPath ? [] : noticeLines;
127
+ const footerLines = showFullPath ? wrapText("p hide path · Esc back", panelWidth) : ordinaryFooterLines;
128
+ const reservesWindowIndicator = !showFullPath && rows.length > 0 && listHeight === undefined ? 1 : 0;
129
+ const reserved = 3 + directoryLines.length + statusLineCount + filterNoteLines.length + visibleNoticeLines.length + footerLines.length + reservesWindowIndicator;
130
+ const available = useListHeight(listHeight, reserved, 1);
131
+ const height = rows.length > available && listHeight !== undefined ? Math.max(available - 1, 1) : available;
132
+ const window = computeWindow(rows.length, cursor, height);
133
+ const listedRows = showFullPath ? [] : rows.slice(window.start, window.end);
134
+ return (_jsxs(BorderPanel, { title: purpose === "directory" ? "Choose export folder" : "Choose import file", children: [directoryLines.map((line, index) => _jsx(Text, { color: theme.value, children: line }, index)), !showFullPath && state.status === "loading" ? _jsx(Text, { color: theme.muted, children: "Loading\u2026" }) : null, !showFullPath && state.status === "error" ? _jsxs(Text, { color: theme.error, children: ["Error: ", clipText(state.message, panelWidth)] }) : null, !showFullPath && state.status === "ready" && rows.length === 0 ? _jsx(Text, { color: theme.muted, children: "Empty folder" }) : null, filterNoteLines.map((line, index) => _jsx(Text, { color: theme.muted, children: line }, `filter-${index}`)), listedRows.map((row, position) => {
135
+ const index = window.start + position;
136
+ const marker = index === cursor ? "> " : " ";
137
+ const label = row.kind === "use-folder" ? `Use this folder: ${directory}`
138
+ : row.kind === "parent" ? ".. (parent folder)"
139
+ : row.entry.kind === "directory" ? `${row.entry.name}/` : row.entry.name;
140
+ return _jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: marker }), _jsx(Text, { color: theme.value, children: clipText(label, Math.max(panelWidth - 2, 1)) })] }, index);
141
+ }), visibleNoticeLines.map((line, index) => _jsx(Text, { color: theme.warning, children: line }, `notice-${index}`)), footerLines.map((line, index) => _jsx(Text, { color: theme.legend, children: line }, `footer-${index}`)), !showFullPath && window.indicator ? _jsx(Text, { color: theme.muted, children: window.indicator }) : null] }));
142
+ }