@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.
- package/CHANGELOG.md +35 -0
- package/README.md +24 -2
- package/dist/src/app-flow.js +3 -3
- package/dist/src/app-recipe-removal.js +3 -1
- package/dist/src/atomic-file.js +24 -1
- package/dist/src/cli.js +47 -13
- package/dist/src/path-safety.js +4 -1
- package/dist/src/profile-export.js +1 -1
- package/dist/src/profile-import.js +94 -18
- package/dist/src/project-installation.js +2 -1
- package/dist/src/project-manifest.js +7 -0
- package/dist/src/project-target.js +58 -0
- package/dist/src/skill-discovery.js +86 -6
- package/dist/src/skill-install.js +20 -8
- package/dist/src/tui/app-actions.js +18 -7
- package/dist/src/tui/app.js +19 -4
- package/dist/src/tui/batch.js +2 -1
- package/dist/src/tui/bulk-actions.js +21 -8
- package/dist/src/tui/catalog-installs.js +3 -2
- package/dist/src/tui/catalog-panel.js +3 -2
- package/dist/src/tui/catalog-state.js +47 -21
- package/dist/src/tui/catalog-tree.js +47 -10
- package/dist/src/tui/catalog-view.js +11 -5
- package/dist/src/tui/installations-view.js +19 -6
- package/dist/src/tui/manage-panel.js +1 -1
- package/dist/src/tui/profile-view.js +68 -31
- package/dist/src/tui/sources-recipes.js +44 -8
- package/dist/src/tui/sources-view.js +12 -5
- package/dist/src/tui/updates.js +1 -0
- package/dist/src/tui/view-retention.js +17 -0
- package/dist/src/update-flow.js +3 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
140
|
-
sourceId: source.id,
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
379
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/src/tui/app.js
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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
|
}
|
package/dist/src/tui/batch.js
CHANGED
|
@@ -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
|
-
|
|
213
|
+
const started = busy(label);
|
|
214
|
+
context.setMode(started);
|
|
206
215
|
let result;
|
|
207
216
|
try {
|
|
208
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
134
|
+
void load(retention?.catalogApps !== undefined);
|
|
114
135
|
return () => { generation.current += 1; };
|
|
115
|
-
}, [
|
|
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(
|
|
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
|
-
|
|
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
|