@nklisch/pi-enhanced 0.2.4 → 0.2.6
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 +12 -0
- package/node_modules/@nklisch/pi-clearance/native/clearance-core.linux-x64-gnu.node +0 -0
- package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
- package/node_modules/@nklisch/pi-plugins/README.md +53 -23
- package/node_modules/@nklisch/pi-plugins/dist/host.d.ts +1 -0
- package/node_modules/@nklisch/pi-plugins/dist/host.js +278 -16
- package/node_modules/@nklisch/pi-plugins/dist/host.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/index.d.ts +2 -2
- package/node_modules/@nklisch/pi-plugins/dist/index.js +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/index.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/pi/commands.js +30 -4
- package/node_modules/@nklisch/pi-plugins/dist/pi/commands.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/pi/extension.d.ts +3 -3
- package/node_modules/@nklisch/pi-plugins/dist/pi/extension.js +21 -4
- package/node_modules/@nklisch/pi-plugins/dist/pi/extension.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-model.d.ts +59 -0
- package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-model.js +96 -0
- package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-model.js.map +1 -0
- package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.d.ts +133 -0
- package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js +1251 -0
- package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js.map +1 -0
- package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js +26 -13
- package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/types.d.ts +72 -4
- package/node_modules/@nklisch/pi-plugins/dist/types.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/package.json +3 -3
- package/package.json +1 -1
- package/test/verify-bundle.mjs +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.2.6
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Rebundle `@nklisch/pi-plugins` 0.6.1 with an explicit Pi 0.82+ peer floor so npm installs a compatible TUI beside the plugin manager instead of resolving through the older peer used by another bundled extension.
|
|
8
|
+
|
|
9
|
+
## v0.2.5
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
3
15
|
## v0.2.4
|
|
4
16
|
|
|
5
17
|
### Changed
|
|
Binary file
|
|
Binary file
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# Pi Plugins
|
|
2
2
|
|
|
3
|
-
`@nklisch/pi-plugins` is a
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
75
|
-
file-discovered MCP configuration. Duplicate plugin server names are
|
|
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
|
|
|
@@ -87,7 +117,7 @@ npm run build
|
|
|
87
117
|
npm run test:package
|
|
88
118
|
```
|
|
89
119
|
|
|
90
|
-
Node.js 22.19 or newer
|
|
120
|
+
Pi 0.82 or newer and Node.js 22.19 or newer are required. The package keeps the bundled
|
|
91
121
|
`@nklisch/pi-subagents` Pi resource available through a direct, best-effort
|
|
92
122
|
loader; failure of that optional resource does not prevent plugin discovery.
|
|
93
123
|
|
|
@@ -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, {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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
|
});
|