@iseoane/agent-depot 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.es.md +969 -0
  3. package/README.md +244 -23
  4. package/dist/src/app-flow.js +52 -51
  5. package/dist/src/app-recipe-removal.js +63 -0
  6. package/dist/src/cli.js +122 -11
  7. package/dist/src/executable-resolution.js +54 -0
  8. package/dist/src/git-source.js +129 -6
  9. package/dist/src/package-bundles.js +614 -0
  10. package/dist/src/package-cli.js +412 -0
  11. package/dist/src/package-content.js +158 -0
  12. package/dist/src/package-flow.js +636 -0
  13. package/dist/src/package-host-plans.js +240 -0
  14. package/dist/src/package-host-state.js +349 -0
  15. package/dist/src/package-model.js +33 -0
  16. package/dist/src/package-owned-skills.js +122 -0
  17. package/dist/src/package-state.js +404 -0
  18. package/dist/src/profile-cli.js +15 -9
  19. package/dist/src/profile-export.js +44 -9
  20. package/dist/src/profile-import.js +80 -17
  21. package/dist/src/profile.js +78 -3
  22. package/dist/src/skill-discovery.js +2 -2
  23. package/dist/src/skill-install.js +16 -0
  24. package/dist/src/sources.js +11 -0
  25. package/dist/src/tui/app-actions.js +1 -1
  26. package/dist/src/tui/app.js +5 -6
  27. package/dist/src/tui/catalog-browse.js +10 -3
  28. package/dist/src/tui/catalog-panel.js +7 -3
  29. package/dist/src/tui/catalog-state.js +57 -6
  30. package/dist/src/tui/catalog-tree.js +52 -10
  31. package/dist/src/tui/catalog-view.js +27 -4
  32. package/dist/src/tui/environment.js +8 -1
  33. package/dist/src/tui/installations-browse.js +20 -2
  34. package/dist/src/tui/installations-rows.js +2 -1
  35. package/dist/src/tui/installations-tree.js +14 -1
  36. package/dist/src/tui/installations-view.js +8 -2
  37. package/dist/src/tui/installations.js +12 -2
  38. package/dist/src/tui/manage-actions.js +2 -0
  39. package/dist/src/tui/manage-panel.js +5 -0
  40. package/dist/src/tui/package-actions.js +165 -0
  41. package/dist/src/tui/package-rows.js +87 -0
  42. package/dist/src/tui/profile-view.js +36 -14
  43. package/dist/src/tui/sources-mode.js +2 -0
  44. package/dist/src/tui/sources-recipes.js +116 -53
  45. package/dist/src/tui/sources-view.js +57 -19
  46. package/dist/src/tui/tree.js +3 -4
  47. package/dist/src/tui/updates-flow.js +4 -1
  48. package/dist/src/tui/updates-panel.js +2 -2
  49. package/dist/src/tui/updates.js +72 -4
  50. package/dist/src/user-global-skill-inventory.js +7 -2
  51. package/package.json +2 -1
  52. package/skills/agent-depot-apprecipe/SKILL.md +29 -5
@@ -1,8 +1,12 @@
1
1
  import { useCallback, useEffect, useReducer, useRef, useState } from "react";
2
+ import { packageSelectionFor } from "../package-model.js";
3
+ import { projectSourceOf } from "../sources.js";
2
4
  import { filterReducer, initialFilter } from "./catalog-filter.js";
3
5
  import { loadInstalledSkills, NO_INSTALLED } from "./catalog-installs.js";
4
- /** Discovers the Skills of one Source, or of all of them, and again whenever those inputs change. */
5
- export function useCatalogSkills(operations, sourceId, all) {
6
+ import { sourceLabel } from "./catalog-tree.js";
7
+ import { packageOperationsOf } from "./environment.js";
8
+ /** Discovers the Skills of one Source, or of all of them, and again whenever those inputs or `reloadKey` change. */
9
+ export function useCatalogSkills(operations, sourceId, all, env, reloadKey = 0) {
6
10
  const [state, setState] = useState({ status: "loading" });
7
11
  useEffect(() => {
8
12
  let cancelled = false;
@@ -13,12 +17,59 @@ export function useCatalogSkills(operations, sourceId, all) {
13
17
  setState({ status: "loading" });
14
18
  void (async () => {
15
19
  try {
20
+ const sources = await operations.listSources();
16
21
  const ids = all || sourceId === undefined
17
- ? (await operations.listSources()).map((source) => source.id)
22
+ ? sources.map((source) => source.id)
18
23
  : [sourceId];
19
- const skills = await operations.discoverSkills(ids);
24
+ const labels = new Map(sources.map((source) => [source.id, sourceLabel(source)]));
25
+ const skills = [];
26
+ const bundles = [];
27
+ const discovered = [];
28
+ const warnings = [];
29
+ try {
30
+ skills.push(...await operations.discoverSkills(ids));
31
+ }
32
+ catch {
33
+ // Discovery is read-only: isolate failures without silently refreshing Sources.
34
+ for (const id of ids) {
35
+ try {
36
+ skills.push(...await operations.discoverSkills([id]));
37
+ }
38
+ catch (error) {
39
+ const detail = error instanceof Error ? error.message : String(error);
40
+ warnings.push(`Source ${labels.get(id) ?? id} unavailable: ${detail}. Go to Sources (1) and refresh it with r.`);
41
+ }
42
+ }
43
+ }
44
+ // Bundles read the same snapshots and group under their Source; one failure never hides the Skills.
45
+ const packages = packageOperationsOf(env);
46
+ for (const id of ids) {
47
+ const source = sources.find((candidate) => candidate.id === id);
48
+ if (source === undefined)
49
+ continue;
50
+ try {
51
+ const projectSource = projectSourceOf(source);
52
+ for (const descriptor of await packages.discover([id])) {
53
+ bundles.push({ sourceId: id, selection: packageSelectionFor(descriptor, projectSource), descriptor });
54
+ discovered.push({ source: projectSource, descriptor });
55
+ }
56
+ }
57
+ catch (error) {
58
+ const detail = error instanceof Error ? error.message : String(error);
59
+ warnings.push(`Source ${labels.get(id) ?? id} Packages unavailable: ${detail}`);
60
+ }
61
+ }
62
+ // Ownership is selection- or host-gated, so a loose Skill stays normal until its Package is active.
63
+ let activeBundles = [];
64
+ try {
65
+ activeBundles = (await packages.activeBundles(discovered)).map(bundle => bundle.descriptor);
66
+ }
67
+ catch (error) {
68
+ const detail = error instanceof Error ? error.message : String(error);
69
+ warnings.push(`Package ownership unavailable: ${detail}`);
70
+ }
20
71
  if (!cancelled)
21
- setState({ status: "ready", skills });
72
+ setState({ status: "ready", skills, bundles, activeBundles, warnings, sources });
22
73
  }
23
74
  catch (error) {
24
75
  if (!cancelled)
@@ -28,7 +79,7 @@ export function useCatalogSkills(operations, sourceId, all) {
28
79
  return () => {
29
80
  cancelled = true;
30
81
  };
31
- }, [operations, sourceId, all]);
82
+ }, [operations, sourceId, all, env, reloadKey]);
32
83
  return state;
33
84
  }
34
85
  /** The Skills already installed, loaded on mount and on demand. */
@@ -1,32 +1,74 @@
1
+ import { githubTreeLocation } from "../git-source.js";
2
+ import { bundleRoot } from "../package-model.js";
3
+ import { packageOwnedSkillMatcher } from "../package-owned-skills.js";
4
+ import { packageVersionLabel } from "./package-rows.js";
1
5
  import { defaultExpanded } from "./tree.js";
2
6
  const DESCRIPTION_LIMIT = 60;
3
7
  export const UNMANAGED_MARKER = "on disk, unmanaged · adopt it from Installations";
4
8
  export const skillKey = (skill) => `skill:${skill.sourceId}:${skill.path}`;
9
+ export const bundleKey = (entry) => `bundle:${entry.sourceId}:${entry.descriptor.host}:${bundleRoot(entry.descriptor.coordinates)}`;
5
10
  function truncate(text) {
6
11
  return text.length > DESCRIPTION_LIMIT ? `${text.slice(0, DESCRIPTION_LIMIT - 3)}...` : text;
7
12
  }
8
- /** Skills grouped by Source in first-seen order; a Source with no Skills never appears. */
9
- export function buildTree(skills) {
13
+ /** A repository name, with directory and ref for scoped GitHub Sources. */
14
+ export function sourceLabel(source) {
15
+ if (source.kind === "builtin")
16
+ return source.id;
17
+ const location = githubTreeLocation(source.url);
18
+ if (location !== undefined) {
19
+ return `${location.repository}${location.directory === undefined ? "" : ` · ${location.directory}`} @ ${location.ref}`;
20
+ }
21
+ const url = new URL(source.url);
22
+ const repository = url.pathname.replace(/^\/+|\/+$/gu, "").replace(/\.git$/u, "");
23
+ return url.hostname === "github.com" ? repository : `${url.host}/${repository}`;
24
+ }
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 = []) {
27
+ const labels = new Map(sources.map((source) => [source.id, sourceLabel(source)]));
28
+ const active = new Set(activeDescriptors);
10
29
  const bySource = new Map();
11
30
  for (const skill of skills)
12
31
  bySource.set(skill.sourceId, [...(bySource.get(skill.sourceId) ?? []), skill]);
13
- return [...bySource].map(([sourceId, members]) => ({
14
- id: `source:${sourceId}`,
15
- data: { kind: "source", sourceId, count: members.length },
16
- children: members.map((skill) => ({ id: skillKey(skill), data: { kind: "skill", skill } })),
17
- }));
32
+ const bundlesBySource = new Map();
33
+ for (const bundle of bundles)
34
+ bundlesBySource.set(bundle.sourceId, [...(bundlesBySource.get(bundle.sourceId) ?? []), bundle]);
35
+ return [...new Set([...bySource.keys(), ...bundlesBySource.keys()])].map((sourceId) => {
36
+ const members = bySource.get(sourceId) ?? [];
37
+ const sourceBundles = bundlesBySource.get(sourceId) ?? [];
38
+ const owned = packageOwnedSkillMatcher(sourceBundles.map((entry) => entry.descriptor).filter((descriptor) => active.has(descriptor)));
39
+ return {
40
+ id: `source:${sourceId}`,
41
+ data: { kind: "source", sourceId, label: labels.get(sourceId) ?? sourceId, count: members.length },
42
+ children: [
43
+ ...(sourceBundles.length === 0 ? [] : [{
44
+ id: `packages:${sourceId}`,
45
+ data: { kind: "group", label: `Packages (${sourceBundles.length})` },
46
+ children: sourceBundles.map((entry) => ({ id: bundleKey(entry), data: { kind: "bundle", entry } })),
47
+ }]),
48
+ ...members.map((skill) => {
49
+ const ownedBy = owned(skill.path);
50
+ return { id: skillKey(skill), data: { kind: "skill", skill, ...(ownedBy === undefined ? {} : { ownedBy }) } };
51
+ }),
52
+ ],
53
+ };
54
+ });
18
55
  }
19
56
  function allSourceIds(roots) {
20
57
  return new Set(roots.filter((root) => (root.children?.length ?? 0) > 0).map((root) => root.id));
21
58
  }
22
- /** While a filter is set every matching source is open so the matches show; otherwise only the first. */
59
+ /** Filtering opens matching Sources; otherwise all groups start collapsed. */
23
60
  export function openByDefault(nodes, query) {
24
- return query === "" ? defaultExpanded(nodes) : allSourceIds(nodes);
61
+ return query === "" ? defaultExpanded() : allSourceIds(nodes);
25
62
  }
26
63
  export function describeRow(row) {
27
64
  const { data } = row.node;
28
65
  if (data.kind === "source")
29
- return `${row.expanded ? "▾" : "▸"} ${data.sourceId} (${data.count})`;
66
+ return `${row.expanded ? "▾" : "▸"} ${data.label} (${data.count})`;
67
+ if (data.kind === "group")
68
+ return `${row.expanded ? "▾" : "▸"} ${data.label}`;
69
+ if (data.kind === "bundle") {
70
+ return `Package ${data.entry.descriptor.name} (${data.entry.descriptor.host}) ${packageVersionLabel(data.entry.descriptor.version)}`;
71
+ }
30
72
  return `${data.skill.name} ${truncate(data.skill.description)}`;
31
73
  }
32
74
  /** The Skills under the tree's Sources, in display order. */
@@ -7,6 +7,8 @@ import { filterSkills } from "./catalog-filter.js";
7
7
  import { handleCatalogActionKey } from "./catalog-flow.js";
8
8
  import { isInstalled } from "./catalog-installs.js";
9
9
  import { ActionPanel, CatalogHeader, CatalogRowLine } from "./catalog-panel.js";
10
+ import { packageOperationsOf } from "./environment.js";
11
+ import { handlePackageKey, isPackageMode, startPackageAction } from "./package-actions.js";
10
12
  import { ListFooter } from "./panel-parts.js";
11
13
  import { useCatalogFilter, useCatalogSkills, useInstalledSkills } from "./catalog-state.js";
12
14
  import { buildTree, leavesOf, openByDefault, skillKey } from "./catalog-tree.js";
@@ -24,10 +26,13 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
24
26
  const marks = useMarks();
25
27
  const [message, setMessage] = useState();
26
28
  const [action, setActionState] = useState(BROWSE);
29
+ // Bumped after a Package action so discovery re-reads the refreshed Source.
30
+ const [reloadKey, setReloadKey] = useState(0);
27
31
  // Mirrors the action mode synchronously, like the filter, so later keystrokes and the shell never see stale state.
28
32
  const actionRef = useRef(BROWSE);
29
33
  const filter = useCatalogFilter(actionRef, onCapturingChange);
30
- const state = useCatalogSkills(operations, sourceId, all);
34
+ const state = useCatalogSkills(operations, sourceId, all, env, reloadKey);
35
+ const packages = useMemo(() => packageOperationsOf(env), [env]);
31
36
  const { installed, installedReady, reloadInstalled } = useInstalledSkills(operations, env, mounted);
32
37
  const setAction = (next) => {
33
38
  actionRef.current = next;
@@ -37,7 +42,7 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
37
42
  const skills = state.status === "ready" ? state.skills : [];
38
43
  const installable = useMemo(() => skills.filter((skill) => !isInstalled(skill, installed)), [state, installed]);
39
44
  const visible = useMemo(() => filterSkills(installable, filter.filter.query), [installable, filter.filter.query]);
40
- const roots = useMemo(() => buildTree(visible), [visible]);
45
+ const roots = useMemo(() => buildTree(visible, state.status === "ready" ? state.sources : [], state.status === "ready" ? state.bundles : [], state.status === "ready" ? state.activeBundles : []), [visible, state]);
41
46
  const navigation = useTreeNavigation(roots, (nodes) => openByDefault(nodes, filter.queryRef.current));
42
47
  // Discovering again starts the list over.
43
48
  const { restart } = navigation;
@@ -58,6 +63,21 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
58
63
  setAction(BROWSE);
59
64
  },
60
65
  };
66
+ const packageHost = {
67
+ setMode: setAction,
68
+ browse: () => setAction(BROWSE),
69
+ setMessage,
70
+ isMounted: () => mounted.current,
71
+ finish: async (result) => {
72
+ await reloadInstalled();
73
+ if (!mounted.current)
74
+ return;
75
+ setReloadKey((value) => value + 1);
76
+ setMessage(result);
77
+ setAction(BROWSE);
78
+ },
79
+ };
80
+ const packageContext = { packages, manage: packageHost };
61
81
  const browse = {
62
82
  navigation,
63
83
  height,
@@ -66,12 +86,15 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
66
86
  canWiden: sourceId !== undefined,
67
87
  ready: state.status === "ready",
68
88
  toggleAll: () => setAll((value) => !value),
89
+ startPackage: (target) => void startPackageAction(packageContext, target, "i"),
69
90
  setMessage,
70
91
  setAction,
71
92
  };
72
93
  useKeys((input, key) => {
73
94
  const mode = actionRef.current;
74
- if (mode.kind !== "browse")
95
+ if (isPackageMode(mode))
96
+ handlePackageKey(packageContext, mode, input, key);
97
+ else if (mode.kind !== "browse")
75
98
  handleCatalogActionKey(flow, mode, input, key);
76
99
  else if (filter.filterRef.current.editing)
77
100
  filter.handleEditKey(input, key, navigation.restart);
@@ -85,5 +108,5 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
85
108
  return _jsxs(Text, { color: theme.error, children: ["Error: ", state.message] });
86
109
  const window = computeWindow(rows.length, index, height);
87
110
  const selectedCount = leavesOf(roots).filter((skill) => marks.marked.has(skillKey(skill))).length;
88
- return (_jsxs(Box, { flexDirection: "column", children: [_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(ListFooter, { indicator: window.indicator, selectedCount: selectedCount, message: message }), _jsx(ActionPanel, { mode: action })] }));
111
+ 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(ListFooter, { indicator: window.indicator, selectedCount: selectedCount, message: message }), _jsx(ActionPanel, { mode: action })] }));
89
112
  }
@@ -1 +1,8 @@
1
- export {};
1
+ import { createPackageOperations } from "../package-flow.js";
2
+ /** The Package operations a view uses: the injected fake, or the real flow over this environment. */
3
+ export function packageOperationsOf(environment) {
4
+ return environment.packages ?? createPackageOperations({
5
+ ...(environment.homeDirectory === undefined ? {} : { homeDirectory: environment.homeDirectory }),
6
+ ...environment.packagesEnvironment,
7
+ });
8
+ }
@@ -1,4 +1,5 @@
1
1
  import { appName } from "./app-actions.js";
2
+ import { startPackageAction } from "./package-actions.js";
2
3
  import { startBulkHostAddition, startBulkUninstall } from "./bulk-actions.js";
3
4
  import { skillOf, sourceIdOf } from "./installations.js";
4
5
  import { leavesOf, targetOf } from "./installations-tree.js";
@@ -26,13 +27,17 @@ function markRow(context, row) {
26
27
  context.setMessage({ kind: "error", text: "Mark skills, not group rows" });
27
28
  return;
28
29
  }
30
+ if (row.node.data.kind === "package") {
31
+ context.setMessage({ kind: "error", text: "Packages act from their own row; marking is not supported" });
32
+ return;
33
+ }
29
34
  context.setMessage(undefined);
30
35
  context.marks.setMarked(toggledMark(context.marks.markedRef.current, row.node.id));
31
36
  }
32
37
  /** `a`: marks every listed leaf, or unmarks them when they are all marked already. */
33
38
  function markAllListed(context, visible) {
34
39
  const { markedRef, setMarked } = context.marks;
35
- const leaves = visible.filter((candidate) => candidate.node.data.kind !== "group").map((candidate) => candidate.node.id);
40
+ const leaves = visible.filter((candidate) => candidate.node.data.kind !== "group" && candidate.node.data.kind !== "package").map((candidate) => candidate.node.id);
36
41
  if (leaves.length === 0) {
37
42
  context.setMessage({ kind: "error", text: "There are no skills to select" });
38
43
  return;
@@ -71,6 +76,16 @@ function actOnInstallation(context, data, item, input, key) {
71
76
  else
72
77
  startHostAddition(context.manage, skill);
73
78
  }
79
+ /** The per-row Package keys; Enter approves the install declaration. */
80
+ const PACKAGE_ACTION_MESSAGE = "Packages use i install, U update, u uninstall, f forget and Enter approve";
81
+ function actOnPackage(context, item, input, key) {
82
+ const action = key.return ? "approve" : input;
83
+ if (action === "i" || action === "U" || action === "u" || action === "f" || action === "approve") {
84
+ void startPackageAction({ packages: context.packages, manage: context.manage }, { selection: item.row.selection, name: item.row.name }, action);
85
+ return;
86
+ }
87
+ context.setMessage({ kind: "error", text: PACKAGE_ACTION_MESSAGE });
88
+ }
74
89
  /** `A`, Enter, `u`, `h` and `i` on the highlighted row. */
75
90
  function actOnRow(context, snapshot, row, input, key) {
76
91
  const { data } = context;
@@ -87,6 +102,8 @@ function actOnRow(context, snapshot, row, input, key) {
87
102
  context.setMessage(undefined);
88
103
  if (item.kind === "unmanaged")
89
104
  actOnUnmanaged(context, item.group, input, key);
105
+ else if (item.kind === "package")
106
+ actOnPackage(context, item, input, key);
90
107
  else if (item.kind === "app") {
91
108
  if (input === "A")
92
109
  context.setMessage({ kind: "error", text: "Adoption applies to unmanaged Skills, not Apps" });
@@ -110,7 +127,8 @@ export function handleBrowseKey(context, input, key) {
110
127
  if (navigateTree(context.navigation, snapshot, context.height, input, key))
111
128
  return;
112
129
  const row = snapshot.visible[snapshot.at];
113
- const rowAction = input === "A" || key.return || input === "u" || input === "h" || input === "i";
130
+ const rowAction = input === "A" || key.return || input === "u" || input === "h" || input === "i"
131
+ || (row?.node.data.kind === "package" && (input === "U" || input === "f"));
114
132
  const appMarks = leavesOf(context.navigation.latestRoots()).filter(node => context.marks.markedRef.current.has(node.id) && node.data.kind === "app");
115
133
  if ((input === "u" || input === "h" || input === "i") && appMarks.length) {
116
134
  if (markedTargets(context).length)
@@ -1,11 +1,12 @@
1
1
  import { jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Text } from "ink";
3
3
  import { describeDetails, describeLinks, describeNode, markersOf } from "./installations-tree.js";
4
+ import { describePackageRow } from "./package-rows.js";
4
5
  import { rowStyle, theme } from "./theme.js";
5
6
  /** One line of the Installations tree: cursor, indent, mark box, label and the leaf's details. */
6
7
  export function TreeRowLine({ row, selected, marked }) {
7
8
  const item = row.node.data;
8
9
  const leaf = !row.expandable && item.kind !== "group";
9
10
  const prefix = `${selected ? "> " : " "}${" ".repeat(row.depth)}${leaf ? (marked ? "[x] " : "[ ] ") : ""}`;
10
- return (_jsxs(Text, { ...rowStyle(selected), color: item.kind === "group" ? theme.group : undefined, children: [prefix, describeNode(row), item.kind === "installation" ? (_jsxs(Text, { color: theme.marker, children: [" ", describeDetails(item.row), markersOf(item.row) === "" ? "" : ` ${markersOf(item.row)}`] })) : null, item.kind === "unmanaged" && describeLinks(item.group) !== "" ? (_jsxs(Text, { color: theme.marker, children: [" ", describeLinks(item.group)] })) : null] }));
11
+ return (_jsxs(Text, { ...rowStyle(selected), color: item.kind === "group" ? theme.group : undefined, children: [prefix, describeNode(row), item.kind === "installation" ? (_jsxs(Text, { color: theme.marker, children: [" ", describeDetails(item.row), markersOf(item.row) === "" ? "" : ` ${markersOf(item.row)}`] })) : null, item.kind === "package" ? (_jsxs(Text, { color: theme.marker, children: [" ", describePackageRow(item.row)] })) : null, item.kind === "unmanaged" && describeLinks(item.group) !== "" ? (_jsxs(Text, { color: theme.marker, children: [" ", describeLinks(item.group)] })) : null] }));
11
12
  }
@@ -1,3 +1,4 @@
1
+ import { selectionKey } from "../package-model.js";
1
2
  /** Managed installations of one scope, grouped by Source in first-seen order. */
2
3
  function sourceNodes(scope, rows) {
3
4
  const bySource = new Map();
@@ -40,6 +41,12 @@ export function buildTree(data) {
40
41
  id: "scope:apps", data: { kind: "group", label: `Apps (user-global) (${data.apps?.length ?? 0})${data.appsError ? ` — ${data.appsError}` : ""}` },
41
42
  children: (data.apps ?? []).map(row => ({ id: `app:${row.entry.file}`, data: { kind: "app", row } })),
42
43
  });
44
+ if (data.packages?.length || data.packagesError)
45
+ roots.push({
46
+ id: "scope:packages",
47
+ data: { kind: "group", label: `Packages (user-global) (${data.packages?.length ?? 0})${data.packagesError ? ` — ${data.packagesError}` : ""}` },
48
+ children: (data.packages ?? []).map(row => ({ id: `package:${selectionKey(row.selection)}`, data: { kind: "package", row } })),
49
+ });
43
50
  return roots;
44
51
  }
45
52
  /** Version, policy and hosts of a leaf; `adopted` / `modified` only when true (the legend explains them). */
@@ -67,9 +74,15 @@ export function describeNode(row) {
67
74
  return `${row.expandable ? (row.expanded ? "▾" : "▸") : " "} ${data.label}`;
68
75
  if (data.kind === "installation")
69
76
  return data.row.selection.path;
77
+ if (data.kind === "package")
78
+ return data.row.name;
70
79
  if (data.kind === "app") {
71
80
  const { entry, inspection } = data.row;
72
- return `${entry.recipe?.name ?? entry.file} ${inspection.status === "invalid" ? `invalid recipe: ${inspection.reason}` : inspection.installedVersion ?? inspection.status}`;
81
+ const name = entry.recipe?.name ?? entry.file;
82
+ if (inspection.status === "invalid")
83
+ return `${name} invalid recipe: ${inspection.reason}`;
84
+ const version = inspection.installedVersion ?? inspection.status;
85
+ return `${name} ${version}${inspection.evidence === "recorded" ? " (recorded)" : ""}`;
73
86
  }
74
87
  return `${data.group.name} [${data.group.hosts.join(", ")}]`;
75
88
  }
@@ -3,6 +3,8 @@ import { Box, Text } from "ink";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { createAppOperations } from "../app-flow.js";
5
5
  import { handleAppKey, isAppMode, startAppAction } from "./app-actions.js";
6
+ import { handlePackageKey, isPackageMode } from "./package-actions.js";
7
+ import { packageOperationsOf } from "./environment.js";
6
8
  import { loadInstallations } from "./installations.js";
7
9
  import { BROWSE } from "./installations-mode.js";
8
10
  import { errorText } from "./batch.js";
@@ -25,6 +27,7 @@ export function InstallationsView({ operations, environment, onCapturingChange,
25
27
  const height = useListHeight(listHeight, 7);
26
28
  const env = environment ?? NO_ENVIRONMENT;
27
29
  const apps = useMemo(() => createAppOperations({ homeDirectory: env.homeDirectory, ...env.appEnvironment }), [env]);
30
+ const packages = useMemo(() => packageOperationsOf(env), [env]);
28
31
  const [state, setState] = useState({ status: "loading" });
29
32
  const [mode, setModeState] = useState(BROWSE);
30
33
  // Mirrors the mode synchronously so later keystrokes and the shell never see stale state.
@@ -41,7 +44,7 @@ export function InstallationsView({ operations, environment, onCapturingChange,
41
44
  const reload = useCallback(async () => {
42
45
  const generation = ++loadGeneration.current;
43
46
  try {
44
- const data = await loadInstallations(operations, env, apps);
47
+ const data = await loadInstallations(operations, env, apps, packages);
45
48
  if (mounted.current && generation === loadGeneration.current)
46
49
  setState({ status: "ready", data });
47
50
  }
@@ -49,7 +52,7 @@ export function InstallationsView({ operations, environment, onCapturingChange,
49
52
  if (mounted.current && generation === loadGeneration.current)
50
53
  setState({ status: "error", message: errorText(error) });
51
54
  }
52
- }, [operations, env, apps, mounted]);
55
+ }, [operations, env, apps, packages, mounted]);
53
56
  useEffect(() => {
54
57
  void reload();
55
58
  }, [reload]);
@@ -125,6 +128,7 @@ export function InstallationsView({ operations, environment, onCapturingChange,
125
128
  marks,
126
129
  data,
127
130
  manage,
131
+ packages,
128
132
  startAdoption: (group) => void startAdoption(adoption, group),
129
133
  setMessage,
130
134
  startApps: (entries, input) => void startAppAction({ apps, manage }, entries, input),
@@ -133,6 +137,8 @@ export function InstallationsView({ operations, environment, onCapturingChange,
133
137
  const current = modeRef.current;
134
138
  if (current.kind === "browse")
135
139
  handleBrowseKey(browse, input, key);
140
+ else if (isPackageMode(current))
141
+ handlePackageKey({ packages, manage }, current, input, key);
136
142
  else if (isAppMode(current))
137
143
  handleAppKey({ apps, manage }, current, input, key);
138
144
  else if (!handleManageKey(manage, current, input, key))
@@ -7,6 +7,8 @@ import { inspectSkillRemovals, userGlobalRemovalOptions } from "../skill-removal
7
7
  import { scanUserGlobalSkillInventory, } from "../user-global-skill-inventory.js";
8
8
  import { errorText } from "./batch.js";
9
9
  import { prepareInstall } from "./catalog-installs.js";
10
+ import { packageOperationsOf } from "./environment.js";
11
+ import { packageRow } from "./package-rows.js";
10
12
  /** Hosts in display order; the Claude root serves Claude Code, the shared root serves the others. */
11
13
  const HOST_DISPLAY_ORDER = ["claude", "pi", "codex", "opencode"];
12
14
  const ROOT_HOSTS = {
@@ -71,7 +73,7 @@ async function toRow(scope, selection, inspectionOptions) {
71
73
  };
72
74
  }
73
75
  /** Loads both scopes and the unmanaged user-global inventory the CLI's `update check` uses. */
74
- export async function loadInstallations(operations, environment, appOperations = createAppOperations({ homeDirectory: homeOf(environment), ...environment.appEnvironment })) {
76
+ export async function loadInstallations(operations, environment, appOperations = createAppOperations({ homeDirectory: homeOf(environment), ...environment.appEnvironment }), packageOperations = packageOperationsOf(environment)) {
75
77
  const home = homeOf(environment);
76
78
  let apps = [];
77
79
  let appsError;
@@ -88,6 +90,14 @@ export async function loadInstallations(operations, environment, appOperations =
88
90
  catch (error) {
89
91
  appsError = errorText(error);
90
92
  }
93
+ let packages = [];
94
+ let packagesError;
95
+ try {
96
+ packages = await Promise.all((await packageOperations.list()).map(async (record) => packageRow(record, await packageOperations.inspect(record.selection))));
97
+ }
98
+ catch (error) {
99
+ packagesError = errorText(error);
100
+ }
91
101
  const globalRecords = operations.listUserGlobalInstallations
92
102
  ? await operations.listUserGlobalInstallations()
93
103
  : undefined;
@@ -121,7 +131,7 @@ export async function loadInstallations(operations, environment, appOperations =
121
131
  }))
122
132
  : [];
123
133
  const sources = await operations.listSources().catch(() => []);
124
- return { unmanagedWarnings, global, project, ...(projectError === undefined ? {} : { projectError }), unmanaged, sources, apps, ...(appsError === undefined ? {} : { appsError }) };
134
+ return { unmanagedWarnings, global, project, ...(projectError === undefined ? {} : { projectError }), unmanaged, sources, apps, ...(appsError === undefined ? {} : { appsError }), packages, ...(packagesError === undefined ? {} : { packagesError }) };
125
135
  }
126
136
  /** Source id of an installation's Source, when that Source is still registered. */
127
137
  export function sourceIdOf(selection, sources) {
@@ -1,4 +1,5 @@
1
1
  import { APP_KINDS } from "./app-actions.js";
2
+ import { PACKAGE_KINDS } from "./package-actions.js";
2
3
  import { globalRecord, missingHosts, prepareHostAddition, prepareHostRemoval, prepareUninstall, runHostAddition, runHostRemoval, runUninstall, UNINSTALL_REFUSALS, } from "./catalog-installs.js";
3
4
  import { errorText } from "./batch.js";
4
5
  import { BULK_KINDS, handleBulkKey } from "./bulk-actions.js";
@@ -13,6 +14,7 @@ const MANAGE_KINDS = new Set([
13
14
  "confirm-host-remove",
14
15
  "confirm-uninstall",
15
16
  ...APP_KINDS,
17
+ ...PACKAGE_KINDS,
16
18
  ...UNMANAGED_KINDS,
17
19
  ...BULK_KINDS,
18
20
  "busy",
@@ -1,6 +1,7 @@
1
1
  import { jsxs as _jsxs, Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { AppPanel } from "./app-actions.js";
4
+ import { PackagePanel } from "./package-actions.js";
4
5
  import { ChecklistLines, HostChecklist, PreviewConfirm, PreviewLines } from "./panel-parts.js";
5
6
  import { theme } from "./theme.js";
6
7
  const plural = (n) => `${n} ${n === 1 ? "item" : "items"}`;
@@ -32,6 +33,10 @@ export function ManagePanel({ mode }) {
32
33
  case "app-confirm":
33
34
  case "app-manual":
34
35
  return _jsx(AppPanel, { mode: mode });
36
+ case "package-approve":
37
+ case "package-confirm":
38
+ case "package-forget":
39
+ return _jsx(PackagePanel, { mode: mode });
35
40
  case "remove-scope":
36
41
  return _jsxs(Text, { children: ["Uninstall ", mode.skill.name, " (hosts: ", mode.hosts.join(", "), "): 1 all hosts 2 choose hosts (Esc cancel)"] });
37
42
  case "hosts-add":