@falkenslab/agent-kit 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/extension-launcher.mjs +9 -2
- package/dist/chat/chatController.d.ts +4 -1
- package/dist/chat/chatController.js +43 -17
- package/dist/chat/extensions.d.ts +7 -3
- package/dist/chat/extensions.js +12 -9
- package/dist/chat/runs.d.ts +3 -0
- package/dist/chat/runs.js +1 -1
- package/dist/core/agentSpec.d.ts +20 -56
- package/dist/core/extensions.d.ts +23 -12
- package/dist/core/extensions.js +8 -18
- package/dist/core/externalExtensions.d.ts +49 -9
- package/dist/core/externalExtensions.js +89 -18
- package/dist/core/liveExtensions.d.ts +65 -0
- package/dist/core/liveExtensions.js +121 -0
- package/dist/core/marketplaces.d.ts +118 -0
- package/dist/core/marketplaces.js +242 -0
- package/dist/core/messages/de.js +1 -0
- package/dist/core/messages/en.d.ts +2 -0
- package/dist/core/messages/en.js +1 -0
- package/dist/core/messages/es.js +1 -0
- package/dist/core/messages/fr.js +1 -0
- package/dist/core/pluginDownloads.d.ts +14 -0
- package/dist/core/pluginDownloads.js +141 -0
- package/dist/core/runner.js +6 -1
- package/dist/core/session.d.ts +11 -4
- package/dist/core/session.js +73 -21
- package/dist/core/sessionFacts.d.ts +5 -0
- package/dist/core/sessionFacts.js +1 -0
- package/dist/extensions/awareness/index.d.ts +12 -3
- package/dist/extensions/awareness/index.js +18 -15
- package/dist/extensions/awareness/tools.d.ts +1 -1
- package/dist/extensions/awareness/tools.js +3 -3
- package/dist/extensions/knowledge/index.d.ts +20 -6
- package/dist/extensions/knowledge/index.js +32 -29
- package/dist/extensions/knowledge/knowledgeStore.d.ts +1 -1
- package/dist/extensions/knowledge/prompt.d.ts +4 -4
- package/dist/extensions/knowledge/prompt.js +4 -4
- package/dist/extensions/memory/index.d.ts +15 -6
- package/dist/extensions/memory/index.js +34 -31
- package/dist/extensions/sources/index.d.ts +17 -5
- package/dist/extensions/sources/index.js +31 -29
- package/dist/index.d.ts +7 -2
- package/dist/index.js +6 -1
- package/dist/tui/extensionCommand.d.ts +14 -3
- package/dist/tui/extensionCommand.js +164 -12
- package/package.json +1 -1
|
@@ -10,6 +10,15 @@ import { truncate } from "./toolLabels.js";
|
|
|
10
10
|
import { agentKitVersion } from "./version.js";
|
|
11
11
|
import { outsideArchive } from "./packaged.js";
|
|
12
12
|
export const LOCK_FILE = "extensions.lock.json";
|
|
13
|
+
/**
|
|
14
|
+
* An installed extension's data folder: `${CLAUDE_PLUGIN_DATA}` in its servers' arguments and
|
|
15
|
+
* variables (a browser profile, a cache), in its scope, outside its plugin and its hash, so it's
|
|
16
|
+
* kept across sessions and updates. Created when a session starts it; removing the extension
|
|
17
|
+
* asks before deleting it.
|
|
18
|
+
*/
|
|
19
|
+
export function extensionDataDir(scopeDir, name) {
|
|
20
|
+
return path.join(scopeDir, ".data", name);
|
|
21
|
+
}
|
|
13
22
|
/** A scope's lock: its extensions by name. */
|
|
14
23
|
export async function readLock(scopeDir) {
|
|
15
24
|
try {
|
|
@@ -58,6 +67,7 @@ export async function readExternalManifest(dir) {
|
|
|
58
67
|
provides: kit.provides ?? [],
|
|
59
68
|
requires: kit.requires ?? [],
|
|
60
69
|
...(kit.kit ? { kit: kit.kit } : {}),
|
|
70
|
+
needs: Array.isArray(kit.needs) ? kit.needs.filter((program) => typeof program === "string") : [],
|
|
61
71
|
servers: await pluginServers(dir, raw),
|
|
62
72
|
readOnlyTools: kit.readOnlyTools ?? [],
|
|
63
73
|
labels: kit.labels ?? {},
|
|
@@ -98,29 +108,32 @@ async function pluginServers(dir, raw) {
|
|
|
98
108
|
}
|
|
99
109
|
return servers;
|
|
100
110
|
}
|
|
101
|
-
/** `${CLAUDE_PLUGIN_ROOT}`
|
|
102
|
-
function expand(value,
|
|
103
|
-
return value.replace(/\$\{(\w+)\}/g, (_, name) =>
|
|
111
|
+
/** `${CLAUDE_PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_DATA}` and `${VAR}` (the agent's variable) in a server's values. */
|
|
112
|
+
function expand(value, folders) {
|
|
113
|
+
return value.replace(/\$\{(\w+)\}/g, (_, name) => name === "CLAUDE_PLUGIN_ROOT" ? folders.root : name === "CLAUDE_PLUGIN_DATA" ? folders.data : (process.env[name] ?? ""));
|
|
104
114
|
}
|
|
105
115
|
/**
|
|
106
116
|
* Why one of an extension's servers can't run through the launcher (only Node, its script
|
|
107
117
|
* first, inside the extension), or how the kit starts it.
|
|
108
118
|
*/
|
|
109
|
-
function serverLaunch(name, server,
|
|
119
|
+
function serverLaunch(name, server, folders) {
|
|
120
|
+
const { root } = folders;
|
|
110
121
|
if (!/^node(\.exe)?$/i.test(path.basename(server.command)))
|
|
111
122
|
return `runs its server "${name}" with "${server.command}": only Node servers can be installed`;
|
|
112
|
-
const [script, ...args] = (server.args ?? []).map((arg) => expand(arg,
|
|
123
|
+
const [script, ...args] = (server.args ?? []).map((arg) => expand(arg, folders));
|
|
113
124
|
if (!script)
|
|
114
125
|
return `gives its server "${name}" no script to run`;
|
|
115
126
|
const entry = path.resolve(root, script);
|
|
116
127
|
if (path.relative(root, entry).startsWith(".."))
|
|
117
128
|
return `runs its server "${name}" from outside its folder (${script})`;
|
|
118
|
-
|
|
129
|
+
// Its folders as variables too, as Claude Code gives them to a plugin's servers.
|
|
130
|
+
const declared = Object.fromEntries(Object.entries(server.env ?? {}).map(([key, value]) => [key, expand(String(value), folders)]));
|
|
131
|
+
return { entry, args, env: { CLAUDE_PLUGIN_ROOT: root, CLAUDE_PLUGIN_DATA: folders.data, ...declared } };
|
|
119
132
|
}
|
|
120
133
|
/** Why an extension's servers can't run, or nothing: each must be Node, with its script there. */
|
|
121
134
|
async function serversProblem(manifest, root) {
|
|
122
135
|
for (const [name, server] of Object.entries(manifest.servers)) {
|
|
123
|
-
const launch = serverLaunch(name, server, root);
|
|
136
|
+
const launch = serverLaunch(name, server, { root, data: "" });
|
|
124
137
|
if (typeof launch === "string")
|
|
125
138
|
return launch;
|
|
126
139
|
if (!(await stat(launch.entry).catch(() => null))?.isFile())
|
|
@@ -187,9 +200,9 @@ export async function listInstalled(dirs) {
|
|
|
187
200
|
if (!scopeDir)
|
|
188
201
|
continue;
|
|
189
202
|
for (const [name, lock] of Object.entries(await readLock(scopeDir))) {
|
|
190
|
-
const dir = path.join(scopeDir, name);
|
|
203
|
+
const dir = lock.linked ? lock.source : path.join(scopeDir, name);
|
|
191
204
|
const manifest = await readExternalManifest(dir).catch(() => undefined);
|
|
192
|
-
installed.push({ name, scope, dir, lock, shadowed: installed.some((other) => other.name === name), ...(manifest ? { manifest } : {}) });
|
|
205
|
+
installed.push({ name, scope, scopeDir, dir, dataDir: extensionDataDir(scopeDir, name), lock, shadowed: installed.some((other) => other.name === name), ...(manifest ? { manifest } : {}) });
|
|
193
206
|
}
|
|
194
207
|
}
|
|
195
208
|
return installed;
|
|
@@ -217,10 +230,13 @@ function externalExtension(installed, manifest) {
|
|
|
217
230
|
name: manifest.name,
|
|
218
231
|
plugin: installed.dir,
|
|
219
232
|
external: true,
|
|
233
|
+
installedIn: installed.scopeDir,
|
|
220
234
|
async contribute() {
|
|
221
235
|
const servers = {};
|
|
236
|
+
if (Object.keys(manifest.servers).length)
|
|
237
|
+
await mkdir(installed.dataDir, { recursive: true });
|
|
222
238
|
for (const [name, server] of Object.entries(manifest.servers)) {
|
|
223
|
-
const launch = serverLaunch(name, server, installed.dir);
|
|
239
|
+
const launch = serverLaunch(name, server, { root: installed.dir, data: installed.dataDir });
|
|
224
240
|
if (typeof launch === "string")
|
|
225
241
|
continue; // checked when it loaded
|
|
226
242
|
// Its `env` reaches it (the SDK merges it with the agent's); the launcher removes the rest.
|
|
@@ -254,10 +270,23 @@ export async function loadExternalExtensions(dirs) {
|
|
|
254
270
|
return { extensions, off };
|
|
255
271
|
}
|
|
256
272
|
/** Why an installed extension can't load, or its manifest when it can. */
|
|
273
|
+
/** Whether a program is on the `PATH`, as a shell would find it (with `PATHEXT`'s extensions on Windows). */
|
|
274
|
+
export async function onPath(program, env = process.env) {
|
|
275
|
+
const dirs = (env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean);
|
|
276
|
+
const extensions = process.platform === "win32" ? ["", ...(env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").map((extension) => extension.toLowerCase())] : [""];
|
|
277
|
+
for (const dir of dirs) {
|
|
278
|
+
for (const extension of extensions) {
|
|
279
|
+
const found = await stat(path.join(dir, `${program}${extension}`)).then((entry) => entry.isFile(), () => false);
|
|
280
|
+
if (found)
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
257
286
|
async function problem(installed) {
|
|
258
287
|
let manifest;
|
|
259
288
|
try {
|
|
260
|
-
if ((await hashExtension(installed.dir)) !== installed.lock.sha256)
|
|
289
|
+
if (!installed.lock.linked && (await hashExtension(installed.dir)) !== installed.lock.sha256)
|
|
261
290
|
return "has files that changed since it was installed: install it again to trust them";
|
|
262
291
|
manifest = await readExternalManifest(installed.dir);
|
|
263
292
|
}
|
|
@@ -268,6 +297,12 @@ async function problem(installed) {
|
|
|
268
297
|
return `is installed as "${installed.name}" but its manifest says "${manifest.name}"`;
|
|
269
298
|
if (manifest.kit && !inKitRange(agentKitVersion(), manifest.kit))
|
|
270
299
|
return `works with agent-kit ${manifest.kit}, and this agent runs ${agentKitVersion()}`;
|
|
300
|
+
const missing = [];
|
|
301
|
+
for (const program of manifest.needs)
|
|
302
|
+
if (!(await onPath(program)))
|
|
303
|
+
missing.push(program);
|
|
304
|
+
if (missing.length)
|
|
305
|
+
return `needs ${missing.join(", ")}, which ${missing.length === 1 ? "isn't" : "aren't"} on this computer (on the PATH)`;
|
|
271
306
|
return (await serversProblem(manifest, installed.dir)) ?? manifest;
|
|
272
307
|
}
|
|
273
308
|
const exec = promisify(execFile);
|
|
@@ -276,19 +311,29 @@ export function isGitSource(source) {
|
|
|
276
311
|
return /^(https?:\/\/|git@|ssh:\/\/|git:\/\/|file:\/\/|github:)/.test(source) || /\.git(#.*)?$/.test(source);
|
|
277
312
|
}
|
|
278
313
|
/**
|
|
279
|
-
* Installs an extension into a scope from a folder or a git repository (`url#ref`,
|
|
280
|
-
* for one inside it): copies its files (never runs a script), checks its
|
|
281
|
-
*
|
|
314
|
+
* Installs an extension into a scope from a folder or a git repository (`url#ref`, `sha` to pin
|
|
315
|
+
* a commit, and `subdir` for one inside it): copies its files (never runs a script), checks its
|
|
316
|
+
* manifest, and locks it, enabled, with the marketplace it came from when it did (`origin`, for a
|
|
317
|
+
* temporary folder, is what the lock says it came from). An extension with the same name in that
|
|
318
|
+
* scope is replaced; its data folder stays. With `link`, a folder is registered where it is
|
|
319
|
+
* instead, to develop it: not copied, and its files not checked.
|
|
282
320
|
*/
|
|
283
321
|
export async function addExtension(source, scopeDir, options = {}) {
|
|
322
|
+
if (options.link && isGitSource(source))
|
|
323
|
+
throw new Error("Only a folder can be linked: a git repository is installed as a copy.");
|
|
284
324
|
let from = path.resolve(source);
|
|
285
325
|
let commit;
|
|
286
326
|
let clone;
|
|
287
327
|
if (isGitSource(source)) {
|
|
288
328
|
const [url, ref] = source.replace(/^github:/, "https://github.com/").split("#");
|
|
289
329
|
clone = await mkdtemp(path.join(os.tmpdir(), "agent-kit-extension-"));
|
|
290
|
-
|
|
330
|
+
// A pinned commit needs the history to check it out; otherwise the tip is enough.
|
|
331
|
+
await exec("git", ["clone", ...(options.sha ? [] : ["--depth", "1"]), ...(ref ? ["--branch", ref] : []), url, clone]);
|
|
332
|
+
if (options.sha)
|
|
333
|
+
await exec("git", ["-C", clone, "checkout", "--quiet", options.sha]);
|
|
291
334
|
commit = (await exec("git", ["-C", clone, "rev-parse", "HEAD"])).stdout.trim();
|
|
335
|
+
if (options.sha && commit !== options.sha.toLowerCase())
|
|
336
|
+
throw new Error(`${url} has no commit ${options.sha}.`);
|
|
292
337
|
from = clone;
|
|
293
338
|
}
|
|
294
339
|
try {
|
|
@@ -299,14 +344,23 @@ export async function addExtension(source, scopeDir, options = {}) {
|
|
|
299
344
|
if (servers)
|
|
300
345
|
throw new Error(`${manifest.name} can't be installed: it ${servers}.`);
|
|
301
346
|
const target = path.join(scopeDir, manifest.name);
|
|
347
|
+
// Replacing what's installed there would delete the source itself.
|
|
348
|
+
if (path.resolve(from) === path.resolve(target))
|
|
349
|
+
throw new Error(`${from} is where ${manifest.name} is installed: install it from another folder.`);
|
|
302
350
|
const lock = await readLock(scopeDir);
|
|
303
351
|
const replaced = Boolean(lock[manifest.name]);
|
|
304
352
|
await rm(target, { recursive: true, force: true });
|
|
305
353
|
await mkdir(scopeDir, { recursive: true });
|
|
354
|
+
if (options.link) {
|
|
355
|
+
lock[manifest.name] = { source: from, linked: true, sha256: "", enabled: true, installed: new Date().toISOString() };
|
|
356
|
+
await writeLock(scopeDir, lock);
|
|
357
|
+
return { name: manifest.name, replaced };
|
|
358
|
+
}
|
|
306
359
|
await cp(from, target, { recursive: true, filter: (file) => path.basename(file) !== ".git" });
|
|
307
360
|
lock[manifest.name] = {
|
|
308
|
-
source: isGitSource(source) ? `${source}${options.subdir ? ` (${options.subdir})` : ""}` : from,
|
|
361
|
+
source: options.origin ?? (isGitSource(source) ? `${source}${options.subdir ? ` (${options.subdir})` : ""}` : from),
|
|
309
362
|
...(commit ? { commit } : {}),
|
|
363
|
+
...(options.marketplace ? { marketplace: options.marketplace } : {}),
|
|
310
364
|
sha256: await hashExtension(target),
|
|
311
365
|
enabled: true,
|
|
312
366
|
installed: new Date().toISOString(),
|
|
@@ -319,16 +373,33 @@ export async function addExtension(source, scopeDir, options = {}) {
|
|
|
319
373
|
await rm(clone, { recursive: true, force: true });
|
|
320
374
|
}
|
|
321
375
|
}
|
|
322
|
-
/**
|
|
323
|
-
|
|
376
|
+
/**
|
|
377
|
+
* Removes an extension from a scope (its files and its lock entry; a linked one's folder is left
|
|
378
|
+
* where it is), and its data folder with `deleteData`; false when it wasn't there.
|
|
379
|
+
*/
|
|
380
|
+
export async function removeExtension(scopeDir, name, options = {}) {
|
|
324
381
|
const lock = await readLock(scopeDir);
|
|
325
382
|
if (!lock[name])
|
|
326
383
|
return false;
|
|
327
384
|
delete lock[name];
|
|
328
385
|
await rm(path.join(scopeDir, name), { recursive: true, force: true });
|
|
386
|
+
if (options.deleteData)
|
|
387
|
+
await rm(extensionDataDir(scopeDir, name), { recursive: true, force: true });
|
|
329
388
|
await writeLock(scopeDir, lock);
|
|
330
389
|
return true;
|
|
331
390
|
}
|
|
391
|
+
/** The size in bytes of the files under `dir` (0 when it doesn't exist). */
|
|
392
|
+
export async function folderSize(dir) {
|
|
393
|
+
let size = 0;
|
|
394
|
+
for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
395
|
+
const full = path.join(dir, entry.name);
|
|
396
|
+
if (entry.isDirectory())
|
|
397
|
+
size += await folderSize(full);
|
|
398
|
+
else if (entry.isFile())
|
|
399
|
+
size += (await stat(full)).size;
|
|
400
|
+
}
|
|
401
|
+
return size;
|
|
402
|
+
}
|
|
332
403
|
/** Enables or disables an installed extension in a scope; false when it isn't there. */
|
|
333
404
|
export async function setExtensionEnabled(scopeDir, name, enabled) {
|
|
334
405
|
const lock = await readLock(scopeDir);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ExtensionsStatus } from "./session.js";
|
|
2
|
+
import type { SessionLive, StaticSessionFacts } from "./sessionFacts.js";
|
|
3
|
+
import type { ToolLabels } from "./toolLabels.js";
|
|
4
|
+
/**
|
|
5
|
+
* Turning an installed extension off and on in a running session (#49), without reopening it.
|
|
6
|
+
* Only one that was running when the session opened: its MCP servers are toggled
|
|
7
|
+
* (`toggleMcpServer()`, which removes their tools from the model's context and brings them back,
|
|
8
|
+
* confirmed empirically) and its plugin, mounted in the run's folder, is unmounted or mounted again
|
|
9
|
+
* before `reloadPlugins()` (its skills, commands and subagents go and come back, confirmed
|
|
10
|
+
* empirically, through a link too). The kit's own lists follow: the plan gate's read-only tools, the subagent gates'
|
|
11
|
+
* allowed types, the chat's labels, `/extensions` and what `about_me` says. One that wasn't
|
|
12
|
+
* running has no plugin path the session knows: it takes reopening the session.
|
|
13
|
+
*
|
|
14
|
+
* Not through `setMcpServers()`: it leaves the servers given at start alone (confirmed
|
|
15
|
+
* empirically: removing one from its set removes nothing).
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Mounts an installed extension's plugin at `at`, in a run's folder, for the session to load from
|
|
19
|
+
* there: a link to it (a junction on Windows, which needs no privilege), so nothing is copied and
|
|
20
|
+
* its `node_modules` costs nothing; a copy where a link can't be made. The CLI loads a plugin
|
|
21
|
+
* through a link, and `reloadPlugins()` unloads it when the link goes and loads it when it's back
|
|
22
|
+
* (confirmed empirically).
|
|
23
|
+
*/
|
|
24
|
+
export declare function mountPlugin(from: string, at: string): Promise<"link" | "copy">;
|
|
25
|
+
/**
|
|
26
|
+
* Takes a mounted plugin away: a link is removed, never what it points at (its contents are the
|
|
27
|
+
* installed extension); a copy is emptied, so the path stays for mounting it again.
|
|
28
|
+
*/
|
|
29
|
+
export declare function unmountPlugin(at: string): Promise<void>;
|
|
30
|
+
/** What an installed extension brought to this session, to take it away and bring it back. */
|
|
31
|
+
export interface LiveExtension {
|
|
32
|
+
name: string;
|
|
33
|
+
/** Its plugin as installed (in its scope's folder, or where it's linked): what's mounted in the run's folder. */
|
|
34
|
+
installedDir: string;
|
|
35
|
+
/** Its scope's folder, where its lock is. */
|
|
36
|
+
scopeDir: string;
|
|
37
|
+
/** Where its plugin is mounted in the run's folder for this session (`mountPlugin()`). */
|
|
38
|
+
pluginMount: string;
|
|
39
|
+
servers: string[];
|
|
40
|
+
readOnlyTools: string[];
|
|
41
|
+
toolLabels: ToolLabels;
|
|
42
|
+
/** Its subagents, as the type gate names them (`<plugin>:<name>`). */
|
|
43
|
+
agents: string[];
|
|
44
|
+
description: string;
|
|
45
|
+
provides: string[];
|
|
46
|
+
help: string[];
|
|
47
|
+
}
|
|
48
|
+
/** The session's own lists the switch keeps in step: the same objects the gates, the chat and the view read. */
|
|
49
|
+
export interface LiveTargets {
|
|
50
|
+
readOnlyTools: string[];
|
|
51
|
+
allowedSubagentTypes: string[];
|
|
52
|
+
toolLabels: ToolLabels;
|
|
53
|
+
status: ExtensionsStatus;
|
|
54
|
+
facts: StaticSessionFacts;
|
|
55
|
+
/** The running session's controls, once it has started. */
|
|
56
|
+
controls: () => SessionLive;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Turns `name` on or off in the running session; false when it can't be done here (it wasn't
|
|
60
|
+
* running when the session opened, the session hasn't started, or its files changed since it
|
|
61
|
+
* was installed), and the session must be opened again.
|
|
62
|
+
*/
|
|
63
|
+
export type ExtensionSwitch = (name: string, enabled: boolean) => Promise<boolean>;
|
|
64
|
+
/** The switch for a session's installed extensions. */
|
|
65
|
+
export declare function createExtensionSwitch(extensions: readonly LiveExtension[], targets: LiveTargets): ExtensionSwitch;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { cp, lstat, mkdir, readdir, rm, symlink, unlink } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { hashExtension, listInstalled, readLock } from "./externalExtensions.js";
|
|
4
|
+
/**
|
|
5
|
+
* Turning an installed extension off and on in a running session (#49), without reopening it.
|
|
6
|
+
* Only one that was running when the session opened: its MCP servers are toggled
|
|
7
|
+
* (`toggleMcpServer()`, which removes their tools from the model's context and brings them back,
|
|
8
|
+
* confirmed empirically) and its plugin, mounted in the run's folder, is unmounted or mounted again
|
|
9
|
+
* before `reloadPlugins()` (its skills, commands and subagents go and come back, confirmed
|
|
10
|
+
* empirically, through a link too). The kit's own lists follow: the plan gate's read-only tools, the subagent gates'
|
|
11
|
+
* allowed types, the chat's labels, `/extensions` and what `about_me` says. One that wasn't
|
|
12
|
+
* running has no plugin path the session knows: it takes reopening the session.
|
|
13
|
+
*
|
|
14
|
+
* Not through `setMcpServers()`: it leaves the servers given at start alone (confirmed
|
|
15
|
+
* empirically: removing one from its set removes nothing).
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Mounts an installed extension's plugin at `at`, in a run's folder, for the session to load from
|
|
19
|
+
* there: a link to it (a junction on Windows, which needs no privilege), so nothing is copied and
|
|
20
|
+
* its `node_modules` costs nothing; a copy where a link can't be made. The CLI loads a plugin
|
|
21
|
+
* through a link, and `reloadPlugins()` unloads it when the link goes and loads it when it's back
|
|
22
|
+
* (confirmed empirically).
|
|
23
|
+
*/
|
|
24
|
+
export async function mountPlugin(from, at) {
|
|
25
|
+
// Whatever was there goes: a link (never what it points at), or a copy, whole.
|
|
26
|
+
const found = await lstat(at).catch(() => undefined);
|
|
27
|
+
if (found?.isSymbolicLink())
|
|
28
|
+
await unlink(at);
|
|
29
|
+
else if (found)
|
|
30
|
+
await rm(at, { recursive: true, force: true });
|
|
31
|
+
await mkdir(path.dirname(at), { recursive: true });
|
|
32
|
+
try {
|
|
33
|
+
await symlink(from, at, process.platform === "win32" ? "junction" : "dir");
|
|
34
|
+
return "link";
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
await cp(from, at, { recursive: true });
|
|
38
|
+
return "copy";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Takes a mounted plugin away: a link is removed, never what it points at (its contents are the
|
|
43
|
+
* installed extension); a copy is emptied, so the path stays for mounting it again.
|
|
44
|
+
*/
|
|
45
|
+
export async function unmountPlugin(at) {
|
|
46
|
+
const found = await lstat(at).catch(() => undefined);
|
|
47
|
+
if (!found)
|
|
48
|
+
return;
|
|
49
|
+
if (found.isSymbolicLink())
|
|
50
|
+
await unlink(at);
|
|
51
|
+
else
|
|
52
|
+
for (const entry of await readdir(at))
|
|
53
|
+
await rm(path.join(at, entry), { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
/** The switch for a session's installed extensions. */
|
|
56
|
+
export function createExtensionSwitch(extensions, targets) {
|
|
57
|
+
const off = new Set();
|
|
58
|
+
return async (name, enabled) => {
|
|
59
|
+
const extension = extensions.find((candidate) => candidate.name === name);
|
|
60
|
+
const controls = targets.controls();
|
|
61
|
+
if (!extension || !controls.toggleMcpServer || !controls.reloadPlugins)
|
|
62
|
+
return false;
|
|
63
|
+
if (enabled === !off.has(name))
|
|
64
|
+
return true; // already as asked
|
|
65
|
+
if (enabled) {
|
|
66
|
+
// Its files as they were installed, or nothing: a changed one is for a new session to judge.
|
|
67
|
+
// A linked one's aren't checked: it's being developed, and a rebuild is why it's turned on again.
|
|
68
|
+
const lock = (await readLock(extension.scopeDir))[name];
|
|
69
|
+
if (!lock || (!lock.linked && lock.sha256 !== (await hashExtension(extension.installedDir))))
|
|
70
|
+
return false;
|
|
71
|
+
await mountPlugin(extension.installedDir, extension.pluginMount);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
await unmountPlugin(extension.pluginMount);
|
|
75
|
+
}
|
|
76
|
+
await controls.reloadPlugins();
|
|
77
|
+
for (const server of extension.servers)
|
|
78
|
+
await controls.toggleMcpServer(server, enabled);
|
|
79
|
+
if (enabled)
|
|
80
|
+
off.delete(name);
|
|
81
|
+
else
|
|
82
|
+
off.add(name);
|
|
83
|
+
follow(extension, enabled, targets);
|
|
84
|
+
targets.status.installed = await listInstalled(targets.status.dirs);
|
|
85
|
+
return true;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** The kit's lists, as the extension is now. */
|
|
89
|
+
function follow(extension, enabled, targets) {
|
|
90
|
+
const without = (list, items) => {
|
|
91
|
+
for (const item of items) {
|
|
92
|
+
const index = list.indexOf(item);
|
|
93
|
+
if (index >= 0)
|
|
94
|
+
list.splice(index, 1);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const { status, facts } = targets;
|
|
98
|
+
if (enabled) {
|
|
99
|
+
targets.readOnlyTools.push(...extension.readOnlyTools);
|
|
100
|
+
targets.allowedSubagentTypes.push(...extension.agents);
|
|
101
|
+
Object.assign(targets.toolLabels, extension.toolLabels);
|
|
102
|
+
status.active.push(extension.name);
|
|
103
|
+
status.about[extension.name] = { description: extension.description, provides: extension.provides };
|
|
104
|
+
status.inactive = status.inactive.filter((entry) => entry.name !== extension.name);
|
|
105
|
+
facts.extensions.active.push({ name: extension.name, description: extension.description, provides: extension.provides, servers: extension.servers, help: extension.help });
|
|
106
|
+
facts.extensions.inactive = facts.extensions.inactive.filter((entry) => entry.name !== extension.name);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
without(targets.readOnlyTools, extension.readOnlyTools);
|
|
110
|
+
without(targets.allowedSubagentTypes, extension.agents);
|
|
111
|
+
// The same object the chat reads its labels from: changed in place, readonly to everyone else.
|
|
112
|
+
for (const tool of Object.keys(extension.toolLabels))
|
|
113
|
+
delete targets.toolLabels[tool];
|
|
114
|
+
without(status.active, [extension.name]);
|
|
115
|
+
delete status.about[extension.name];
|
|
116
|
+
const reason = { name: extension.name, reason: "was turned off in this session" };
|
|
117
|
+
status.inactive.push(reason);
|
|
118
|
+
facts.extensions.active = facts.extensions.active.filter((entry) => entry.name !== extension.name);
|
|
119
|
+
facts.extensions.inactive.push(reason);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** Where a marketplace's plugin comes from, as `marketplace.json` says (Claude Code's forms). */
|
|
2
|
+
export type PluginSource = string | {
|
|
3
|
+
source: "github";
|
|
4
|
+
repo: string;
|
|
5
|
+
ref?: string;
|
|
6
|
+
sha?: string;
|
|
7
|
+
} | {
|
|
8
|
+
source: "url";
|
|
9
|
+
url: string;
|
|
10
|
+
ref?: string;
|
|
11
|
+
sha?: string;
|
|
12
|
+
} | {
|
|
13
|
+
source: "git-subdir";
|
|
14
|
+
url: string;
|
|
15
|
+
path: string;
|
|
16
|
+
ref?: string;
|
|
17
|
+
sha?: string;
|
|
18
|
+
} | {
|
|
19
|
+
source: string;
|
|
20
|
+
[field: string]: unknown;
|
|
21
|
+
};
|
|
22
|
+
/** A plugin a marketplace offers. */
|
|
23
|
+
export interface MarketplacePlugin {
|
|
24
|
+
name: string;
|
|
25
|
+
source: PluginSource;
|
|
26
|
+
description?: string;
|
|
27
|
+
version?: string;
|
|
28
|
+
category?: string;
|
|
29
|
+
tags?: string[];
|
|
30
|
+
keywords?: string[];
|
|
31
|
+
}
|
|
32
|
+
/** A marketplace's `marketplace.json`, as the kit reads it. */
|
|
33
|
+
export interface MarketplaceManifest {
|
|
34
|
+
name: string;
|
|
35
|
+
owner: {
|
|
36
|
+
name: string;
|
|
37
|
+
email?: string;
|
|
38
|
+
url?: string;
|
|
39
|
+
};
|
|
40
|
+
description?: string;
|
|
41
|
+
/** Where bare plugin sources resolve, relative to the marketplace's root. */
|
|
42
|
+
pluginRoot?: string;
|
|
43
|
+
plugins: MarketplacePlugin[];
|
|
44
|
+
}
|
|
45
|
+
/** A marketplace an agent knows. */
|
|
46
|
+
export interface KnownMarketplace {
|
|
47
|
+
name: string;
|
|
48
|
+
/** Where it came from: a folder, a git URL (`#ref`), or `owner/repo`. */
|
|
49
|
+
source: string;
|
|
50
|
+
/** The commit its copy was taken at, for a git one. */
|
|
51
|
+
commit?: string;
|
|
52
|
+
/** The agent's own: known without asking, and its plugins installed without a confirmation. */
|
|
53
|
+
official: boolean;
|
|
54
|
+
/** When its copy was taken, ISO 8601 in UTC. */
|
|
55
|
+
updated: string;
|
|
56
|
+
/** Its copy, inside the agent's extensions folder. */
|
|
57
|
+
dir: string;
|
|
58
|
+
manifest?: MarketplaceManifest;
|
|
59
|
+
}
|
|
60
|
+
/** Reads and checks a marketplace's `.claude-plugin/marketplace.json`. */
|
|
61
|
+
export declare function readMarketplaceManifest(root: string): Promise<MarketplaceManifest>;
|
|
62
|
+
/** Reads a marketplace from a folder, a git URL (`#ref`) or `owner/repo` without adding it: what to show before asking. */
|
|
63
|
+
export declare function inspectMarketplace(source: string): Promise<MarketplaceManifest>;
|
|
64
|
+
/**
|
|
65
|
+
* Adds a marketplace to the agent (its extensions folder, `dir`) from a folder, a git URL
|
|
66
|
+
* (`#ref`) or `owner/repo`, taking a copy; one with the same name is replaced. Asking the person
|
|
67
|
+
* first is the caller's: the kit's command asks them to type its name, unless it's `official`.
|
|
68
|
+
*/
|
|
69
|
+
export declare function addMarketplace(source: string, dir: string, options?: {
|
|
70
|
+
official?: boolean;
|
|
71
|
+
}): Promise<{
|
|
72
|
+
name: string;
|
|
73
|
+
replaced: boolean;
|
|
74
|
+
manifest: MarketplaceManifest;
|
|
75
|
+
commit?: string;
|
|
76
|
+
}>;
|
|
77
|
+
/** The marketplaces the agent knows, with their manifests when they can be read. */
|
|
78
|
+
export declare function listMarketplaces(dir: string): Promise<KnownMarketplace[]>;
|
|
79
|
+
/** Takes a fresh copy of a known marketplace from where it came from. */
|
|
80
|
+
export declare function updateMarketplace(dir: string, name: string): Promise<{
|
|
81
|
+
commit?: string;
|
|
82
|
+
}>;
|
|
83
|
+
/** Forgets a marketplace (its copy and its entry); the extensions installed from it stay. False when it wasn't known. */
|
|
84
|
+
export declare function removeMarketplace(dir: string, name: string): Promise<boolean>;
|
|
85
|
+
/** A plugin found in the known marketplaces: `<name>@<marketplace>`, or a name only one of them offers. */
|
|
86
|
+
export declare function findPlugin(dir: string, spec: string): Promise<{
|
|
87
|
+
marketplace: KnownMarketplace;
|
|
88
|
+
plugin: MarketplacePlugin;
|
|
89
|
+
}>;
|
|
90
|
+
/** Where a plugin comes from: a folder or git repository for `addExtension()`, or a download (npm, archive). */
|
|
91
|
+
export type ResolvedSource = {
|
|
92
|
+
source: string;
|
|
93
|
+
subdir?: string;
|
|
94
|
+
sha?: string;
|
|
95
|
+
} | {
|
|
96
|
+
npm: {
|
|
97
|
+
name: string;
|
|
98
|
+
version?: string;
|
|
99
|
+
registry?: string;
|
|
100
|
+
};
|
|
101
|
+
} | {
|
|
102
|
+
archive: {
|
|
103
|
+
url: string;
|
|
104
|
+
sha256?: string;
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
/** Where a plugin's source points: a folder of the marketplace's copy, a git repository, an npm package or a zip. */
|
|
108
|
+
export declare function resolvePluginSource(marketplace: KnownMarketplace, plugin: MarketplacePlugin): ResolvedSource;
|
|
109
|
+
/**
|
|
110
|
+
* Installs `<plugin>@<marketplace>` (or a plugin only one known marketplace offers) into a scope,
|
|
111
|
+
* locked with where it came from. Its name in the agent is its `plugin.json`'s, as for any extension.
|
|
112
|
+
*/
|
|
113
|
+
export declare function installFromMarketplace(dir: string, spec: string, scopeDir: string): Promise<{
|
|
114
|
+
name: string;
|
|
115
|
+
replaced: boolean;
|
|
116
|
+
commit?: string;
|
|
117
|
+
marketplace: string;
|
|
118
|
+
}>;
|