@iseoane/agent-depot 0.6.9 → 0.6.10

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.
@@ -0,0 +1,58 @@
1
+ import { stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { assertNoSymlinkPath, SymlinkPathError } from "./path-safety.js";
4
+ import { HOST_PROJECT_DIRECTORIES } from "./project-manifest.js";
5
+ import { CliUsageError } from "./usage-error.js";
6
+ /** `.agents` is the canonical Skill location the Hosts share, so it also marks a project that already uses Skills. */
7
+ const PROJECT_DIRECTORIES = [".agents", ...Object.values(HOST_PROJECT_DIRECTORIES)];
8
+ /** The warnings are advisory, so an entry that cannot be read counts as absent. */
9
+ async function exists(candidate) {
10
+ return stat(candidate).then(() => true, () => false);
11
+ }
12
+ /**
13
+ * Nearest ancestor-or-self holding a `.git` entry. A file marks a linked
14
+ * worktree or submodule, so any `.git` entry counts. Walking the filesystem
15
+ * avoids spawning `git` and cannot reach the network.
16
+ */
17
+ async function findGitTopLevel(start) {
18
+ for (let current = start;; current = path.dirname(current)) {
19
+ if (await exists(path.join(current, ".git")))
20
+ return current;
21
+ if (path.dirname(current) === current)
22
+ return undefined;
23
+ }
24
+ }
25
+ /**
26
+ * The lines every project-scope preview starts with: the project root the
27
+ * Skills are installed into, then non-blocking warnings about a root that is
28
+ * probably not the intended one. A brand-new project is legitimate, so nothing
29
+ * here refuses.
30
+ */
31
+ export async function describeProjectTarget(projectRoot, manifestPath) {
32
+ const lines = [`Project: ${projectRoot}`];
33
+ const gitTopLevel = await findGitTopLevel(projectRoot);
34
+ if (gitTopLevel !== undefined && path.relative(gitTopLevel, projectRoot) !== "") {
35
+ lines.push(` WARNING: this directory is inside the Git repository at ${gitTopLevel} but is not its top level; Skills will be installed here, not at the repository root`);
36
+ }
37
+ const markers = [manifestPath, ...PROJECT_DIRECTORIES.map((directory) => path.join(projectRoot, directory))];
38
+ if (!(await Promise.all(markers.map(exists))).some(Boolean)) {
39
+ lines.push(` WARNING: this directory has no project manifest (${path.basename(manifestPath)}) and none of ${PROJECT_DIRECTORIES.join(", ")}; confirm it is the intended project directory`);
40
+ }
41
+ return lines;
42
+ }
43
+ /** Resolves a `--project` value against the working directory; it must be an existing directory without symbolic links. */
44
+ export async function resolveProjectRootOption(value) {
45
+ const root = path.resolve(value);
46
+ const information = await stat(root).catch((error) => {
47
+ if (typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
48
+ throw new CliUsageError(`--project ${JSON.stringify(value)} does not exist`);
49
+ }
50
+ throw error;
51
+ });
52
+ if (!information.isDirectory())
53
+ throw new CliUsageError(`--project ${JSON.stringify(value)} is not a directory`);
54
+ await assertNoSymlinkPath(root, `--project ${JSON.stringify(value)}`).catch((error) => {
55
+ throw error instanceof SymlinkPathError ? new CliUsageError(error.message) : error;
56
+ });
57
+ return root;
58
+ }
@@ -13,6 +13,21 @@ export const EXCLUDED_LIFECYCLE_SEGMENTS = Object.freeze([
13
13
  "retired",
14
14
  ]);
15
15
  const excludedLifecycleSegments = new Set(EXCLUDED_LIFECYCLE_SEGMENTS);
16
+ /**
17
+ * Directories where other coding agents keep generated copies of a Source's
18
+ * Skills. Agent Depot manages Claude, Codex, Pi and OpenCode only, so a Skill
19
+ * found below one of these is a mirror of the real one, never a candidate.
20
+ * `.claude`, `.agents`, `.codex`, `.pi` and `.opencode` are deliberately absent.
21
+ */
22
+ const UNMANAGED_HOST_MIRROR_SEGMENTS = Object.freeze([
23
+ ".cursor", "cursor-plugin", ".windsurf", ".gemini", ".github", ".kiro", ".trae", ".trae-cn", ".devin", ".dsh",
24
+ ".hermes", ".veto", ".cline", ".roo", ".continue", ".augment", ".goose", ".amazonq", ".junie", ".kilocode",
25
+ ".qwen", ".agent", ".factory", ".crush", ".openhands", ".codebuddy", ".qoder", ".zed",
26
+ ]);
27
+ /** Test and fixture trees hold sample Skills; only ancestors count, so a Skill directory may be named `test`. */
28
+ const TEST_TREE_SEGMENTS = Object.freeze(["tests", "test", "__tests__", "fixtures", "__fixtures__"]);
29
+ const unmanagedMirrorSegments = new Set(UNMANAGED_HOST_MIRROR_SEGMENTS);
30
+ const testTreeSegments = new Set(TEST_TREE_SEGMENTS);
16
31
  /**
17
32
  * The package-owned root used for built-in skills. A missing root is an empty
18
33
  * built-in Source, which lets packages ship no built-in skills yet.
@@ -121,6 +136,7 @@ export function skillTreeBaseline(files) {
121
136
  export async function discoverSkillsFromSources(sources, contentAccess) {
122
137
  const candidates = [];
123
138
  for (const source of sources) {
139
+ const found = [];
124
140
  const files = [...await contentAccess.readSnapshot(source)].sort((left, right) => left.path.localeCompare(right.path));
125
141
  for (const file of files) {
126
142
  const normalizedPath = normalizeSourcePath(file.path);
@@ -129,23 +145,87 @@ export async function discoverSkillsFromSources(sources, contentAccess) {
129
145
  }
130
146
  const directoryPath = path.posix.dirname(normalizedPath);
131
147
  const segments = directoryPath === "." ? [] : directoryPath.split("/");
132
- if (segments.some((segment) => excludedLifecycleSegments.has(segment))) {
148
+ if (segments.some((segment) => excludedLifecycleSegments.has(segment) || unmanagedMirrorSegments.has(segment))
149
+ || segments.slice(0, -1).some((segment) => testTreeSegments.has(segment))) {
133
150
  continue;
134
151
  }
135
152
  const metadata = parseSkillFrontmatter(file.content);
136
153
  if (!metadata) {
137
154
  continue;
138
155
  }
139
- candidates.push({
140
- sourceId: source.id,
141
- path: directoryPath,
142
- name: metadata.name,
143
- description: metadata.description,
156
+ found.push({
157
+ candidate: { sourceId: source.id, path: directoryPath, name: metadata.name, description: metadata.description },
158
+ digest: createHash("sha256").update(file.content, "utf8").digest("hex"),
144
159
  });
145
160
  }
161
+ candidates.push(...await collapseIdenticalSkills(source, found, files, contentAccess));
146
162
  }
147
163
  return Object.freeze(candidates);
148
164
  }
165
+ /**
166
+ * One candidate per Skill name and whole-directory digest within a Source,
167
+ * because a repository often repeats the same Skill under several directories.
168
+ * Only candidates already sharing a name and SKILL.md digest are compared by
169
+ * their full tree. The canonical copy is the one outside dot-directories, then
170
+ * the shallowest, then the first by path, so the choice never depends on
171
+ * snapshot order. Same-named Skills with different content all stay. Survivors
172
+ * keep their path order.
173
+ */
174
+ async function collapseIdenticalSkills(source, found, snapshot, contentAccess) {
175
+ const sameManifest = new Map();
176
+ for (const { candidate, digest } of found) {
177
+ const key = `${candidate.name}|${digest}`;
178
+ sameManifest.set(key, (sameManifest.get(key) ?? 0) + 1);
179
+ }
180
+ const best = new Map();
181
+ for (const { candidate, digest } of found) {
182
+ const manifestKey = `${candidate.name}|${digest}`;
183
+ const key = sameManifest.get(manifestKey) > 1
184
+ ? `${manifestKey}|${await skillDirectoryDigest(source, candidate.path, snapshot, contentAccess)}`
185
+ : manifestKey;
186
+ const current = best.get(key);
187
+ if (current === undefined || compareCanonical(candidate.path, current.path) < 0) {
188
+ best.set(key, candidate);
189
+ }
190
+ }
191
+ const survivors = new Set(best.values());
192
+ return found.filter(({ candidate }) => survivors.has(candidate)).map(({ candidate }) => candidate);
193
+ }
194
+ /**
195
+ * Digest of every file of a Skill directory, by relative path and bytes. Reads
196
+ * the whole tree from the Source when the access supports it, otherwise uses the
197
+ * snapshot files. A tree that cannot be read digests to its own path, so it is
198
+ * never collapsed into another.
199
+ */
200
+ async function skillDirectoryDigest(source, directory, snapshot, contentAccess) {
201
+ const prefix = `${directory}/`;
202
+ try {
203
+ const files = contentAccess.readSkillTree
204
+ ? [...await contentAccess.readSkillTree(source, directory)]
205
+ : snapshot.filter((file) => file.path.startsWith(prefix)).map((file) => ({
206
+ path: file.path,
207
+ content: Buffer.from(file.content, "utf8"),
208
+ executable: false,
209
+ }));
210
+ return skillTreeBaseline(files.map((file) => ({ ...file, path: file.path.startsWith(prefix) ? file.path.slice(prefix.length) : file.path }))).digest;
211
+ }
212
+ catch {
213
+ return `unreadable:${directory}`;
214
+ }
215
+ }
216
+ function compareCanonical(left, right) {
217
+ const rank = (candidate) => {
218
+ const segments = candidate.split("/");
219
+ return [segments.some((segment) => segment.startsWith(".")) ? 1 : 0, segments.length];
220
+ };
221
+ const [leftHidden, leftDepth] = rank(left);
222
+ const [rightHidden, rightDepth] = rank(right);
223
+ if (leftHidden !== rightHidden)
224
+ return leftHidden - rightHidden;
225
+ if (leftDepth !== rightDepth)
226
+ return leftDepth - rightDepth;
227
+ return left < right ? -1 : left > right ? 1 : 0;
228
+ }
149
229
  /** Collects scalar frontmatter values up to the closing delimiter; undefined when malformed or unclosed. */
150
230
  function readFrontmatterValues(lines) {
151
231
  const values = new Map();
@@ -2,8 +2,9 @@ import path from "node:path";
2
2
  import { assertSkillNotAppOwned } from "./app-owned-skills.js";
3
3
  import { assertSkillNotPackageOwned, loadPackageOwnedSkillFilter, } from "./package-owned-skills.js";
4
4
  import { pathsOverlap } from "./path-safety.js";
5
+ import { describeProjectTarget } from "./project-target.js";
5
6
  import { inspectProjectSkillInstallation, installProjectSkillTransaction, validateProjectSkillInstallationPreview, } from "./project-installation.js";
6
- import { AGENT_DEPOT_PACKAGE_VERSION, parseProjectManifest, } from "./project-manifest.js";
7
+ import { AGENT_DEPOT_PACKAGE_VERSION, defaultProjectManifestPath, parseProjectManifest, } from "./project-manifest.js";
7
8
  import { createSourceContentAccess } from "./skill-discovery.js";
8
9
  import { relativeProjectPath, selectionWithInstallation } from "./skill-update.js";
9
10
  import { executeSourceInstallationMethod, parseSourceInstallationMethod, resolveProjectSource, SourceNotFoundError, } from "./sources.js";
@@ -12,7 +13,7 @@ import { CliUsageError } from "./usage-error.js";
12
13
  * Shared install flow for one Skill selection (project or user-global):
13
14
  * resolve, inspect, preview, then execute. Used by the CLI and the TUI.
14
15
  */
15
- const INSTALL_USAGE = "Usage: agent-depot install --scope <project|user-global> --source <id> --skill <path> --host <host>... --version <latest|version> [--ref <git-ref>] [--method <json>] --portable-v1 [--overwrite --yes] [--confirm-additional-host]";
16
+ const INSTALL_USAGE = "Usage: agent-depot install --scope <project|user-global> --source <id> --skill <path> --host <host>... --version <latest|version> [--ref <git-ref>] [--method <json>] --portable-v1 [--overwrite --yes] [--confirm-additional-host] [--project <path>]";
16
17
  export async function selectInstallSource(options, operations) {
17
18
  const source = await findSource(operations, options.sourceId);
18
19
  const projectSource = projectSourceFromSource(source, options.ref, options.version);
@@ -314,12 +315,17 @@ export async function planSingleInstall(request, context) {
314
315
  }
315
316
  const { resolvedSource, resolved, installationInspection } = await resolveInstallInspection(selection, source, projectSource, context.root, request, context.operations, context.environment, context.sourceAccess);
316
317
  await assertInstallNotAppOwned(request, context, installationInspection);
317
- await assertInstallNotPackageOwned(selection, resolvedSource, context);
318
- return { request, context, selection, source, resolvedSource, resolved, inspection: installationInspection };
318
+ await assertInstallNotPackageOwned(request, selection, resolvedSource, context);
319
+ const projectTarget = request.scope === "project"
320
+ ? await describeProjectTarget(context.root, context.manifestPath ?? defaultProjectManifestPath(context.root))
321
+ : [];
322
+ return { request, context, selection, source, resolvedSource, resolved, inspection: installationInspection, projectTarget };
319
323
  }
320
324
  /** Prints the preview the user confirms before {@link executeSingleInstall}. */
321
325
  export function outputSingleInstallPreview(plan, output) {
322
- const { request, context, selection, source, resolved, inspection } = plan;
326
+ const { request, context, selection, source, resolved, inspection, projectTarget } = plan;
327
+ for (const line of projectTarget)
328
+ output(line);
323
329
  outputInstallPreview(selection, source, context.root, output, resolved.files, resolved.method, request.scope, inspection, request.overwrite, request.confirmed);
324
330
  }
325
331
  /**
@@ -329,7 +335,7 @@ export function outputSingleInstallPreview(plan, output) {
329
335
  export async function executeSingleInstall(plan, persist, output) {
330
336
  const { request, context, selection, resolvedSource, resolved, inspection } = plan;
331
337
  await assertInstallNotAppOwned(request, context, inspection);
332
- await assertInstallNotPackageOwned(selection, resolvedSource, context);
338
+ await assertInstallNotPackageOwned(request, selection, resolvedSource, context);
333
339
  const protectedPaths = assertNoManagedInstallationOverlap(context.existing, selection, inspection, context.root, request.overwrite);
334
340
  rejectInstallationCollision(inspection, request.overwrite);
335
341
  const transaction = await installOne(selection, resolvedSource, context.sourceAccess, context.root, context.environment, resolved.method, request.portableV1, resolved.files, resolved.resolvedVersion, request.confirmAdditionalHostExposure, request.overwrite, inspection, protectedPaths);
@@ -375,8 +381,14 @@ async function assertInstallNotAppOwned(request, context, inspection) {
375
381
  await assertSkillNotAppOwned(path.basename(location.path), { ...context.environment, homeDirectory: context.root });
376
382
  }
377
383
  }
378
- /** Reloads bundle ownership from the selected Source and refuses its own Skill paths. */
379
- async function assertInstallNotPackageOwned(selection, source, context) {
384
+ /**
385
+ * Reloads bundle ownership from the selected Source and refuses its own Skill paths.
386
+ * Package state lives under the user's home, so a project install has nothing to
387
+ * check and must not read or lock state derived from the project root.
388
+ */
389
+ async function assertInstallNotPackageOwned(request, selection, source, context) {
390
+ if (request.scope !== "user-global")
391
+ return;
380
392
  const owned = await loadPackageOwnedSkillFilter({
381
393
  sources: [source],
382
394
  homeDirectory: context.root,
@@ -5,7 +5,10 @@ import { errorText, runBatch } from "./batch.js";
5
5
  import { hostChecklistKey, movePreviewCursor } from "./mode-keys.js";
6
6
  import { BorderPanel, HostChecklist, PreviewConfirm, Working } from "./panel-parts.js";
7
7
  import { previewViewport } from "./preview-window.js";
8
+ import { theme } from "./theme.js";
8
9
  import { operationStart } from "./working-model.js";
10
+ /** Finished items shown under a running batch; older ones are counted. */
11
+ const MAX_DONE_LINES = 3;
9
12
  export const APP_KINDS = ["app-busy", "app-approve", "app-hosts", "app-confirm", "app-manual"];
10
13
  /** Narrows the view's mode without asserting a partial mode is an App mode. */
11
14
  export function isAppMode(mode) {
@@ -61,15 +64,21 @@ function appPreviewLines(mode) {
61
64
  ];
62
65
  }
63
66
  /** The busy mode of an App step; `previous` is the busy mode this step continues, so the clock keeps running. */
64
- function appBusy(label, previous) {
65
- return { kind: "app-busy", label, startedAt: operationStart(previous) };
67
+ function appBusy(label, previous, done) {
68
+ return { kind: "app-busy", label, startedAt: operationStart(previous), ...(done?.length ? { done } : {}) };
69
+ }
70
+ /** `Running App step: name (2/5)…`; the position is left out for a batch of one. */
71
+ function stepLabel(verb, entry, finished, total) {
72
+ return `${verb} App step: ${appName(entry)}${total > 1 ? ` (${finished + 1}/${total})` : ""}…`;
66
73
  }
67
74
  async function prepareNext(context, requests, results = [], failures = 0, previous) {
68
75
  const { manage, apps } = context;
69
- const running = appBusy("Preparing App step…", previous);
70
- manage.setMode(running);
71
76
  // Independent planning failures never stop the remaining marked Apps.
72
77
  const [request, ...remaining] = requests;
78
+ const running = appBusy(request ? stepLabel("Preparing", request.entry, results.length, results.length + requests.length) : "Finishing App steps…", previous,
79
+ // Once the batch is over its results are the Result panel, not a busy line.
80
+ request ? results : undefined);
81
+ manage.setMode(running);
73
82
  if (!request) {
74
83
  await manage.finish({ kind: failures > 0 ? "error" : "ok", text: results.join("\n") });
75
84
  return;
@@ -124,7 +133,7 @@ export async function startAppAction(context, entries, input) {
124
133
  }
125
134
  async function execute(context, mode) {
126
135
  const { manage, apps } = context;
127
- const running = appBusy("Running App step…");
136
+ const running = appBusy(stepLabel("Running", mode.plan.entry, mode.results.length, mode.results.length + 1 + mode.remaining.length), undefined, mode.results);
128
137
  manage.setMode(running);
129
138
  let manualRequired = false;
130
139
  const [outcome] = await runBatch([mode.plan], async (plan) => {
@@ -201,8 +210,10 @@ export function handleAppKey(context, mode, input, key) {
201
210
  /** Renders App prompts using the existing checklist and preview-confirm panels. */
202
211
  export function AppPanel({ mode }) {
203
212
  const { rows, columns } = useWindowSize();
204
- if (mode.kind === "app-busy")
205
- return _jsx(BorderPanel, { title: "App lifecycle", children: _jsx(Working, { label: mode.label, since: mode.startedAt }) });
213
+ if (mode.kind === "app-busy") {
214
+ const done = mode.done ?? [];
215
+ return (_jsxs(BorderPanel, { title: "App lifecycle", children: [_jsx(Working, { label: mode.label, since: mode.startedAt }), done.length > MAX_DONE_LINES ? _jsxs(Text, { color: theme.muted, children: [done.length - MAX_DONE_LINES, " earlier finished"] }) : null, done.slice(-MAX_DONE_LINES).map((line, index) => _jsx(Text, { color: theme.muted, children: line }, index))] }));
216
+ }
206
217
  if (mode.kind === "app-hosts") {
207
218
  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 })] }));
208
219
  }
@@ -10,6 +10,7 @@ import { ProfileView } from "./profile-view.js";
10
10
  import { SourcesView, sourceRowHints } from "./sources-view.js";
11
11
  import { theme } from "./theme.js";
12
12
  import { UpdatesView } from "./updates-view.js";
13
+ import { createViewRetention, invalidateRetention } from "./view-retention.js";
13
14
  /** Sources derives its own hints from the selected row, so it is excluded here. */
14
15
  function hintsOf(view, catalogFocusedOnSource) {
15
16
  switch (view) {
@@ -33,14 +34,28 @@ export function App({ operations, environment, onExit }) {
33
34
  const [capturing, setCapturing] = useState(false);
34
35
  // Updated synchronously by the views so keys in the same burst never see a stale capturing state.
35
36
  const capturingRef = useRef(false);
37
+ const [view, setView] = useState("sources");
38
+ // Catalog and Installations unmount on a tab change; this keeps what they loaded, for display only.
39
+ const [retention] = useState(createViewRetention);
40
+ // Sources, Updates and Profile may change what the retained data describes. Any mode they capture
41
+ // keys for (add, refresh, approve, confirm, import) can, so leaving after one drops the retention.
42
+ const viewRef = useRef(view);
43
+ const touched = useRef(false);
36
44
  const onCapturingChange = useCallback((value) => {
37
45
  capturingRef.current = value;
46
+ if (value && viewRef.current !== "catalog" && viewRef.current !== "installations")
47
+ touched.current = true;
38
48
  setCapturing(value);
39
49
  }, []);
40
- const [view, setView] = useState("sources");
41
50
  const [catalogSourceId, setCatalogSourceId] = useState();
42
51
  const [sourcesHints, setSourcesHints] = useState(sourceRowHints(undefined));
43
- const showView = (next) => setView(next);
52
+ const showView = (next) => {
53
+ if (touched.current)
54
+ invalidateRetention(retention);
55
+ touched.current = false;
56
+ viewRef.current = next;
57
+ setView(next);
58
+ };
44
59
  useKeys((input, key) => {
45
60
  if (capturingRef.current)
46
61
  return;
@@ -66,8 +81,8 @@ export function App({ operations, environment, onExit }) {
66
81
  });
67
82
  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) => {
68
83
  setCatalogSourceId(source.id);
69
- setView("catalog");
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
84
+ showView("catalog");
85
+ } })) : view === "installations" ? (_jsx(InstallationsView, { operations: operations, environment: environment, onCapturingChange: onCapturingChange, retention: retention })) : 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, retention: retention })) }), _jsx(Text, { color: theme.muted, children: capturing
71
86
  ? columns < 64 ? "Enter submit · Esc cancel" : "Enter submit · Esc cancel · y/n confirm · space toggle"
72
87
  : view === "sources" ? sourcesHints : hintsOf(view, catalogSourceId !== undefined) })] }));
73
88
  }
@@ -3,9 +3,10 @@ export const errorText = (error) => (error instanceof Error ? error.message : St
3
3
  * Applies the items one after another. A failure is reported for that item and
4
4
  * the rest still run, so the outcomes always cover every item in order.
5
5
  */
6
- export async function runBatch(items, run, describeFailure) {
6
+ export async function runBatch(items, run, describeFailure, onStart) {
7
7
  const outcomes = [];
8
8
  for (const item of items) {
9
+ onStart?.(item, outcomes.length + 1, items.length, outcomes);
9
10
  try {
10
11
  outcomes.push({ item, ok: true, line: await run(item) });
11
12
  }
@@ -78,8 +78,16 @@ function resultOf(outcomes, skipped) {
78
78
  applied: outcomes.filter((outcome) => outcome.ok).map((outcome) => outcome.item.id),
79
79
  };
80
80
  }
81
+ /** `Removing name (2/5)…` for each item as it starts. */
82
+ function startOf(verb, progress) {
83
+ if (!progress)
84
+ return undefined;
85
+ return (item, position, total, finished) => {
86
+ progress(`${verb} ${item.label} (${position}/${total})…`, finished.map(outcome => outcome.line));
87
+ };
88
+ }
81
89
  /** Execute step: each item is rechecked and applied on its own against the records the previous items were expected to leave. */
82
- export async function runBulkUninstall(plan, operations, env) {
90
+ export async function runBulkUninstall(plan, operations, env, progress) {
83
91
  let expected = plan.installations;
84
92
  const outcomes = await runBatch(plan.items, async (item) => {
85
93
  await assertNoDrift(operations, expected);
@@ -95,7 +103,7 @@ export async function runBulkUninstall(plan, operations, env) {
95
103
  return item.prepared.plan.inspections.every((inspection) => inspection.recordOnly)
96
104
  ? `Forgot missing installation record for ${item.label}; no files were removed`
97
105
  : `Removed ${item.label}`;
98
- }, (item, error) => (item.kind === "unmanaged" ? errorText(error) : `Failed ${item.label}: ${errorText(error)}`));
106
+ }, (item, error) => (item.kind === "unmanaged" ? errorText(error) : `Failed ${item.label}: ${errorText(error)}`), startOf("Removing", progress));
99
107
  return resultOf(outcomes, plan.skipped);
100
108
  }
101
109
  /** Managed user-global leaves that can gain hosts, and the leaves that cannot with their reasons. */
@@ -146,7 +154,7 @@ export async function prepareBulkHostAddition(context, targets, alreadySkipped,
146
154
  return { items, skipped, installations };
147
155
  }
148
156
  /** Execute step: the single exposure confirmation covers every item; each one is rechecked on its own. */
149
- export async function runBulkHostAddition(plan, operations, env) {
157
+ export async function runBulkHostAddition(plan, operations, env, progress) {
150
158
  let expected = plan.installations;
151
159
  const outcomes = await runBatch(plan.items, async (item) => {
152
160
  await assertNoDrift(operations, expected);
@@ -155,7 +163,7 @@ export async function runBulkHostAddition(plan, operations, env) {
155
163
  const { selection, updated } = item.prepared.plan;
156
164
  expected = expected.map((record) => (sameRecord(record, selection) ? updated : record));
157
165
  return line;
158
- }, (item, error) => `Failed ${item.label}: ${errorText(error)}`);
166
+ }, (item, error) => `Failed ${item.label}: ${errorText(error)}`, startOf("Changing hosts of", progress));
159
167
  return resultOf(outcomes, plan.skipped);
160
168
  }
161
169
  function cancel(context, text) {
@@ -202,10 +210,15 @@ function prepareHosts(context, mode) {
202
210
  return preparePlan(context, `Preparing host change of ${count(mode.targets.length, "item")}…`, () => prepareBulkHostAddition(context, mode.targets, mode.skipped, mode.selected), (plan) => ({ kind: "confirm-bulk-hosts", plan }));
203
211
  }
204
212
  async function apply(context, label, action) {
205
- context.setMode(busy(label));
213
+ const started = busy(label);
214
+ context.setMode(started);
206
215
  let result;
207
216
  try {
208
- result = await action();
217
+ // Each item names itself, but the clock of the whole batch keeps running.
218
+ result = await action((itemLabel, done) => {
219
+ if (context.isMounted())
220
+ context.setMode({ ...busy(itemLabel, started), done });
221
+ });
209
222
  }
210
223
  catch (error) {
211
224
  result = { message: { kind: "error", text: errorText(error) }, applied: [] };
@@ -229,7 +242,7 @@ export function handleBulkKey(context, mode, input, key) {
229
242
  }
230
243
  case "confirm-bulk-uninstall":
231
244
  answerYesNo(input, key, () => {
232
- void apply(context, `Removing ${count(current.plan.items.length, "item")}…`, () => runBulkUninstall(current.plan, operations, env));
245
+ void apply(context, `Removing ${count(current.plan.items.length, "item")}…`, (progress) => runBulkUninstall(current.plan, operations, env, progress));
233
246
  }, () => cancel(context, "Uninstall cancelled"));
234
247
  return true;
235
248
  case "confirm-bulk-hosts":
@@ -237,7 +250,7 @@ export function handleBulkKey(context, mode, input, key) {
237
250
  return true;
238
251
  case "confirm-bulk-exposure":
239
252
  answerYesNo(input, key, () => {
240
- void apply(context, `Changing hosts of ${count(current.plan.items.length, "item")}…`, () => runBulkHostAddition(current.plan, operations, env));
253
+ void apply(context, `Changing hosts of ${count(current.plan.items.length, "item")}…`, (progress) => runBulkHostAddition(current.plan, operations, env, progress));
241
254
  }, () => cancel(context, "Add hosts cancelled"));
242
255
  return true;
243
256
  default:
@@ -83,17 +83,18 @@ export async function prepareInstall(operations, environment, skill, choice) {
83
83
  root: path.resolve(environment.projectRoot ?? process.cwd()),
84
84
  existing: (await manifestStore.load()).skills,
85
85
  recordedIn: manifestStore.path,
86
+ manifestPath: manifestStore.path,
86
87
  });
87
88
  }
88
89
  const lines = [];
89
90
  outputSingleInstallPreview(plan, (line) => lines.push(line));
90
91
  // The TUI confirms interactively, so the CLI's --yes wording does not apply.
91
- const preview = lines.filter((line) => !line.startsWith(" confirmation:"));
92
+ const preview = lines.filter((line) => !plan.projectTarget.includes(line) && !line.startsWith(" confirmation:"));
92
93
  const additionalHostExposure = needsAdditionalHostExposure(plan.inspection, choice.hosts);
93
94
  if (additionalHostExposure) {
94
95
  preview.push(" ADDITIONAL HOST EXPOSURE: an identical Skill already exists; installing adds the missing Host location (equivalent to --confirm-additional-host) and needs a separate confirmation");
95
96
  }
96
- return { plan, manifestStore, preview, runsExternalCommand: plan.resolved.method !== undefined, additionalHostExposure };
97
+ return { plan, manifestStore, projectTarget: plan.projectTarget, preview, runsExternalCommand: plan.resolved.method !== undefined, additionalHostExposure };
97
98
  }
98
99
  /** Execute step: installs and persists the record; returns the result lines. */
99
100
  export async function runInstall(prepared, operations, confirmAdditionalHostExposure = false) {
@@ -19,7 +19,7 @@ export function CatalogRowLine({ row, selected, marked, installed }) {
19
19
  const isGroup = data.kind === "source" || data.kind === "group";
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
- ? _jsxs(_Fragment, { children: [data.skill.name, _jsxs(Text, { color: theme.marker, children: [" ", "[", packageOwnerBadge(ownedBy), "]"] }), " ", truncateDescription(data.skill.description)] })
22
+ ? _jsxs(_Fragment, { children: [data.skill.name, _jsxs(Text, { color: theme.marker, children: [" ", "[", packageOwnerBadge(ownedBy), "]"] }), " ", truncateDescription(data.skill.description), data.variant === true ? ` (${data.skill.path})` : ""] })
23
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] }));
@@ -47,7 +47,8 @@ export function ActionPanel({ mode }) {
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(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)), "?"] })] }));
50
+ const projectTarget = ready[0]?.prepared?.projectTarget ?? [];
51
+ return (_jsxs(BorderPanel, { title: `Skill install preview · ${ready.length} ready`, children: [projectTarget.length === 0 ? null : _jsx(PreviewLines, { lines: projectTarget }), 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
52
  }
52
53
  case "confirm-exposure": {
53
54
  const names = mode.items.filter((item) => item.prepared?.additionalHostExposure).map((item) => item.skill.name);
@@ -6,15 +6,22 @@ import { loadInstalledSkills, NO_INSTALLED } from "./catalog-installs.js";
6
6
  import { sourceLabel } from "./catalog-tree.js";
7
7
  import { packageOperationsOf } from "./environment.js";
8
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) {
10
- const [state, setState] = useState({ status: "loading" });
9
+ export function useCatalogSkills(operations, sourceId, all, env, reloadKey = 0, retention) {
10
+ const key = `${sourceId ?? ""}|${all}`;
11
+ /** The last result of this scope, shown while it is discovered again. */
12
+ const retained = () => {
13
+ const kept = retention?.catalogKey === key ? retention.catalogSkills : undefined;
14
+ return kept ? { ...kept, stale: true } : { status: "loading" };
15
+ };
16
+ const [state, setState] = useState(retained);
11
17
  useEffect(() => {
12
18
  let cancelled = false;
19
+ const generation = retention?.generation;
13
20
  if (!operations.discoverSkills) {
14
21
  setState({ status: "error", message: "Discovery is not supported by the configured operations" });
15
22
  return;
16
23
  }
17
- setState({ status: "loading" });
24
+ setState(retained());
18
25
  void (async () => {
19
26
  try {
20
27
  const sources = await operations.listSources();
@@ -50,6 +57,9 @@ export function useCatalogSkills(operations, sourceId, all, env, reloadKey = 0)
50
57
  try {
51
58
  const projectSource = projectSourceOf(source);
52
59
  for (const descriptor of await packages.discover([id])) {
60
+ // A Package Agent Depot cannot install is not a choice; its warning stays in `package inspect`.
61
+ if (!descriptor.installable)
62
+ continue;
53
63
  bundles.push({ sourceId: id, selection: packageSelectionFor(descriptor, projectSource), descriptor });
54
64
  discovered.push({ source: projectSource, descriptor });
55
65
  }
@@ -73,8 +83,14 @@ export function useCatalogSkills(operations, sourceId, all, env, reloadKey = 0)
73
83
  const detail = error instanceof Error ? error.message : String(error);
74
84
  warnings.push(`Package ownership unavailable: ${detail}`);
75
85
  }
86
+ const ready = { status: "ready", skills, bundles, activeBundles, warnings, sources };
87
+ // A result that began before an invalidation may predate the change, so it is not kept.
88
+ if (!cancelled && retention && retention.generation === generation) {
89
+ retention.catalogKey = key;
90
+ retention.catalogSkills = ready;
91
+ }
76
92
  if (!cancelled)
77
- setState({ status: "ready", skills, bundles, activeBundles, warnings, sources });
93
+ setState(ready);
78
94
  }
79
95
  catch (error) {
80
96
  if (!cancelled)
@@ -87,49 +103,59 @@ export function useCatalogSkills(operations, sourceId, all, env, reloadKey = 0)
87
103
  }, [operations, sourceId, all, env, reloadKey]);
88
104
  return state;
89
105
  }
90
- export function useCatalogApps(apps) {
91
- const [state, setState] = useState({ status: "loading" });
106
+ export function useCatalogApps(apps, retention) {
107
+ const [state, setState] = useState(() => retention?.catalogApps ? { ...retention.catalogApps, stale: true } : { status: "loading" });
92
108
  const generation = useRef(0);
93
- const reload = useCallback(async () => {
109
+ /** `keepShown` reloads behind the rows already on screen instead of replacing them with a loading state. */
110
+ const load = useCallback(async (keepShown) => {
94
111
  const current = ++generation.current;
95
- setState({ status: "loading" });
112
+ const retained = retention?.generation;
113
+ if (!keepShown)
114
+ setState({ status: "loading" });
96
115
  try {
97
116
  const rows = await Promise.all((await apps.load()).filter(entry => entry.applicable).map(async (entry) => ({
98
117
  entry,
99
118
  inspection: await apps.inspect(entry),
100
119
  })));
101
- if (generation.current === current)
102
- setState({
103
- status: "ready",
104
- rows: rows.filter(row => row.inspection.status === "not installed"),
105
- });
120
+ if (generation.current === current) {
121
+ const ready = { status: "ready", rows: rows.filter(row => row.inspection.status === "not installed") };
122
+ if (retention && retention.generation === retained)
123
+ retention.catalogApps = ready;
124
+ setState(ready);
125
+ }
106
126
  }
107
127
  catch (error) {
108
128
  if (generation.current === current)
109
129
  setState({ status: "ready", rows: [], error: error instanceof Error ? error.message : String(error) });
110
130
  }
111
- }, [apps]);
131
+ }, [apps, retention]);
132
+ const reload = useCallback(() => load(false), [load]);
112
133
  useEffect(() => {
113
- void reload();
134
+ void load(retention?.catalogApps !== undefined);
114
135
  return () => { generation.current += 1; };
115
- }, [reload]);
136
+ }, [load, retention]);
116
137
  return { state, reload };
117
138
  }
118
139
  /** The Skills already installed, loaded on mount and on demand. */
119
- export function useInstalledSkills(operations, env, mounted) {
120
- const [installed, setInstalled] = useState(NO_INSTALLED);
121
- const [installedReady, setInstalledReady] = useState(false);
140
+ export function useInstalledSkills(operations, env, mounted, retention) {
141
+ const [installed, setInstalled] = useState(retention?.installed ?? NO_INSTALLED);
142
+ const [installedReady, setInstalledReady] = useState(retention?.installed !== undefined);
143
+ const [installedStale, setInstalledStale] = useState(retention?.installed !== undefined);
122
144
  const reloadInstalled = useCallback(async () => {
145
+ const generation = retention?.generation;
123
146
  const loaded = await loadInstalledSkills(operations, env);
147
+ if (retention && retention.generation === generation)
148
+ retention.installed = loaded;
124
149
  if (!mounted.current)
125
150
  return;
126
151
  setInstalled(loaded);
127
152
  setInstalledReady(true);
128
- }, [operations, env, mounted]);
153
+ setInstalledStale(false);
154
+ }, [operations, env, mounted, retention]);
129
155
  useEffect(() => {
130
156
  void reloadInstalled();
131
157
  }, [reloadInstalled]);
132
- return { installed, installedReady, reloadInstalled };
158
+ return { installed, installedReady, installedStale, reloadInstalled };
133
159
  }
134
160
  /**
135
161
  * The filter box. Its state is mirrored in refs, updated first, so the shell