@nklisch/pi-enhanced 0.2.4 → 0.2.5

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 (27) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/node_modules/@nklisch/pi-clearance/native/clearance-core.linux-x64-gnu.node +0 -0
  3. package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
  4. package/node_modules/@nklisch/pi-plugins/README.md +52 -22
  5. package/node_modules/@nklisch/pi-plugins/dist/host.d.ts +1 -0
  6. package/node_modules/@nklisch/pi-plugins/dist/host.js +278 -16
  7. package/node_modules/@nklisch/pi-plugins/dist/host.js.map +1 -1
  8. package/node_modules/@nklisch/pi-plugins/dist/index.d.ts +2 -2
  9. package/node_modules/@nklisch/pi-plugins/dist/index.js +1 -1
  10. package/node_modules/@nklisch/pi-plugins/dist/index.js.map +1 -1
  11. package/node_modules/@nklisch/pi-plugins/dist/pi/commands.js +30 -4
  12. package/node_modules/@nklisch/pi-plugins/dist/pi/commands.js.map +1 -1
  13. package/node_modules/@nklisch/pi-plugins/dist/pi/extension.d.ts +3 -3
  14. package/node_modules/@nklisch/pi-plugins/dist/pi/extension.js +21 -4
  15. package/node_modules/@nklisch/pi-plugins/dist/pi/extension.js.map +1 -1
  16. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-model.d.ts +59 -0
  17. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-model.js +96 -0
  18. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-model.js.map +1 -0
  19. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.d.ts +133 -0
  20. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js +1251 -0
  21. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js.map +1 -0
  22. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js +26 -13
  23. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js.map +1 -1
  24. package/node_modules/@nklisch/pi-plugins/dist/types.d.ts +72 -4
  25. package/node_modules/@nklisch/pi-plugins/dist/types.js.map +1 -1
  26. package/node_modules/@nklisch/pi-plugins/package.json +1 -1
  27. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.5
4
+
5
+ ### Changed
6
+
7
+ - Rebundle `@nklisch/pi-plugins` 0.6.0 with the keyboard-first plugin manager, explicit multi-select batches, bounded marketplace checks, and marker-authorized startup updates.
8
+
3
9
  ## v0.2.4
4
10
 
5
11
  ### Changed
@@ -1,9 +1,9 @@
1
1
  # Pi Plugins
2
2
 
3
- `@nklisch/pi-plugins` is a small filesystem-first marketplace and plugin host
4
- for [Pi](https://github.com/badlogic/pi-mono). It installs compatible Claude
5
- Code and Codex plugin bundles, discovers their skills, runs their simple
6
- command hooks, and supplies their MCP declarations to Pi.
3
+ `@nklisch/pi-plugins` is a filesystem-first marketplace and plugin manager for
4
+ [Pi](https://github.com/badlogic/pi-mono). It installs compatible Claude Code
5
+ and Codex plugin bundles, discovers their skills, runs their simple command
6
+ hooks, and supplies their MCP declarations to Pi.
7
7
 
8
8
  ## Install
9
9
 
@@ -11,23 +11,53 @@ command hooks, and supplies their MCP declarations to Pi.
11
11
  pi install npm:@nklisch/pi-plugins
12
12
  ```
13
13
 
14
- Then use the concise command surface:
14
+ Run `/plugins` with no arguments to open the keyboard-first manager. It provides
15
+ Installed, Discover, Marketplaces, and Issues views; search and plugin details;
16
+ and explicit multi-select batches. `Ctrl+F` focuses search, Space selects rows,
17
+ `a` selects all filtered rows, Enter opens details, `r` checks marketplaces,
18
+ and the footer shows the contextual action keys.
19
+
20
+ The manager opens from local files. Marketplace checks run asynchronously with
21
+ bounded concurrency and timeouts, remain cancellable, and never hide the
22
+ installed copies when a source is offline. Runtime changes are applied
23
+ sequentially and trigger one Pi reload when the manager closes.
24
+
25
+ The direct command surface remains available for scripts and focused actions:
15
26
 
16
27
  ```text
28
+ /plugins list
17
29
  /plugins marketplace add nklisch/skills
18
30
  /plugins marketplace list
31
+ /plugins marketplace refresh nklisch-skills
19
32
  /plugins browse nklisch-skills
20
33
  /plugins install workbench@nklisch-skills --yes
21
34
  /plugins update workbench@nklisch-skills --yes
22
35
  /plugins enable workbench@nklisch-skills --yes
23
36
  /plugins disable workbench@nklisch-skills
24
37
  /plugins remove workbench@nklisch-skills --yes
38
+ /plugins update-marked
25
39
  ```
26
40
 
27
- The command uses Pi's ordinary select, input, and confirmation dialogs when
28
- invoked without arguments in a UI session. Installing, updating, enabling, or
29
- removing executable plugin content requires confirmation; pass `--yes` for
30
- headless use. Pi reloads after a runtime-affecting mutation.
41
+ Direct installation, update, enablement, or removal of executable plugin
42
+ content requires confirmation; pass `--yes` for headless use. Each direct
43
+ runtime mutation reloads Pi after it succeeds.
44
+
45
+ ## Automatic updates
46
+
47
+ An installed plugin can be marked for automatic updates from its detail view.
48
+ The `.auto-update` marker is both the selection and the standing authorization
49
+ to replace that plugin's executable content.
50
+
51
+ Before plugin activation, Pi refreshes each affected marketplace once with a
52
+ bounded timeout. A marked plugin updates only when the catalog declares a
53
+ version different from its installed receipt. Missing versions, offline
54
+ sources, and item failures leave the installed copy unchanged and do not block
55
+ startup.
56
+
57
+ `/plugins update-marked` is the explicit escape path. It refreshes affected
58
+ marketplaces, force-updates every marked plugin—including unversioned entries—
59
+ reports mixed results, and reloads once if any update succeeds. There is no
60
+ scheduler or periodic background updater.
31
61
 
32
62
  ## Filesystem truth
33
63
 
@@ -35,18 +65,21 @@ The host stores no database or lifecycle ledger. Its durable layout is:
35
65
 
36
66
  ```text
37
67
  <agent-dir>/plugin-host/
68
+ ├── .check-on-open?
38
69
  ├── marketplaces/<name>/{source.json,checkout}
39
- ├── plugins/<marketplace>/<plugin>/{.pi-plugin.json,.disabled?,...bundle}
70
+ ├── plugins/<marketplace>/<plugin>/{.pi-plugin.json,.disabled?,.auto-update?,...bundle}
40
71
  └── data/<marketplace>/<plugin>/
41
72
  ```
42
73
 
43
74
  A plugin directory is installed; `.disabled` means disabled. The receipt is
44
- descriptive only. Refresh and install/update stage a sibling directory and
45
- rename it into place. Persistent plugin data is not replaced by an update and
46
- is retained by removal unless `--delete-data` is supplied.
47
-
48
- Marketplaces accept GitHub shorthand, Git URLs, and local repository paths.
49
- The host reads `.agents/plugins/marketplace.json` and
75
+ descriptive only. `.check-on-open` stores the manager's optional refresh
76
+ preference; cursor, selection, progress, results, and errors are never stored.
77
+ Refresh and install/update stage a sibling directory and rename it into place.
78
+ Persistent plugin data is not replaced by an update and is retained by removal
79
+ unless `--delete-data` is supplied.
80
+
81
+ Marketplaces accept GitHub shorthand, Git URLs, and local repository paths. The
82
+ host reads `.agents/plugins/marketplace.json` and
50
83
  `.claude-plugin/marketplace.json`, merges valid siblings, and supports local
51
84
  plugin declarations such as `{ "source": "local", "path": "./plugins/demo" }`
52
85
  and ordinary relative string paths. Catalog paths must remain within the
@@ -71,12 +104,9 @@ injected into the next Pi system prompt.
71
104
 
72
105
  MCP declarations are recursively expanded for the plugin root, persistent data
73
106
  root, and project root, then passed as an in-memory `{ mcpServers }` overlay to
74
- `@nklisch/pi-mcp-adapter`. The adapter merges that overlay with the user's normal
75
- file-discovered MCP configuration. Duplicate plugin server names are qualified
76
- by plugin identity.
77
- There is no background network activity. Marketplace refresh runs only when
78
- requested; updating a plugin first refreshes its marketplace, then replaces the
79
- installed copy.
107
+ `@nklisch/pi-mcp-adapter`. The adapter merges that overlay with the user's
108
+ normal file-discovered MCP configuration. Duplicate plugin server names are
109
+ qualified by plugin identity.
80
110
 
81
111
  ## Development
82
112
 
@@ -1,4 +1,5 @@
1
1
  import type { PluginHost } from "./types.js";
2
+ export declare const DEFAULT_REFRESH_TIMEOUT_MS = 10000;
2
3
  export declare function createPluginHost(agentDir: string): PluginHost;
3
4
  export { assertSafeRelativePath, assertSafeName, isPathContained, resolveContainedPath } from "./paths.js";
4
5
  export { mergeMarketplaceCatalogs, readMarketplaceCatalog } from "./catalog.js";
@@ -7,6 +7,18 @@ import { assertNoSymlinks, assertSafeName, assertSafeRelativePath, createPluginH
7
7
  import { buildMcpConfig } from "./mcp.js";
8
8
  import { scanInstalledPlugins } from "./runtime-discovery.js";
9
9
  const execFileAsync = promisify(execFile);
10
+ export const DEFAULT_REFRESH_TIMEOUT_MS = 10_000;
11
+ const CHECK_ON_OPEN_MARKER = ".check-on-open";
12
+ function errorMessage(error) {
13
+ return error instanceof Error ? error.message : String(error);
14
+ }
15
+ function throwIfAborted(signal) {
16
+ if (!signal?.aborted)
17
+ return;
18
+ const error = new Error(signal.reason instanceof Error ? signal.reason.message : "operation cancelled");
19
+ error.name = "AbortError";
20
+ throw error;
21
+ }
10
22
  function isRecord(value) {
11
23
  return value !== null && typeof value === "object" && !Array.isArray(value);
12
24
  }
@@ -62,13 +74,19 @@ async function normalizeMarketplaceSource(value, ref) {
62
74
  async function readSource(root) {
63
75
  return parseSourceFile(JSON.parse(await readFile(join(root, "source.json"), "utf8")));
64
76
  }
65
- async function runGitClone(source, destination) {
77
+ async function runGitClone(source, destination, options = {}) {
78
+ throwIfAborted(options.signal);
66
79
  const gitSource = source.kind === "github" ? `https://github.com/${source.value}.git` : source.value;
67
80
  const args = ["clone", "--quiet", "--no-tags"];
68
81
  if (source.ref !== undefined)
69
82
  args.push("--branch", source.ref);
70
83
  args.push(gitSource, destination);
71
- await execFileAsync("git", args, { maxBuffer: 4 * 1024 * 1024 });
84
+ await execFileAsync("git", args, {
85
+ maxBuffer: 4 * 1024 * 1024,
86
+ timeout: options.timeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS,
87
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
88
+ });
89
+ throwIfAborted(options.signal);
72
90
  }
73
91
  async function makeOwnerWritable(root) {
74
92
  const stat = await lstat(root);
@@ -92,13 +110,15 @@ async function removeOwnedTree(root) {
92
110
  }
93
111
  await rm(root, { recursive: true, force: true });
94
112
  }
95
- async function materializeMarketplace(source, destination) {
113
+ async function materializeMarketplace(source, destination, options = {}) {
114
+ throwIfAborted(options.signal);
96
115
  if (source.kind === "local") {
97
116
  await cp(source.value, destination, { recursive: true, dereference: false, force: true });
98
117
  }
99
118
  else {
100
- await runGitClone(source, destination);
119
+ await runGitClone(source, destination, options);
101
120
  }
121
+ throwIfAborted(options.signal);
102
122
  await makeOwnerWritable(destination);
103
123
  }
104
124
  async function ensureRoots(paths) {
@@ -154,6 +174,50 @@ async function readReceipt(root) {
154
174
  return undefined;
155
175
  }
156
176
  }
177
+ async function hasRegularMarker(root, marker) {
178
+ try {
179
+ const stat = await lstat(join(root, marker));
180
+ if (stat.isSymbolicLink())
181
+ throw new Error(`${marker} must not be a symlink: ${root}`);
182
+ return stat.isFile();
183
+ }
184
+ catch (error) {
185
+ if (error.code === "ENOENT")
186
+ return false;
187
+ throw error;
188
+ }
189
+ }
190
+ async function setRegularMarker(root, marker, enabled) {
191
+ const path = join(root, marker);
192
+ if (enabled) {
193
+ try {
194
+ const stat = await lstat(path);
195
+ if (stat.isSymbolicLink())
196
+ throw new Error(`${marker} must not be a symlink: ${root}`);
197
+ if (!stat.isFile())
198
+ throw new Error(`${marker} must be a regular file: ${root}`);
199
+ return;
200
+ }
201
+ catch (error) {
202
+ if (error.code !== "ENOENT")
203
+ throw error;
204
+ await writeFile(path, "", { encoding: "utf8", flag: "wx" });
205
+ }
206
+ return;
207
+ }
208
+ try {
209
+ const stat = await lstat(path);
210
+ if (stat.isSymbolicLink())
211
+ throw new Error(`${marker} must not be a symlink: ${root}`);
212
+ if (!stat.isFile())
213
+ throw new Error(`${marker} must be a regular file: ${root}`);
214
+ await rm(path, { force: true });
215
+ }
216
+ catch (error) {
217
+ if (error.code !== "ENOENT")
218
+ throw error;
219
+ }
220
+ }
157
221
  async function installedInfo(paths, marketplace, plugin) {
158
222
  const market = assertSafeName(marketplace, "marketplace name");
159
223
  const name = assertSafeName(plugin, "plugin name");
@@ -162,9 +226,10 @@ async function installedInfo(paths, marketplace, plugin) {
162
226
  if (!stat.isDirectory() || stat.isSymbolicLink())
163
227
  throw new Error(`installed plugin is not a directory: ${name}@${market}`);
164
228
  const data = join(paths.data, market, name);
165
- const disabled = await lstat(join(root, ".disabled")).then((item) => item.isFile()).catch(() => false);
229
+ const disabled = await hasRegularMarker(root, ".disabled");
230
+ const autoUpdate = await hasRegularMarker(root, ".auto-update");
166
231
  const receipt = await readReceipt(root);
167
- return Object.freeze({ marketplace: market, name, root, data, enabled: !disabled, ...(receipt === undefined ? {} : { receipt }) });
232
+ return Object.freeze({ marketplace: market, name, root, data, enabled: !disabled, autoUpdate, ...(receipt === undefined ? {} : { receipt }) });
168
233
  }
169
234
  function pluginPath(paths, marketplace, plugin) {
170
235
  return join(paths.plugins, assertSafeName(marketplace, "marketplace name"), assertSafeName(plugin, "plugin name"));
@@ -205,7 +270,7 @@ async function resolvePluginSource(paths, info, entry) {
205
270
  throw error;
206
271
  }
207
272
  }
208
- async function copyPluginBundle(paths, source, marketplace, plugin, entry, preserveDisabled) {
273
+ async function copyPluginBundle(paths, source, marketplace, plugin, entry, preserveDisabled, preserveAutoUpdate) {
209
274
  await assertNoSymlinks(source);
210
275
  const market = assertSafeName(marketplace, "marketplace name");
211
276
  const name = assertSafeName(plugin, "plugin name");
@@ -214,7 +279,10 @@ async function copyPluginBundle(paths, source, marketplace, plugin, entry, prese
214
279
  const stage = await mkdtemp(join(parent, `.${name}-`));
215
280
  try {
216
281
  for (const item of await readdir(source, { withFileTypes: true })) {
217
- if (item.name === ".git")
282
+ // These files are host authority, not bundle content. In particular, a
283
+ // catalog must not be able to grant automatic executable updates by
284
+ // shipping its own marker.
285
+ if (item.name === ".git" || item.name === ".pi-plugin.json" || item.name === ".disabled" || item.name === ".auto-update")
218
286
  continue;
219
287
  await cp(join(source, item.name), join(stage, item.name), { recursive: true, dereference: false, force: true });
220
288
  }
@@ -228,6 +296,8 @@ async function copyPluginBundle(paths, source, marketplace, plugin, entry, prese
228
296
  }, null, 2)}\n`, "utf8");
229
297
  if (preserveDisabled)
230
298
  await writeFile(join(stage, ".disabled"), "", "utf8");
299
+ if (preserveAutoUpdate)
300
+ await writeFile(join(stage, ".auto-update"), "", "utf8");
231
301
  await assertNoSymlinks(stage);
232
302
  const target = pluginPath(paths, market, name);
233
303
  await removeOwnedTree(target);
@@ -248,7 +318,11 @@ export function createPluginHost(agentDir) {
248
318
  const stageRoot = await mkdtemp(join(paths.marketplaces, ".marketplace-"));
249
319
  try {
250
320
  const checkout = join(stageRoot, "checkout");
251
- await materializeMarketplace(source, checkout);
321
+ // Adding a user-selected source may need to clone substantially more
322
+ // history than a routine refresh. Node's zero timeout leaves this
323
+ // foreground acquisition user-controlled instead of applying the
324
+ // manager/startup refresh budget.
325
+ await materializeMarketplace(source, checkout, { timeoutMs: 0 });
252
326
  const result = await catalogForCheckout(checkout);
253
327
  const info = marketplaceInfo(paths, result.catalog.name, source);
254
328
  await writeFile(join(stageRoot, "source.json"), `${JSON.stringify(sourceFile(source), null, 2)}\n`, "utf8");
@@ -279,18 +353,20 @@ export function createPluginHost(agentDir) {
279
353
  }
280
354
  return Object.freeze(values.sort((a, b) => a.name.localeCompare(b.name)));
281
355
  }
282
- async function refreshMarketplace(nameValue) {
356
+ async function refreshMarketplace(nameValue, options = {}) {
283
357
  const name = assertSafeName(nameValue, "marketplace name");
284
358
  await ensureRoots(paths);
359
+ throwIfAborted(options.signal);
285
360
  const root = join(paths.marketplaces, name);
286
361
  const source = await readSource(root);
287
362
  const stageRoot = await mkdtemp(join(paths.marketplaces, ".marketplace-refresh-"));
288
363
  try {
289
364
  const checkout = join(stageRoot, "checkout");
290
- await materializeMarketplace(source, checkout);
365
+ await materializeMarketplace(source, checkout, options);
291
366
  const result = await catalogForCheckout(checkout);
292
367
  if (result.catalog.name !== name)
293
368
  throw new Error(`refreshed marketplace declares ${result.catalog.name}, expected ${name}`);
369
+ throwIfAborted(options.signal);
294
370
  await removeOwnedTree(join(root, "checkout"));
295
371
  await rename(checkout, join(root, "checkout"));
296
372
  return marketplaceInfo(paths, name, source);
@@ -299,6 +375,47 @@ export function createPluginHost(agentDir) {
299
375
  await rm(stageRoot, { recursive: true, force: true });
300
376
  }
301
377
  }
378
+ async function refreshMarketplaces(names, options = {}) {
379
+ const uniqueNames = [...new Set(names)].map((name) => assertSafeName(name, "marketplace name"));
380
+ const concurrency = Math.max(1, Math.min(options.concurrency ?? 2, uniqueNames.length || 1));
381
+ const results = [];
382
+ let next = 0;
383
+ const worker = async () => {
384
+ while (true) {
385
+ const index = next++;
386
+ const name = uniqueNames[index];
387
+ if (name === undefined)
388
+ return;
389
+ let result;
390
+ if (options.signal?.aborted) {
391
+ result = Object.freeze({ marketplace: name, ok: false, diagnostics: Object.freeze([]), error: "operation cancelled" });
392
+ }
393
+ else {
394
+ try {
395
+ const info = await refreshMarketplace(name, options);
396
+ const catalog = await catalogForCheckout(info.checkout);
397
+ result = Object.freeze({
398
+ marketplace: name,
399
+ ok: true,
400
+ info,
401
+ catalog: Object.freeze({ ...catalog.catalog, diagnostics: catalog.diagnostics }),
402
+ diagnostics: catalog.diagnostics,
403
+ });
404
+ }
405
+ catch (error) {
406
+ result = Object.freeze({ marketplace: name, ok: false, diagnostics: Object.freeze([]), error: errorMessage(error) });
407
+ }
408
+ }
409
+ results[index] = result;
410
+ try {
411
+ options.onResult?.(result);
412
+ }
413
+ catch { /* UI observers cannot change filesystem truth. */ }
414
+ }
415
+ };
416
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
417
+ return Object.freeze(results.filter((result) => result !== undefined));
418
+ }
302
419
  async function removeMarketplace(nameValue) {
303
420
  const name = assertSafeName(nameValue, "marketplace name");
304
421
  const root = join(paths.marketplaces, name);
@@ -326,7 +443,7 @@ export function createPluginHost(agentDir) {
326
443
  }
327
444
  return Object.freeze(result.sort((a, b) => `${a.marketplace}/${a.name}`.localeCompare(`${b.marketplace}/${b.name}`)));
328
445
  }
329
- async function mutatePlugin(marketplace, plugin, update) {
446
+ async function mutatePlugin(marketplace, plugin, update, options = {}) {
330
447
  await ensureRoots(paths);
331
448
  let currentInfo;
332
449
  try {
@@ -340,13 +457,14 @@ export function createPluginHost(agentDir) {
340
457
  throw new Error(`plugin is not installed: ${plugin}@${marketplace}`);
341
458
  }
342
459
  const preserveDisabled = currentInfo?.enabled === false;
343
- if (update) {
344
- await refreshMarketplace(marketplace);
460
+ const preserveAutoUpdate = currentInfo?.autoUpdate === true;
461
+ if (update && options.refresh !== false) {
462
+ await refreshMarketplace(marketplace, options);
345
463
  }
346
464
  const { info, entry } = await findCatalogPlugin(paths, marketplace, plugin);
347
465
  const source = await resolvePluginSource(paths, info, entry);
348
466
  try {
349
- return await copyPluginBundle(paths, source.root, info.name, entry.name, entry, preserveDisabled);
467
+ return await copyPluginBundle(paths, source.root, info.name, entry.name, entry, preserveDisabled, preserveAutoUpdate);
350
468
  }
351
469
  finally {
352
470
  await source.cleanup();
@@ -370,9 +488,33 @@ export function createPluginHost(agentDir) {
370
488
  async function disablePlugin(marketplace, plugin) {
371
489
  const info = await installedInfo(paths, marketplace, plugin);
372
490
  const marker = join(info.root, ".disabled");
491
+ try {
492
+ const stat = await lstat(marker);
493
+ if (stat.isSymbolicLink())
494
+ throw new Error(`disabled marker is a symlink: ${marker}`);
495
+ if (!stat.isFile())
496
+ throw new Error(`disabled marker is not a file: ${marker}`);
497
+ }
498
+ catch (error) {
499
+ if (error.code !== "ENOENT")
500
+ throw error;
501
+ }
373
502
  await writeFile(marker, "", { encoding: "utf8", flag: "w" });
374
503
  return installedInfo(paths, marketplace, plugin);
375
504
  }
505
+ async function setAutoUpdate(marketplace, plugin, enabled) {
506
+ const info = await installedInfo(paths, marketplace, plugin);
507
+ await setRegularMarker(info.root, ".auto-update", enabled);
508
+ return installedInfo(paths, marketplace, plugin);
509
+ }
510
+ async function getCheckOnOpen() {
511
+ await ensureRoots(paths);
512
+ return hasRegularMarker(paths.hostRoot, CHECK_ON_OPEN_MARKER);
513
+ }
514
+ async function setCheckOnOpen(enabled) {
515
+ await ensureRoots(paths);
516
+ await setRegularMarker(paths.hostRoot, CHECK_ON_OPEN_MARKER, enabled);
517
+ }
376
518
  async function removePlugin(marketplace, plugin, deleteData = false) {
377
519
  const root = pluginPath(paths, marketplace, plugin);
378
520
  await removeOwnedTree(root);
@@ -385,19 +527,139 @@ export function createPluginHost(agentDir) {
385
527
  await ensureRoots(paths);
386
528
  return scanInstalledPlugins(paths);
387
529
  }
530
+ async function runPluginBatch(action, identities, options = {}) {
531
+ const refreshRequired = options.refresh ?? (action === "install" || action === "update");
532
+ const refreshes = refreshRequired
533
+ ? await refreshMarketplaces(identities.map((identity) => identity.marketplace), options)
534
+ : [];
535
+ const refreshByMarketplace = new Map(refreshes.map((result) => [result.marketplace, result]));
536
+ const results = [];
537
+ let cancelled = false;
538
+ for (const identity of identities) {
539
+ if (options.signal?.aborted) {
540
+ cancelled = true;
541
+ break;
542
+ }
543
+ try {
544
+ options.onBeforeItem?.(identity);
545
+ }
546
+ catch { /* observers cannot change filesystem truth */ }
547
+ if (options.signal?.aborted) {
548
+ cancelled = true;
549
+ break;
550
+ }
551
+ let result;
552
+ try {
553
+ const marketplace = assertSafeName(identity.marketplace, "marketplace name");
554
+ const plugin = assertSafeName(identity.plugin, "plugin name");
555
+ const refresh = refreshByMarketplace.get(marketplace);
556
+ if (refreshRequired && refresh?.ok !== true) {
557
+ throw new Error(`marketplace refresh failed: ${refresh?.error ?? "unknown error"}`);
558
+ }
559
+ let info;
560
+ if (action === "install")
561
+ info = await mutatePlugin(marketplace, plugin, false);
562
+ if (action === "update") {
563
+ const updateOptions = {
564
+ refresh: false,
565
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
566
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
567
+ };
568
+ info = await mutatePlugin(marketplace, plugin, true, updateOptions);
569
+ }
570
+ if (action === "enable")
571
+ info = await enablePlugin(marketplace, plugin);
572
+ if (action === "disable")
573
+ info = await disablePlugin(marketplace, plugin);
574
+ if (action === "remove")
575
+ await removePlugin(marketplace, plugin, options.deleteData === true);
576
+ result = Object.freeze({ action, identity: Object.freeze({ marketplace, plugin }), ok: true, ...(info === undefined ? {} : { info }) });
577
+ }
578
+ catch (error) {
579
+ result = Object.freeze({
580
+ action,
581
+ identity: Object.freeze({ ...identity }),
582
+ ok: false,
583
+ error: errorMessage(error),
584
+ });
585
+ }
586
+ // The scan is deliberately after each settled item, including failures:
587
+ // the next item and the result page both observe current filesystem truth.
588
+ await scanRuntime().catch(() => undefined);
589
+ results.push(result);
590
+ try {
591
+ options.onItem?.(result);
592
+ }
593
+ catch { /* a view observer cannot change the batch result */ }
594
+ }
595
+ return Object.freeze({ results: Object.freeze(results), cancelled });
596
+ }
597
+ async function updateMarkedPlugins(options = {}) {
598
+ const marked = (await listInstalled()).filter((plugin) => plugin.autoUpdate);
599
+ const refreshes = await refreshMarketplaces([...new Set(marked.map((plugin) => plugin.marketplace))], options);
600
+ const refreshByMarketplace = new Map(refreshes.map((result) => [result.marketplace, result]));
601
+ const results = [];
602
+ const force = options.force === true;
603
+ for (const installed of marked) {
604
+ if (options.signal?.aborted)
605
+ break;
606
+ const identity = Object.freeze({ marketplace: installed.marketplace, plugin: installed.name });
607
+ const refresh = refreshByMarketplace.get(installed.marketplace);
608
+ let result;
609
+ try {
610
+ if (refresh?.ok !== true || refresh.catalog === undefined) {
611
+ throw new Error(`marketplace refresh failed: ${refresh?.error ?? "unknown error"}`);
612
+ }
613
+ const entry = refresh.catalog.plugins.find((candidate) => candidate.name === installed.name);
614
+ if (entry === undefined)
615
+ throw new Error(`plugin is not in marketplace catalog: ${installed.name}@${installed.marketplace}`);
616
+ const installedVersion = typeof installed.receipt?.version === "string" ? installed.receipt.version : undefined;
617
+ if (!force && entry.version === undefined) {
618
+ result = Object.freeze({ identity, ok: true, updated: false, skipped: true, reason: "catalog version is not declared" });
619
+ }
620
+ else if (!force && installedVersion === entry.version) {
621
+ result = Object.freeze({ identity, ok: true, updated: false, skipped: true, reason: "already at declared catalog version" });
622
+ }
623
+ else {
624
+ const updateOptions = {
625
+ refresh: false,
626
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
627
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
628
+ };
629
+ const info = await mutatePlugin(installed.marketplace, installed.name, true, updateOptions);
630
+ result = Object.freeze({ identity, ok: true, updated: true, skipped: false, info });
631
+ }
632
+ }
633
+ catch (error) {
634
+ result = Object.freeze({ identity, ok: false, updated: false, skipped: false, error: errorMessage(error) });
635
+ }
636
+ results.push(result);
637
+ try {
638
+ options.onItem?.(result);
639
+ }
640
+ catch { /* a view observer cannot change the update outcome */ }
641
+ }
642
+ return Object.freeze({ refreshes, results: Object.freeze(results) });
643
+ }
388
644
  return Object.freeze({
389
645
  paths,
390
646
  addMarketplace,
391
647
  listMarketplaces,
392
648
  refreshMarketplace,
649
+ refreshMarketplaces,
393
650
  removeMarketplace,
394
651
  browseMarketplace,
395
652
  listInstalled,
396
653
  installPlugin: (marketplace, plugin) => mutatePlugin(marketplace, plugin, false),
397
- updatePlugin: (marketplace, plugin) => mutatePlugin(marketplace, plugin, true),
654
+ updatePlugin: (marketplace, plugin, options) => mutatePlugin(marketplace, plugin, true, options),
398
655
  enablePlugin,
399
656
  disablePlugin,
400
657
  removePlugin,
658
+ setAutoUpdate,
659
+ runPluginBatch,
660
+ updateMarkedPlugins,
661
+ getCheckOnOpen,
662
+ setCheckOnOpen,
401
663
  scanRuntime,
402
664
  buildMcpConfig: async (snapshot) => buildMcpConfig(snapshot ?? await scanRuntime()),
403
665
  });