@falkenslab/agent-kit 0.20.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/extensions.js +1 -1
- package/dist/core/extensions.d.ts +2 -0
- package/dist/core/externalExtensions.d.ts +35 -7
- package/dist/core/externalExtensions.js +57 -14
- package/dist/core/liveExtensions.d.ts +19 -5
- package/dist/core/liveExtensions.js +56 -5
- package/dist/core/session.js +14 -14
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/tui/extensionCommand.d.ts +3 -1
- package/dist/tui/extensionCommand.js +36 -8
- package/package.json +1 -1
|
@@ -12,8 +12,15 @@ if (!entry) {
|
|
|
12
12
|
process.exit(1);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
// What any process may need to run, on Windows and elsewhere; never a credential.
|
|
16
|
-
|
|
15
|
+
// What any process may need to run, on Windows and elsewhere; never a credential. Where programs
|
|
16
|
+
// are installed and the desktop session too: a browser is found through PROGRAMFILES on Windows
|
|
17
|
+
// (Playwright found no Edge without them, #52) and opens a window through DISPLAY on Linux.
|
|
18
|
+
const SYSTEM = [
|
|
19
|
+
"PATH", "PATHEXT", "SYSTEMROOT", "SYSTEMDRIVE", "WINDIR", "COMSPEC", "TEMP", "TMP", "TMPDIR", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "LANG", "LC_ALL", "TZ",
|
|
20
|
+
"PROGRAMFILES", "PROGRAMFILES(X86)", "PROGRAMW6432", "COMMONPROGRAMFILES", "COMMONPROGRAMFILES(X86)", "COMMONPROGRAMW6432", "PROGRAMDATA", "ALLUSERSPROFILE",
|
|
21
|
+
"HOMEDRIVE", "HOMEPATH", "USERNAME", "USER", "LOGNAME", "OS", "PROCESSOR_ARCHITECTURE", "NUMBER_OF_PROCESSORS",
|
|
22
|
+
"DISPLAY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", "DBUS_SESSION_BUS_ADDRESS",
|
|
23
|
+
];
|
|
17
24
|
const allowed = new Set([...SYSTEM, ...JSON.parse(declared)].map((name) => name.toUpperCase()));
|
|
18
25
|
for (const name of Object.keys(process.env)) {
|
|
19
26
|
if (!allowed.has(name.toUpperCase())) delete process.env[name];
|
package/dist/chat/extensions.js
CHANGED
|
@@ -3,7 +3,7 @@ import { setExtensionEnabled } from "../core/externalExtensions.js";
|
|
|
3
3
|
import { t } from "../core/messages/index.js";
|
|
4
4
|
/** One line for an installed extension: name and version, scope, on or off, its author, where it came from. */
|
|
5
5
|
export function describe(extension) {
|
|
6
|
-
const state = extension.shadowed ? "not used (the project's wins)" : extension.lock.enabled ? "enabled" : "disabled"
|
|
6
|
+
const state = `${extension.shadowed ? "not used (the project's wins)" : extension.lock.enabled ? "enabled" : "disabled"}${extension.lock.linked ? ", linked" : ""}`;
|
|
7
7
|
// From a marketplace, its name there (`jokebook@shipyard`): the copy it came from is the kit's business.
|
|
8
8
|
const where = extension.lock.commit ? `${extension.lock.source} @ ${extension.lock.commit.slice(0, 12)}` : path.normalize(extension.lock.source);
|
|
9
9
|
const source = extension.lock.marketplace ? `${extension.lock.marketplace}${extension.lock.commit ? ` @ ${extension.lock.commit.slice(0, 12)}` : ""}` : where;
|
|
@@ -82,6 +82,8 @@ export interface Extension {
|
|
|
82
82
|
plugin: string;
|
|
83
83
|
/** Installed rather than shipped in a package (#37): its subagents get no `Bash`, and no plugin hook runs. */
|
|
84
84
|
external?: boolean;
|
|
85
|
+
/** An installed one's scope folder, where its lock is (its plugin is elsewhere when it's linked). */
|
|
86
|
+
installedIn?: string;
|
|
85
87
|
/** Why it can't run in this session (e.g. "needs a folder (`dir`)"), or `undefined` when it can. */
|
|
86
88
|
missing?(context: ExtensionContext): string | undefined;
|
|
87
89
|
/** What it brings to the session, once it's active. */
|
|
@@ -9,7 +9,9 @@ import { type ToolPhrase } from "./messages/index.js";
|
|
|
9
9
|
* extension is a Claude Code plugin as it is (#38): its MCP servers come from its `.mcp.json`,
|
|
10
10
|
* and the kit starts each with Node through a launcher that leaves it only the system's
|
|
11
11
|
* variables and its own `env`. The `"agent-kit"` key of its `plugin.json` is optional: it adds
|
|
12
|
-
* what the kit's gates and chat can use (capabilities, read-only tools, labels, help).
|
|
12
|
+
* what the kit's gates and chat can use (capabilities, read-only tools, labels, help). Each has a
|
|
13
|
+
* data folder of its own (`${CLAUDE_PLUGIN_DATA}`, as Claude Code gives a plugin), kept across
|
|
14
|
+
* sessions and updates; one being developed is linked rather than copied (#52).
|
|
13
15
|
*/
|
|
14
16
|
/** The two folders an agent installs extensions into; either may be left out. */
|
|
15
17
|
export interface ExtensionDirs {
|
|
@@ -24,7 +26,7 @@ export type ExtensionScope = keyof ExtensionDirs;
|
|
|
24
26
|
export interface PluginServer {
|
|
25
27
|
command: string;
|
|
26
28
|
args?: string[];
|
|
27
|
-
/** Its variables: a value may take the agent's with `${VAR}`. */
|
|
29
|
+
/** Its variables: a value may take the agent's with `${VAR}`, and its folders with `${CLAUDE_PLUGIN_ROOT}` and `${CLAUDE_PLUGIN_DATA}`. */
|
|
28
30
|
env?: Record<string, string>;
|
|
29
31
|
}
|
|
30
32
|
/** How the chat shows one of its tools in one language: `{field}` takes the call's input. */
|
|
@@ -68,19 +70,31 @@ export interface ExternalManifest {
|
|
|
68
70
|
}
|
|
69
71
|
/** One extension in a scope's lock. */
|
|
70
72
|
export interface LockEntry {
|
|
71
|
-
/** Where it came from: a folder, or a git URL (with `#ref` when given). */
|
|
73
|
+
/** Where it came from: a folder, or a git URL (with `#ref` when given). A linked one runs from there. */
|
|
72
74
|
source: string;
|
|
75
|
+
/**
|
|
76
|
+
* Linked rather than copied (`extension add --link`), to develop it: it runs from its folder,
|
|
77
|
+
* whose files aren't checked, so a rebuild takes effect when its server starts again.
|
|
78
|
+
*/
|
|
79
|
+
linked?: boolean;
|
|
73
80
|
/** The commit it was cloned at, for a git source. */
|
|
74
81
|
commit?: string;
|
|
75
82
|
/** The marketplace it was installed from (`<name>@<marketplace>`), if any. */
|
|
76
83
|
marketplace?: string;
|
|
77
|
-
/** SHA-256 over its files, checked when it loads. */
|
|
84
|
+
/** SHA-256 over its files, checked when it loads; empty for a linked one. */
|
|
78
85
|
sha256: string;
|
|
79
86
|
enabled: boolean;
|
|
80
87
|
/** When it was installed, ISO 8601 in UTC. */
|
|
81
88
|
installed: string;
|
|
82
89
|
}
|
|
83
90
|
export declare const LOCK_FILE = "extensions.lock.json";
|
|
91
|
+
/**
|
|
92
|
+
* An installed extension's data folder: `${CLAUDE_PLUGIN_DATA}` in its servers' arguments and
|
|
93
|
+
* variables (a browser profile, a cache), in its scope, outside its plugin and its hash, so it's
|
|
94
|
+
* kept across sessions and updates. Created when a session starts it; removing the extension
|
|
95
|
+
* asks before deleting it.
|
|
96
|
+
*/
|
|
97
|
+
export declare function extensionDataDir(scopeDir: string, name: string): string;
|
|
84
98
|
/** A scope's lock: its extensions by name. */
|
|
85
99
|
export declare function readLock(scopeDir: string): Promise<Record<string, LockEntry>>;
|
|
86
100
|
/** Reads an extension's manifest, checking what the kit needs from it. */
|
|
@@ -96,7 +110,12 @@ export declare function inKitRange(version: string, range: string): boolean;
|
|
|
96
110
|
export interface InstalledExtension {
|
|
97
111
|
name: string;
|
|
98
112
|
scope: ExtensionScope;
|
|
113
|
+
/** Its scope's folder, where its lock is. */
|
|
114
|
+
scopeDir: string;
|
|
115
|
+
/** Its plugin: in its scope's folder, or where it is when it's linked. */
|
|
99
116
|
dir: string;
|
|
117
|
+
/** Its data folder (`${CLAUDE_PLUGIN_DATA}`), which may not exist yet. */
|
|
118
|
+
dataDir: string;
|
|
100
119
|
lock: LockEntry;
|
|
101
120
|
/** Also installed in the project's scope, which wins: this one isn't used. */
|
|
102
121
|
shadowed: boolean;
|
|
@@ -125,19 +144,28 @@ export declare function isGitSource(source: string): boolean;
|
|
|
125
144
|
* a commit, and `subdir` for one inside it): copies its files (never runs a script), checks its
|
|
126
145
|
* manifest, and locks it, enabled, with the marketplace it came from when it did (`origin`, for a
|
|
127
146
|
* temporary folder, is what the lock says it came from). An extension with the same name in that
|
|
128
|
-
* scope is replaced.
|
|
147
|
+
* scope is replaced; its data folder stays. With `link`, a folder is registered where it is
|
|
148
|
+
* instead, to develop it: not copied, and its files not checked.
|
|
129
149
|
*/
|
|
130
150
|
export declare function addExtension(source: string, scopeDir: string, options?: {
|
|
131
151
|
subdir?: string;
|
|
132
152
|
sha?: string;
|
|
133
153
|
marketplace?: string;
|
|
134
154
|
origin?: string;
|
|
155
|
+
link?: boolean;
|
|
135
156
|
}): Promise<{
|
|
136
157
|
name: string;
|
|
137
158
|
replaced: boolean;
|
|
138
159
|
commit?: string;
|
|
139
160
|
}>;
|
|
140
|
-
/**
|
|
141
|
-
|
|
161
|
+
/**
|
|
162
|
+
* Removes an extension from a scope (its files and its lock entry; a linked one's folder is left
|
|
163
|
+
* where it is), and its data folder with `deleteData`; false when it wasn't there.
|
|
164
|
+
*/
|
|
165
|
+
export declare function removeExtension(scopeDir: string, name: string, options?: {
|
|
166
|
+
deleteData?: boolean;
|
|
167
|
+
}): Promise<boolean>;
|
|
168
|
+
/** The size in bytes of the files under `dir` (0 when it doesn't exist). */
|
|
169
|
+
export declare function folderSize(dir: string): Promise<number>;
|
|
142
170
|
/** Enables or disables an installed extension in a scope; false when it isn't there. */
|
|
143
171
|
export declare function setExtensionEnabled(scopeDir: string, name: string, enabled: boolean): Promise<boolean>;
|
|
@@ -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 {
|
|
@@ -99,29 +108,32 @@ async function pluginServers(dir, raw) {
|
|
|
99
108
|
}
|
|
100
109
|
return servers;
|
|
101
110
|
}
|
|
102
|
-
/** `${CLAUDE_PLUGIN_ROOT}`
|
|
103
|
-
function expand(value,
|
|
104
|
-
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] ?? ""));
|
|
105
114
|
}
|
|
106
115
|
/**
|
|
107
116
|
* Why one of an extension's servers can't run through the launcher (only Node, its script
|
|
108
117
|
* first, inside the extension), or how the kit starts it.
|
|
109
118
|
*/
|
|
110
|
-
function serverLaunch(name, server,
|
|
119
|
+
function serverLaunch(name, server, folders) {
|
|
120
|
+
const { root } = folders;
|
|
111
121
|
if (!/^node(\.exe)?$/i.test(path.basename(server.command)))
|
|
112
122
|
return `runs its server "${name}" with "${server.command}": only Node servers can be installed`;
|
|
113
|
-
const [script, ...args] = (server.args ?? []).map((arg) => expand(arg,
|
|
123
|
+
const [script, ...args] = (server.args ?? []).map((arg) => expand(arg, folders));
|
|
114
124
|
if (!script)
|
|
115
125
|
return `gives its server "${name}" no script to run`;
|
|
116
126
|
const entry = path.resolve(root, script);
|
|
117
127
|
if (path.relative(root, entry).startsWith(".."))
|
|
118
128
|
return `runs its server "${name}" from outside its folder (${script})`;
|
|
119
|
-
|
|
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 } };
|
|
120
132
|
}
|
|
121
133
|
/** Why an extension's servers can't run, or nothing: each must be Node, with its script there. */
|
|
122
134
|
async function serversProblem(manifest, root) {
|
|
123
135
|
for (const [name, server] of Object.entries(manifest.servers)) {
|
|
124
|
-
const launch = serverLaunch(name, server, root);
|
|
136
|
+
const launch = serverLaunch(name, server, { root, data: "" });
|
|
125
137
|
if (typeof launch === "string")
|
|
126
138
|
return launch;
|
|
127
139
|
if (!(await stat(launch.entry).catch(() => null))?.isFile())
|
|
@@ -188,9 +200,9 @@ export async function listInstalled(dirs) {
|
|
|
188
200
|
if (!scopeDir)
|
|
189
201
|
continue;
|
|
190
202
|
for (const [name, lock] of Object.entries(await readLock(scopeDir))) {
|
|
191
|
-
const dir = path.join(scopeDir, name);
|
|
203
|
+
const dir = lock.linked ? lock.source : path.join(scopeDir, name);
|
|
192
204
|
const manifest = await readExternalManifest(dir).catch(() => undefined);
|
|
193
|
-
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 } : {}) });
|
|
194
206
|
}
|
|
195
207
|
}
|
|
196
208
|
return installed;
|
|
@@ -218,10 +230,13 @@ function externalExtension(installed, manifest) {
|
|
|
218
230
|
name: manifest.name,
|
|
219
231
|
plugin: installed.dir,
|
|
220
232
|
external: true,
|
|
233
|
+
installedIn: installed.scopeDir,
|
|
221
234
|
async contribute() {
|
|
222
235
|
const servers = {};
|
|
236
|
+
if (Object.keys(manifest.servers).length)
|
|
237
|
+
await mkdir(installed.dataDir, { recursive: true });
|
|
223
238
|
for (const [name, server] of Object.entries(manifest.servers)) {
|
|
224
|
-
const launch = serverLaunch(name, server, installed.dir);
|
|
239
|
+
const launch = serverLaunch(name, server, { root: installed.dir, data: installed.dataDir });
|
|
225
240
|
if (typeof launch === "string")
|
|
226
241
|
continue; // checked when it loaded
|
|
227
242
|
// Its `env` reaches it (the SDK merges it with the agent's); the launcher removes the rest.
|
|
@@ -271,7 +286,7 @@ export async function onPath(program, env = process.env) {
|
|
|
271
286
|
async function problem(installed) {
|
|
272
287
|
let manifest;
|
|
273
288
|
try {
|
|
274
|
-
if ((await hashExtension(installed.dir)) !== installed.lock.sha256)
|
|
289
|
+
if (!installed.lock.linked && (await hashExtension(installed.dir)) !== installed.lock.sha256)
|
|
275
290
|
return "has files that changed since it was installed: install it again to trust them";
|
|
276
291
|
manifest = await readExternalManifest(installed.dir);
|
|
277
292
|
}
|
|
@@ -300,9 +315,12 @@ export function isGitSource(source) {
|
|
|
300
315
|
* a commit, and `subdir` for one inside it): copies its files (never runs a script), checks its
|
|
301
316
|
* manifest, and locks it, enabled, with the marketplace it came from when it did (`origin`, for a
|
|
302
317
|
* temporary folder, is what the lock says it came from). An extension with the same name in that
|
|
303
|
-
* scope is replaced.
|
|
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.
|
|
304
320
|
*/
|
|
305
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.");
|
|
306
324
|
let from = path.resolve(source);
|
|
307
325
|
let commit;
|
|
308
326
|
let clone;
|
|
@@ -326,10 +344,18 @@ export async function addExtension(source, scopeDir, options = {}) {
|
|
|
326
344
|
if (servers)
|
|
327
345
|
throw new Error(`${manifest.name} can't be installed: it ${servers}.`);
|
|
328
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.`);
|
|
329
350
|
const lock = await readLock(scopeDir);
|
|
330
351
|
const replaced = Boolean(lock[manifest.name]);
|
|
331
352
|
await rm(target, { recursive: true, force: true });
|
|
332
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
|
+
}
|
|
333
359
|
await cp(from, target, { recursive: true, filter: (file) => path.basename(file) !== ".git" });
|
|
334
360
|
lock[manifest.name] = {
|
|
335
361
|
source: options.origin ?? (isGitSource(source) ? `${source}${options.subdir ? ` (${options.subdir})` : ""}` : from),
|
|
@@ -347,16 +373,33 @@ export async function addExtension(source, scopeDir, options = {}) {
|
|
|
347
373
|
await rm(clone, { recursive: true, force: true });
|
|
348
374
|
}
|
|
349
375
|
}
|
|
350
|
-
/**
|
|
351
|
-
|
|
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 = {}) {
|
|
352
381
|
const lock = await readLock(scopeDir);
|
|
353
382
|
if (!lock[name])
|
|
354
383
|
return false;
|
|
355
384
|
delete lock[name];
|
|
356
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 });
|
|
357
388
|
await writeLock(scopeDir, lock);
|
|
358
389
|
return true;
|
|
359
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
|
+
}
|
|
360
403
|
/** Enables or disables an installed extension in a scope; false when it isn't there. */
|
|
361
404
|
export async function setExtensionEnabled(scopeDir, name, enabled) {
|
|
362
405
|
const lock = await readLock(scopeDir);
|
|
@@ -5,23 +5,37 @@ import type { ToolLabels } from "./toolLabels.js";
|
|
|
5
5
|
* Turning an installed extension off and on in a running session (#49), without reopening it.
|
|
6
6
|
* Only one that was running when the session opened: its MCP servers are toggled
|
|
7
7
|
* (`toggleMcpServer()`, which removes their tools from the model's context and brings them back,
|
|
8
|
-
* confirmed empirically) and its plugin,
|
|
8
|
+
* confirmed empirically) and its plugin, mounted in the run's folder, is unmounted or mounted again
|
|
9
9
|
* before `reloadPlugins()` (its skills, commands and subagents go and come back, confirmed
|
|
10
|
-
* empirically). The kit's own lists follow: the plan gate's read-only tools, the subagent gates'
|
|
10
|
+
* empirically, through a link too). The kit's own lists follow: the plan gate's read-only tools, the subagent gates'
|
|
11
11
|
* allowed types, the chat's labels, `/extensions` and what `about_me` says. One that wasn't
|
|
12
12
|
* running has no plugin path the session knows: it takes reopening the session.
|
|
13
13
|
*
|
|
14
14
|
* Not through `setMcpServers()`: it leaves the servers given at start alone (confirmed
|
|
15
15
|
* empirically: removing one from its set removes nothing).
|
|
16
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>;
|
|
17
30
|
/** What an installed extension brought to this session, to take it away and bring it back. */
|
|
18
31
|
export interface LiveExtension {
|
|
19
32
|
name: string;
|
|
20
|
-
/**
|
|
33
|
+
/** Its plugin as installed (in its scope's folder, or where it's linked): what's mounted in the run's folder. */
|
|
21
34
|
installedDir: string;
|
|
35
|
+
/** Its scope's folder, where its lock is. */
|
|
22
36
|
scopeDir: string;
|
|
23
|
-
/**
|
|
24
|
-
|
|
37
|
+
/** Where its plugin is mounted in the run's folder for this session (`mountPlugin()`). */
|
|
38
|
+
pluginMount: string;
|
|
25
39
|
servers: string[];
|
|
26
40
|
readOnlyTools: string[];
|
|
27
41
|
toolLabels: ToolLabels;
|
|
@@ -1,6 +1,57 @@
|
|
|
1
|
-
import { cp, readdir, rm } from "node:fs/promises";
|
|
1
|
+
import { cp, lstat, mkdir, readdir, rm, symlink, unlink } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
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
|
+
}
|
|
4
55
|
/** The switch for a session's installed extensions. */
|
|
5
56
|
export function createExtensionSwitch(extensions, targets) {
|
|
6
57
|
const off = new Set();
|
|
@@ -13,14 +64,14 @@ export function createExtensionSwitch(extensions, targets) {
|
|
|
13
64
|
return true; // already as asked
|
|
14
65
|
if (enabled) {
|
|
15
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.
|
|
16
68
|
const lock = (await readLock(extension.scopeDir))[name];
|
|
17
|
-
if (!lock || lock.sha256 !== (await hashExtension(extension.installedDir)))
|
|
69
|
+
if (!lock || (!lock.linked && lock.sha256 !== (await hashExtension(extension.installedDir))))
|
|
18
70
|
return false;
|
|
19
|
-
await
|
|
71
|
+
await mountPlugin(extension.installedDir, extension.pluginMount);
|
|
20
72
|
}
|
|
21
73
|
else {
|
|
22
|
-
|
|
23
|
-
await rm(path.join(extension.pluginCopy, entry), { recursive: true, force: true });
|
|
74
|
+
await unmountPlugin(extension.pluginMount);
|
|
24
75
|
}
|
|
25
76
|
await controls.reloadPlugins();
|
|
26
77
|
for (const server of extension.servers)
|
package/dist/core/session.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createTranscriptLogger } from "./hooks/transcriptLogger.js";
|
|
@@ -22,7 +22,7 @@ import { replyLanguageInstruction } from "./language.js";
|
|
|
22
22
|
import { chooseLanguage } from "./messages/index.js";
|
|
23
23
|
import { createRunStore } from "./runs.js";
|
|
24
24
|
import { createSessionView, registerSessionView } from "./sessionFacts.js";
|
|
25
|
-
import { createExtensionSwitch } from "./liveExtensions.js";
|
|
25
|
+
import { createExtensionSwitch, mountPlugin } from "./liveExtensions.js";
|
|
26
26
|
import { agentKitVersion } from "./version.js";
|
|
27
27
|
/**
|
|
28
28
|
* Builds the `options` object passed to the Agent SDK's `query()` — everything about
|
|
@@ -81,15 +81,15 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
|
|
|
81
81
|
const enabledNames = new Set((spec.extensions ?? []).map((extension) => extension.name));
|
|
82
82
|
const resolved = resolveExtensions([...(spec.extensions ?? []), ...external.extensions.filter((extension) => !enabledNames.has(extension.name))], extensionContext);
|
|
83
83
|
resolved.inactive.push(...external.off, ...external.extensions.filter((extension) => enabledNames.has(extension.name)).map((extension) => ({ name: extension.name, reason: "has the name of one this agent already has" })));
|
|
84
|
-
// Each installed one's plugin,
|
|
85
|
-
//
|
|
84
|
+
// Each installed one's plugin, mounted in the run's folder (a link, nothing copied): the running
|
|
85
|
+
// session unmounts and mounts it to turn the extension off and on (liveExtensions.ts), never
|
|
86
|
+
// touching what's installed.
|
|
86
87
|
const installedDirs = new Map();
|
|
87
88
|
for (const { extension } of resolved.active.filter(({ extension }) => extension.external)) {
|
|
88
|
-
const
|
|
89
|
-
await
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
extension.plugin = copy;
|
|
89
|
+
const mount = path.join(runDir, "extensions", extension.name);
|
|
90
|
+
await mountPlugin(extension.plugin, mount);
|
|
91
|
+
installedDirs.set(extension.name, { dir: extension.plugin, scopeDir: extension.installedIn ?? path.dirname(extension.plugin) });
|
|
92
|
+
extension.plugin = mount;
|
|
93
93
|
}
|
|
94
94
|
const externalPlugins = new Set(resolved.active.filter(({ extension }) => extension.external).map(({ extension }) => path.resolve(extension.plugin)));
|
|
95
95
|
extensionContext.capabilities = resolved.capabilities;
|
|
@@ -359,16 +359,16 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
|
|
|
359
359
|
registerSessionView(sdkOptions, late.view);
|
|
360
360
|
// Turning an installed extension off and on while the session runs (#49).
|
|
361
361
|
const switchExtension = createExtensionSwitch(resolved.active.flatMap(({ extension, manifest }, index) => {
|
|
362
|
-
const
|
|
363
|
-
if (!
|
|
362
|
+
const installed = installedDirs.get(extension.name);
|
|
363
|
+
if (!installed)
|
|
364
364
|
return [];
|
|
365
365
|
const contribution = added[index];
|
|
366
366
|
return [
|
|
367
367
|
{
|
|
368
368
|
name: extension.name,
|
|
369
|
-
installedDir,
|
|
370
|
-
scopeDir:
|
|
371
|
-
|
|
369
|
+
installedDir: installed.dir,
|
|
370
|
+
scopeDir: installed.scopeDir,
|
|
371
|
+
pluginMount: extension.plugin,
|
|
372
372
|
servers: Object.keys(contribution?.mcpServers ?? {}),
|
|
373
373
|
readOnlyTools: contribution?.readOnlyTools ?? [],
|
|
374
374
|
toolLabels: contribution?.toolLabels ?? {},
|
package/dist/index.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export { sources, type SourcesOptions } from "./extensions/sources/index.js";
|
|
|
16
16
|
export { knowledge, type KnowledgeOptions } from "./extensions/knowledge/index.js";
|
|
17
17
|
export { memory, type MemoryOptions } from "./extensions/memory/index.js";
|
|
18
18
|
export { readExtensionManifest, type Extension, type ExtensionContext, type ExtensionContribution, type ExtensionManifest, type FolderOption, } from "./core/extensions.js";
|
|
19
|
-
export { addExtension, removeExtension, setExtensionEnabled, listInstalled, readExternalManifest, type ExtensionAuthor, type ExtensionDirs, type ExtensionScope, type ExternalManifest, type ExternalToolLabel, type PluginServer, type InstalledExtension, type LockEntry, } from "./core/externalExtensions.js";
|
|
19
|
+
export { addExtension, extensionDataDir, removeExtension, setExtensionEnabled, listInstalled, readExternalManifest, type ExtensionAuthor, type ExtensionDirs, type ExtensionScope, type ExternalManifest, type ExternalToolLabel, type PluginServer, type InstalledExtension, type LockEntry, } from "./core/externalExtensions.js";
|
|
20
20
|
export { addMarketplace, findPlugin, inspectMarketplace, installFromMarketplace, listMarketplaces, readMarketplaceManifest, removeMarketplace, resolvePluginSource, updateMarketplace, type KnownMarketplace, type MarketplaceManifest, type MarketplacePlugin, type PluginSource, } from "./core/marketplaces.js";
|
|
21
21
|
export { runExtensionCommand } from "./tui/extensionCommand.js";
|
|
22
22
|
export { agentKitVersion } from "./core/version.js";
|
package/dist/index.js
CHANGED
|
@@ -16,7 +16,7 @@ export { sources } from "./extensions/sources/index.js";
|
|
|
16
16
|
export { knowledge } from "./extensions/knowledge/index.js";
|
|
17
17
|
export { memory } from "./extensions/memory/index.js";
|
|
18
18
|
export { readExtensionManifest, } from "./core/extensions.js";
|
|
19
|
-
export { addExtension, removeExtension, setExtensionEnabled, listInstalled, readExternalManifest, } from "./core/externalExtensions.js";
|
|
19
|
+
export { addExtension, extensionDataDir, removeExtension, setExtensionEnabled, listInstalled, readExternalManifest, } from "./core/externalExtensions.js";
|
|
20
20
|
export { addMarketplace, findPlugin, inspectMarketplace, installFromMarketplace, listMarketplaces, readMarketplaceManifest, removeMarketplace, resolvePluginSource, updateMarketplace, } from "./core/marketplaces.js";
|
|
21
21
|
export { runExtensionCommand } from "./tui/extensionCommand.js";
|
|
22
22
|
export { agentKitVersion } from "./core/version.js";
|
|
@@ -9,7 +9,9 @@ export { chatExtensionsCommand, describe } from "../chat/extensions.js";
|
|
|
9
9
|
* - `extension list`
|
|
10
10
|
* - `extension info <name>`: its metadata, what it offers and where its README is
|
|
11
11
|
* - `extension add <folder | git URL[#ref] | plugin[@marketplace]> [--project] [--path <subfolder>] [--yes]`
|
|
12
|
-
* - `extension
|
|
12
|
+
* - `extension add <folder> --link [--project]`: registered where it is, to develop it (not copied, its files not checked)
|
|
13
|
+
* - `extension remove <name> [--project | --agent] [--yes | --keep-data]`: asks before deleting its data folder
|
|
14
|
+
* - `extension enable|disable <name> [--project | --agent]`
|
|
13
15
|
* - `extension search [words]`: what the known marketplaces offer
|
|
14
16
|
* - `extension marketplace add <folder | git URL[#ref] | owner/repo> [--yes]`, `list`, `update [name]`, `remove <name>`
|
|
15
17
|
*
|
|
@@ -2,7 +2,7 @@ import { stat } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { addMarketplace, findPlugin, inspectMarketplace, installFromMarketplace, listMarketplaces, removeMarketplace, updateMarketplace, } from "../core/marketplaces.js";
|
|
5
|
-
import { addExtension, isGitSource, listInstalled, removeExtension, setExtensionEnabled, } from "../core/externalExtensions.js";
|
|
5
|
+
import { addExtension, folderSize, isGitSource, listInstalled, removeExtension, setExtensionEnabled, } from "../core/externalExtensions.js";
|
|
6
6
|
import { describe } from "../chat/extensions.js";
|
|
7
7
|
export { chatExtensionsCommand, describe } from "../chat/extensions.js";
|
|
8
8
|
/**
|
|
@@ -14,7 +14,9 @@ export { chatExtensionsCommand, describe } from "../chat/extensions.js";
|
|
|
14
14
|
* - `extension list`
|
|
15
15
|
* - `extension info <name>`: its metadata, what it offers and where its README is
|
|
16
16
|
* - `extension add <folder | git URL[#ref] | plugin[@marketplace]> [--project] [--path <subfolder>] [--yes]`
|
|
17
|
-
* - `extension
|
|
17
|
+
* - `extension add <folder> --link [--project]`: registered where it is, to develop it (not copied, its files not checked)
|
|
18
|
+
* - `extension remove <name> [--project | --agent] [--yes | --keep-data]`: asks before deleting its data folder
|
|
19
|
+
* - `extension enable|disable <name> [--project | --agent]`
|
|
18
20
|
* - `extension search [words]`: what the known marketplaces offer
|
|
19
21
|
* - `extension marketplace add <folder | git URL[#ref] | owner/repo> [--yes]`, `list`, `update [name]`, `remove <name>`
|
|
20
22
|
*
|
|
@@ -92,6 +94,13 @@ export async function runExtensionCommand(argv, options) {
|
|
|
92
94
|
return fail(`This agent has no ${scope} scope for extensions.`);
|
|
93
95
|
// A plugin of a marketplace, unless it's a folder here or a git URL.
|
|
94
96
|
const isFolder = await stat(path.resolve(source)).then((found) => found.isDirectory(), () => false);
|
|
97
|
+
if (flag("link")) {
|
|
98
|
+
if (!isFolder)
|
|
99
|
+
return fail(`Only a folder can be linked: ${source} isn't one here.`);
|
|
100
|
+
const result = await addExtension(source, dir, { link: true });
|
|
101
|
+
write(`Linked ${result.name} in the ${scope} scope: it runs from ${path.resolve(source)}, whose files aren't checked. A rebuild takes effect when its server starts again (a new session, or \`/extensions disable\` and \`enable\` in the chat).`);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
95
104
|
if (!isFolder && !isGitSource(source) && /^[A-Za-z0-9][\w.-]*(@[A-Za-z0-9][\w.-]*)?$/.test(source)) {
|
|
96
105
|
await knownMarketplaces();
|
|
97
106
|
const { marketplace, plugin } = await findPlugin(marketsDir, source);
|
|
@@ -121,13 +130,18 @@ export async function runExtensionCommand(argv, options) {
|
|
|
121
130
|
return fail(`${name} isn't installed${askedScope ? ` in the ${askedScope} scope` : ""}. \`${command} extension list\` lists them.`);
|
|
122
131
|
if (found.length > 1 && !askedScope)
|
|
123
132
|
return fail(`${name} is installed in both scopes: say which, with --project or --agent.`);
|
|
124
|
-
const { scope } = found[0];
|
|
133
|
+
const { scope, dataDir } = found[0];
|
|
125
134
|
const dir = scopeDir(scope);
|
|
126
|
-
if (action
|
|
127
|
-
await removeExtension(dir, name);
|
|
128
|
-
else
|
|
135
|
+
if (action !== "remove") {
|
|
129
136
|
await setExtensionEnabled(dir, name, action === "enable");
|
|
130
|
-
|
|
137
|
+
write(`${name} ${action}d in the ${scope} scope. It applies from the next session (in the chat, \`/extensions\` reopens it).`);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
// Its data (a browser profile, a signed-in session) may be worth keeping for a reinstall: asked, unless said.
|
|
141
|
+
const size = await folderSize(dataDir);
|
|
142
|
+
const deleteData = size > 0 && !flag("keep-data") && (await confirmed(`Delete its data too (${dataDir}, ${formatSize(size)})? (y/N) `, (typed) => /^(y|yes|s|si|sí)$/i.test(typed)));
|
|
143
|
+
await removeExtension(dir, name, { deleteData });
|
|
144
|
+
write(`${name} removed in the ${scope} scope${size > 0 ? (deleteData ? ", with its data" : `; its data stays in ${dataDir}`) : ""}. It applies from the next session (in the chat, \`/extensions\` reopens it).`);
|
|
131
145
|
return true;
|
|
132
146
|
}
|
|
133
147
|
case "search": {
|
|
@@ -155,7 +169,9 @@ export async function runExtensionCommand(argv, options) {
|
|
|
155
169
|
" list what's installed, in which scope, on or off",
|
|
156
170
|
" info <name> its metadata, what it offers, its README",
|
|
157
171
|
" add <folder | git URL[#ref] | plugin[@marketplace]> [--project] [--path <subfolder>] [--yes]",
|
|
158
|
-
"
|
|
172
|
+
" add <folder> --link [--project] to develop one: runs from its folder, not copied nor checked",
|
|
173
|
+
" remove <name> [--project | --agent] [--yes | --keep-data]",
|
|
174
|
+
" enable|disable <name> [--project | --agent]",
|
|
159
175
|
" search [words] what the known marketplaces offer",
|
|
160
176
|
" marketplace add <folder | git URL[#ref] | owner/repo> [--yes]",
|
|
161
177
|
" marketplace list | update [name] | remove <name>",
|
|
@@ -252,6 +268,18 @@ async function info(extension) {
|
|
|
252
268
|
...field("Only read", manifest.readOnlyTools.join(", ") || undefined),
|
|
253
269
|
...field("Variables", Object.values(manifest.servers).flatMap((server) => Object.keys(server.env ?? {})).join(", ") || undefined),
|
|
254
270
|
...field("Installed", `${extension.scope} scope, ${extension.lock.enabled ? "enabled" : "disabled"}, on ${extension.lock.installed}, from ${describe(extension).split(" from ")[1]}`),
|
|
271
|
+
...field("Data", (await stat(extension.dataDir).catch(() => null))?.isDirectory() ? `${extension.dataDir} (${formatSize(await folderSize(extension.dataDir))})` : undefined),
|
|
255
272
|
...field("README", (await stat(readme).catch(() => null))?.isFile() ? readme : undefined),
|
|
256
273
|
];
|
|
257
274
|
}
|
|
275
|
+
/** A size in bytes, as a person reads it: `512 B`, `3.4 KB`, `120 MB`. */
|
|
276
|
+
function formatSize(bytes) {
|
|
277
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
278
|
+
let size = bytes;
|
|
279
|
+
let unit = 0;
|
|
280
|
+
while (size >= 1024 && unit < units.length - 1) {
|
|
281
|
+
size /= 1024;
|
|
282
|
+
unit++;
|
|
283
|
+
}
|
|
284
|
+
return `${unit === 0 ? size : size.toFixed(size < 10 ? 1 : 0)} ${units[unit]}`;
|
|
285
|
+
}
|