@falkenslab/agent-kit 0.20.0 → 0.22.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 +37 -7
- package/dist/chat/chatController.d.ts +59 -0
- package/dist/chat/chatController.js +209 -2
- package/dist/chat/extensions.d.ts +30 -2
- package/dist/chat/extensions.js +63 -4
- package/dist/chat/runs.d.ts +3 -1
- package/dist/chat/runs.js +1 -1
- package/dist/core/agentSpec.d.ts +11 -0
- package/dist/core/elicitation.d.ts +54 -0
- package/dist/core/elicitation.js +120 -0
- package/dist/core/extensionCatalog.d.ts +94 -0
- package/dist/core/extensionCatalog.js +228 -0
- package/dist/core/extensionOptions.d.ts +56 -0
- package/dist/core/extensionOptions.js +139 -0
- package/dist/core/extensions.d.ts +9 -1
- package/dist/core/extensions.js +2 -1
- package/dist/core/externalExtensions.d.ts +116 -13
- package/dist/core/externalExtensions.js +280 -34
- package/dist/core/hooks/planGate.d.ts +2 -0
- package/dist/core/hooks/planGate.js +3 -0
- package/dist/core/interaction.d.ts +7 -0
- package/dist/core/liveExtensions.d.ts +30 -5
- package/dist/core/liveExtensions.js +78 -5
- package/dist/core/marketplaces.d.ts +6 -2
- package/dist/core/marketplaces.js +6 -4
- package/dist/core/messages/de.js +33 -1
- package/dist/core/messages/en.d.ts +36 -0
- package/dist/core/messages/en.js +33 -1
- package/dist/core/messages/es.js +33 -1
- package/dist/core/messages/fr.js +33 -1
- package/dist/core/npxPackages.d.ts +48 -0
- package/dist/core/npxPackages.js +121 -0
- package/dist/core/pluginDownloads.d.ts +2 -1
- package/dist/core/pluginDownloads.js +2 -2
- package/dist/core/readOnlyHints.d.ts +13 -0
- package/dist/core/readOnlyHints.js +26 -0
- package/dist/core/runner.js +2 -0
- package/dist/core/session.d.ts +13 -1
- package/dist/core/session.js +44 -20
- package/dist/core/sessionFacts.d.ts +5 -1
- package/dist/core/workspaceTrust.d.ts +12 -0
- package/dist/core/workspaceTrust.js +60 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +5 -1
- package/dist/tui/extensionCommand.d.ts +13 -3
- package/dist/tui/extensionCommand.js +214 -37
- package/extensions/awareness/.claude-plugin/plugin.json +13 -5
- package/extensions/knowledge/.claude-plugin/plugin.json +13 -5
- package/extensions/memory/.claude-plugin/plugin.json +13 -5
- package/extensions/sources/.claude-plugin/plugin.json +13 -5
- package/package.json +7 -5
- package/schemas/agent-kit-metadata.schema.json +62 -0
- package/schemas/marketplace-catalog.schema.json +64 -0
|
@@ -9,7 +9,52 @@ import { getLanguage } from "./messages/index.js";
|
|
|
9
9
|
import { truncate } from "./toolLabels.js";
|
|
10
10
|
import { agentKitVersion } from "./version.js";
|
|
11
11
|
import { outsideArchive } from "./packaged.js";
|
|
12
|
+
import { extensionSecretsPath, missingOptions, optionsNamed, parseOptionValue, parseUserConfig, readOptionValues, removeSecrets, storeOptionValues } from "./extensionOptions.js";
|
|
13
|
+
import { isTrusted } from "./workspaceTrust.js";
|
|
14
|
+
import { cachedPackageDir, cachedPackageProblem, cachePackage, npxServer, packageCacheDir, packageSpec } from "./npxPackages.js";
|
|
12
15
|
export const LOCK_FILE = "extensions.lock.json";
|
|
16
|
+
/**
|
|
17
|
+
* An installed extension's data folder: `${CLAUDE_PLUGIN_DATA}` in its servers' arguments and
|
|
18
|
+
* variables (a browser profile, a cache), in its scope, outside its plugin and its hash, so it's
|
|
19
|
+
* kept across sessions and updates. Created when a session starts it; removing the extension
|
|
20
|
+
* asks before deleting it.
|
|
21
|
+
*/
|
|
22
|
+
export function extensionDataDir(scopeDir, name) {
|
|
23
|
+
return path.join(scopeDir, ".data", name);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Where a scope keeps what's the person's, not the workspace's: its extensions' data and secrets
|
|
27
|
+
* (#57). The agent's scope, its own folder; a project's, a folder of its own in the agent's
|
|
28
|
+
* (`<agent scope>/.projects/<project>-<hash>/`), so nothing private travels with a project. An
|
|
29
|
+
* agent without a scope of its own keeps it in the project's.
|
|
30
|
+
*/
|
|
31
|
+
export function scopePrivateDir(dirs, scope) {
|
|
32
|
+
const scopeDir = dirs[scope];
|
|
33
|
+
if (!scopeDir)
|
|
34
|
+
return undefined;
|
|
35
|
+
if (scope === "agent" || !dirs.agent)
|
|
36
|
+
return scopeDir;
|
|
37
|
+
const resolved = path.resolve(scopeDir);
|
|
38
|
+
const key = createHash("sha256").update(resolved.toLowerCase()).digest("hex").slice(0, 12);
|
|
39
|
+
return path.join(dirs.agent, ".projects", `${path.basename(path.dirname(resolved)) || "project"}-${key}`);
|
|
40
|
+
}
|
|
41
|
+
/** The agent's store of extensions' code, by content (#57): where a project's scope keeps its extensions' code. */
|
|
42
|
+
export function codeStoreDir(dirs) {
|
|
43
|
+
return dirs.agent ? path.join(dirs.agent, ".store") : undefined;
|
|
44
|
+
}
|
|
45
|
+
/** Where a stored extension's code is: by its name and its files' hash. */
|
|
46
|
+
export function storedCodeDir(store, name, sha256) {
|
|
47
|
+
return path.join(store, name, sha256.slice(0, 16));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* How an install into `scope` is done: its npx packages into the agent's cache, and a project's
|
|
51
|
+
* code into the agent's store when the agent has a scope of its own.
|
|
52
|
+
*/
|
|
53
|
+
export function installTarget(dirs, scope) {
|
|
54
|
+
const packages = packageCacheDir(dirs);
|
|
55
|
+
const store = scope === "project" ? codeStoreDir(dirs) : undefined;
|
|
56
|
+
return { ...(packages ? { packages } : {}), ...(store ? { store } : {}) };
|
|
57
|
+
}
|
|
13
58
|
/** A scope's lock: its extensions by name. */
|
|
14
59
|
export async function readLock(scopeDir) {
|
|
15
60
|
try {
|
|
@@ -39,7 +84,9 @@ export async function readExternalManifest(dir) {
|
|
|
39
84
|
const name = raw.name;
|
|
40
85
|
if (typeof name !== "string" || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name))
|
|
41
86
|
throw new Error(`${file}: "name" must be kebab-case.`);
|
|
42
|
-
|
|
87
|
+
// The kit's data, where Claude Code keeps a plugin's own (`metadata`, which it doesn't read): a
|
|
88
|
+
// top-level key would fail `claude plugin validate --strict` (#55).
|
|
89
|
+
const kit = (raw.metadata?.["agent-kit"] ?? {});
|
|
43
90
|
const text = (key) => (typeof raw[key] === "string" ? { [key]: raw[key] } : {});
|
|
44
91
|
const author = raw.author;
|
|
45
92
|
// `repository` may be a URL or `{ url }`, as in package.json.
|
|
@@ -60,11 +107,21 @@ export async function readExternalManifest(dir) {
|
|
|
60
107
|
...(kit.kit ? { kit: kit.kit } : {}),
|
|
61
108
|
needs: Array.isArray(kit.needs) ? kit.needs.filter((program) => typeof program === "string") : [],
|
|
62
109
|
servers: await pluginServers(dir, raw),
|
|
63
|
-
readOnlyTools: kit.readOnlyTools
|
|
110
|
+
...(Array.isArray(kit.readOnlyTools) ? { readOnlyTools: kit.readOnlyTools.filter((tool) => typeof tool === "string") } : {}),
|
|
64
111
|
labels: kit.labels ?? {},
|
|
65
112
|
...(kit.help ? { help: kit.help } : {}),
|
|
113
|
+
userConfig: parseUserConfig(raw.userConfig),
|
|
114
|
+
...catalogLook(kit.catalog),
|
|
66
115
|
};
|
|
67
116
|
}
|
|
117
|
+
/** A manifest's `catalog` (#59): only its `logo` and `banner`, when they're paths. */
|
|
118
|
+
function catalogLook(raw) {
|
|
119
|
+
if (!raw || typeof raw !== "object")
|
|
120
|
+
return {};
|
|
121
|
+
const { logo, banner } = raw;
|
|
122
|
+
const look = { ...(typeof logo === "string" ? { logo } : {}), ...(typeof banner === "string" ? { banner } : {}) };
|
|
123
|
+
return Object.keys(look).length ? { catalog: look } : {};
|
|
124
|
+
}
|
|
68
125
|
/**
|
|
69
126
|
* A plugin's MCP servers, where Claude Code looks for them: `mcpServers` in `plugin.json` (inline,
|
|
70
127
|
* or a path to a JSON file), else `.mcp.json` at its root, with or without the `mcpServers` key.
|
|
@@ -99,29 +156,56 @@ async function pluginServers(dir, raw) {
|
|
|
99
156
|
}
|
|
100
157
|
return servers;
|
|
101
158
|
}
|
|
102
|
-
/** `${CLAUDE_PLUGIN_ROOT}`
|
|
103
|
-
function expand(value,
|
|
104
|
-
return value.replace(/\$\{(\w+)\}/g, (_, name) =>
|
|
159
|
+
/** `${CLAUDE_PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_DATA}` and `${VAR}` (the agent's variable) in a server's values. */
|
|
160
|
+
function expand(value, folders) {
|
|
161
|
+
return value.replace(/\$\{(\w+)\}/g, (_, name) => name === "CLAUDE_PLUGIN_ROOT" ? folders.root : name === "CLAUDE_PLUGIN_DATA" ? folders.data : (process.env[name] ?? ""));
|
|
105
162
|
}
|
|
106
163
|
/**
|
|
107
|
-
* Why one of an extension's servers can't run through the launcher (
|
|
108
|
-
*
|
|
164
|
+
* Why one of an extension's servers can't run through the launcher (Node, its script first,
|
|
165
|
+
* inside the extension; or npx with a package at an exact version, from the cache), or how the
|
|
166
|
+
* kit starts it.
|
|
109
167
|
*/
|
|
110
|
-
function serverLaunch(name, server,
|
|
168
|
+
function serverLaunch(name, server, folders, packages = {}) {
|
|
169
|
+
const { root } = folders;
|
|
170
|
+
// Its folders as variables too, as Claude Code gives them to a plugin's servers.
|
|
171
|
+
const declared = Object.fromEntries(Object.entries(server.env ?? {}).map(([key, value]) => [key, expand(String(value), folders)]));
|
|
172
|
+
const env = { CLAUDE_PLUGIN_ROOT: root, CLAUDE_PLUGIN_DATA: folders.data, ...declared };
|
|
173
|
+
const npx = npxServer(server);
|
|
174
|
+
if (typeof npx === "string")
|
|
175
|
+
return `${npx} (its server "${name}")`;
|
|
176
|
+
if (npx) {
|
|
177
|
+
const locked = packages.locked?.[packageSpec(npx)];
|
|
178
|
+
if (!packages.cache || !locked)
|
|
179
|
+
return `runs ${packageSpec(npx)}, which wasn't fetched when it was installed: install it again`;
|
|
180
|
+
const entry = path.join(cachedPackageDir(packages.cache, npx.name, npx.version), locked.bin);
|
|
181
|
+
return { entry, args: npx.args.map((arg) => expand(arg, folders)), env };
|
|
182
|
+
}
|
|
111
183
|
if (!/^node(\.exe)?$/i.test(path.basename(server.command)))
|
|
112
|
-
return `runs its server "${name}" with "${server.command}": only Node servers can be installed`;
|
|
113
|
-
const [script, ...args] = (server.args ?? []).map((arg) => expand(arg,
|
|
184
|
+
return `runs its server "${name}" with "${server.command}": only Node servers, or npx with a package's exact version, can be installed`;
|
|
185
|
+
const [script, ...args] = (server.args ?? []).map((arg) => expand(arg, folders));
|
|
114
186
|
if (!script)
|
|
115
187
|
return `gives its server "${name}" no script to run`;
|
|
116
188
|
const entry = path.resolve(root, script);
|
|
117
189
|
if (path.relative(root, entry).startsWith(".."))
|
|
118
190
|
return `runs its server "${name}" from outside its folder (${script})`;
|
|
119
|
-
return { entry, args, env
|
|
191
|
+
return { entry, args, env };
|
|
120
192
|
}
|
|
121
|
-
/**
|
|
122
|
-
|
|
193
|
+
/**
|
|
194
|
+
* Why an extension's servers can't run, or nothing: each Node with its script there, or npx with
|
|
195
|
+
* a package at an exact version (in the cache, once `packages` is given: when it loads).
|
|
196
|
+
*/
|
|
197
|
+
async function serversProblem(manifest, root, packages) {
|
|
123
198
|
for (const [name, server] of Object.entries(manifest.servers)) {
|
|
124
|
-
const
|
|
199
|
+
const npx = npxServer(server);
|
|
200
|
+
if (typeof npx === "string")
|
|
201
|
+
return `${npx} (its server "${name}")`;
|
|
202
|
+
if (npx) {
|
|
203
|
+
const problem = packages && (await cachedPackageProblem(packages.cache, npx, packages.locked?.[packageSpec(npx)]));
|
|
204
|
+
if (problem)
|
|
205
|
+
return problem;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const launch = serverLaunch(name, server, { root, data: "" });
|
|
125
209
|
if (typeof launch === "string")
|
|
126
210
|
return launch;
|
|
127
211
|
if (!(await stat(launch.entry).catch(() => null))?.isFile())
|
|
@@ -129,7 +213,28 @@ async function serversProblem(manifest, root) {
|
|
|
129
213
|
}
|
|
130
214
|
return undefined;
|
|
131
215
|
}
|
|
132
|
-
/**
|
|
216
|
+
/** Fetches the packages an extension's npx servers run into the cache (#54), as its lock records them. */
|
|
217
|
+
async function fetchServerPackages(manifest, cache) {
|
|
218
|
+
const packages = {};
|
|
219
|
+
for (const server of Object.values(manifest.servers)) {
|
|
220
|
+
const npx = npxServer(server);
|
|
221
|
+
if (!npx || typeof npx === "string")
|
|
222
|
+
continue;
|
|
223
|
+
packages[packageSpec(npx)] = await cachePackage(cache, npx.name, npx.version);
|
|
224
|
+
}
|
|
225
|
+
return packages;
|
|
226
|
+
}
|
|
227
|
+
/** A text file's bytes with CRLF as LF (latin1 maps each byte to itself); a binary one's (a NUL byte in it) as they are. */
|
|
228
|
+
function withoutCarriageReturns(bytes) {
|
|
229
|
+
if (bytes.includes(0) || !bytes.includes(13))
|
|
230
|
+
return bytes;
|
|
231
|
+
return Buffer.from(bytes.toString("latin1").replace(/\r\n/g, "\n"), "latin1");
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* SHA-256 over an extension's files (their paths and contents, in order), `.git` aside. A text
|
|
235
|
+
* file's line endings don't count (CRLF is taken as LF): git checks the same commit out with CRLF
|
|
236
|
+
* on one computer and LF on another, and a workspace's lock must match on both (#57).
|
|
237
|
+
*/
|
|
133
238
|
export async function hashExtension(dir) {
|
|
134
239
|
const hash = createHash("sha256");
|
|
135
240
|
async function walk(relative) {
|
|
@@ -139,7 +244,7 @@ export async function hashExtension(dir) {
|
|
|
139
244
|
if (entry.isDirectory())
|
|
140
245
|
await walk(child);
|
|
141
246
|
else if (entry.isFile())
|
|
142
|
-
hash.update(`${child}\0`).update(await readFile(path.join(dir, child))).update("\0");
|
|
247
|
+
hash.update(`${child}\0`).update(withoutCarriageReturns(await readFile(path.join(dir, child)))).update("\0");
|
|
143
248
|
}
|
|
144
249
|
}
|
|
145
250
|
await walk("");
|
|
@@ -187,10 +292,12 @@ export async function listInstalled(dirs) {
|
|
|
187
292
|
const scopeDir = dirs[scope];
|
|
188
293
|
if (!scopeDir)
|
|
189
294
|
continue;
|
|
295
|
+
const privateDir = scopePrivateDir(dirs, scope);
|
|
296
|
+
const store = codeStoreDir(dirs);
|
|
190
297
|
for (const [name, lock] of Object.entries(await readLock(scopeDir))) {
|
|
191
|
-
const dir = path.join(scopeDir, name);
|
|
298
|
+
const dir = lock.linked ? lock.source : lock.stored && store ? storedCodeDir(store, name, lock.sha256) : path.join(scopeDir, name);
|
|
192
299
|
const manifest = await readExternalManifest(dir).catch(() => undefined);
|
|
193
|
-
installed.push({ name, scope, dir, lock, shadowed: installed.some((other) => other.name === name), ...(manifest ? { manifest } : {}) });
|
|
300
|
+
installed.push({ name, scope, scopeDir, dir, dataDir: extensionDataDir(privateDir, name), privateDir, lock, shadowed: installed.some((other) => other.name === name), ...(manifest ? { manifest } : {}) });
|
|
194
301
|
}
|
|
195
302
|
}
|
|
196
303
|
return installed;
|
|
@@ -213,46 +320,84 @@ function manifestToolLabels(manifest) {
|
|
|
213
320
|
}));
|
|
214
321
|
}
|
|
215
322
|
/** The extension an installed one makes: its servers started through the launcher, its rules from its manifest. */
|
|
216
|
-
function externalExtension(installed, manifest) {
|
|
323
|
+
function externalExtension(installed, manifest, cache) {
|
|
217
324
|
return {
|
|
218
325
|
name: manifest.name,
|
|
219
326
|
plugin: installed.dir,
|
|
220
327
|
external: true,
|
|
328
|
+
installedIn: installed.scopeDir,
|
|
221
329
|
async contribute() {
|
|
222
330
|
const servers = {};
|
|
331
|
+
if (Object.keys(manifest.servers).length)
|
|
332
|
+
await mkdir(installed.dataDir, { recursive: true });
|
|
223
333
|
for (const [name, server] of Object.entries(manifest.servers)) {
|
|
224
|
-
const launch = serverLaunch(name, server, installed.dir);
|
|
334
|
+
const launch = serverLaunch(name, server, { root: installed.dir, data: installed.dataDir }, { cache, locked: installed.lock.packages });
|
|
225
335
|
if (typeof launch === "string")
|
|
226
336
|
continue; // checked when it loaded
|
|
227
337
|
// Its `env` reaches it (the SDK merges it with the agent's); the launcher removes the rest.
|
|
228
338
|
// Inside Electron, process.execPath is the app itself: ELECTRON_RUN_AS_NODE makes it run
|
|
229
339
|
// the launcher as Node (the launcher removes it, with the rest, once it runs).
|
|
230
340
|
const env = process.versions.electron ? { ...launch.env, ELECTRON_RUN_AS_NODE: "1" } : launch.env;
|
|
231
|
-
|
|
341
|
+
// The launcher's own spec: the variables it keeps, and where its options' values are, read
|
|
342
|
+
// when it starts the server, so a changed value reaches a server started again (#55).
|
|
343
|
+
const spec = {
|
|
344
|
+
keep: Object.keys(launch.env),
|
|
345
|
+
...(Object.keys(manifest.userConfig).length
|
|
346
|
+
? { options: { lock: path.join(installed.scopeDir, LOCK_FILE), secrets: extensionSecretsPath(installed.privateDir, installed.name), name: installed.name, defaults: optionDefaults(manifest.userConfig) } }
|
|
347
|
+
: {}),
|
|
348
|
+
};
|
|
349
|
+
servers[name] = { type: "stdio", command: process.execPath, args: [extensionLauncherPath(), launch.entry, JSON.stringify(spec), ...launch.args], env };
|
|
232
350
|
}
|
|
233
351
|
return {
|
|
234
352
|
mcpServers: servers,
|
|
235
|
-
|
|
353
|
+
// Its manifest's list, or its servers' own word: the server is the code the kit trusts
|
|
354
|
+
// least, so its hints only fill the list's absence (ADR-025).
|
|
355
|
+
...(manifest.readOnlyTools
|
|
356
|
+
? { readOnlyTools: Object.keys(manifest.servers).flatMap((server) => manifest.readOnlyTools.map((tool) => `mcp__${server}__${tool}`)) }
|
|
357
|
+
: { readOnlyTools: [], readOnlyHintServers: Object.keys(servers) }),
|
|
236
358
|
toolLabels: manifestToolLabels(manifest),
|
|
237
359
|
...(manifest.help ? { helpLines: [manifest.help] } : {}),
|
|
238
360
|
};
|
|
239
361
|
},
|
|
240
362
|
};
|
|
241
363
|
}
|
|
364
|
+
/** Its options' defaults, for the launcher. */
|
|
365
|
+
function optionDefaults(declared) {
|
|
366
|
+
return Object.fromEntries(Object.entries(declared).flatMap(([key, option]) => (option.default === undefined ? [] : [[key, option.default]])));
|
|
367
|
+
}
|
|
368
|
+
/** Why its options keep it from running (a server naming one it doesn't declare, a required one without a value), or nothing. */
|
|
369
|
+
function optionsProblem(manifest, values) {
|
|
370
|
+
const named = optionsNamed(Object.values(manifest.servers).flatMap((server) => [server.command, ...(server.args ?? []), ...Object.values(server.env ?? {}).map(String)]));
|
|
371
|
+
const undeclared = named.filter((key) => !manifest.userConfig[key]);
|
|
372
|
+
if (undeclared.length)
|
|
373
|
+
return `names ${undeclared.map((key) => "${user_config." + key + "}").join(", ")}, which its userConfig doesn't declare`;
|
|
374
|
+
const missing = missingOptions(manifest.userConfig, values);
|
|
375
|
+
if (missing.length)
|
|
376
|
+
return `needs ${missing.map((key) => `${key} (${manifest.userConfig[key].title})`).join(", ")} set: \`extension config ${manifest.name} ${missing[0]}=<value>\``;
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
242
379
|
/** The installed extensions a session runs with, and those it can't (with why). Disabled ones are neither. */
|
|
243
380
|
export async function loadExternalExtensions(dirs) {
|
|
244
381
|
const extensions = [];
|
|
245
382
|
const off = [];
|
|
383
|
+
const untrusted = [];
|
|
384
|
+
const cache = packageCacheDir(dirs);
|
|
246
385
|
for (const installed of await listInstalled(dirs)) {
|
|
247
386
|
if (installed.shadowed || !installed.lock.enabled)
|
|
248
387
|
continue;
|
|
249
|
-
|
|
388
|
+
// A workspace's own: only once the person trusts it there (#57); the agent's never asks.
|
|
389
|
+
if (installed.scope === "project" && dirs.agent && !(await isTrusted(dirs.agent, installed.scopeDir, installed.name, installed.lock))) {
|
|
390
|
+
untrusted.push(installed);
|
|
391
|
+
off.push({ name: installed.name, reason: "comes with this workspace and isn't trusted here yet: `extension trust`, or the chat asks" });
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
const reason = await problem(installed, cache);
|
|
250
395
|
if (typeof reason === "string")
|
|
251
396
|
off.push({ name: installed.name, reason });
|
|
252
397
|
else
|
|
253
|
-
extensions.push(externalExtension(installed, reason));
|
|
398
|
+
extensions.push(externalExtension(installed, reason, cache));
|
|
254
399
|
}
|
|
255
|
-
return { extensions, off };
|
|
400
|
+
return { extensions, off, untrusted };
|
|
256
401
|
}
|
|
257
402
|
/** Why an installed extension can't load, or its manifest when it can. */
|
|
258
403
|
/** Whether a program is on the `PATH`, as a shell would find it (with `PATHEXT`'s extensions on Windows). */
|
|
@@ -268,10 +413,12 @@ export async function onPath(program, env = process.env) {
|
|
|
268
413
|
}
|
|
269
414
|
return false;
|
|
270
415
|
}
|
|
271
|
-
async function problem(installed) {
|
|
416
|
+
async function problem(installed, cache) {
|
|
272
417
|
let manifest;
|
|
273
418
|
try {
|
|
274
|
-
if ((await
|
|
419
|
+
if (installed.lock.stored && !(await stat(installed.dir).catch(() => null))?.isDirectory())
|
|
420
|
+
return "isn't on this computer yet (its workspace says which version it uses): `extension restore` gets it";
|
|
421
|
+
if (!installed.lock.linked && (await hashExtension(installed.dir)) !== installed.lock.sha256)
|
|
275
422
|
return "has files that changed since it was installed: install it again to trust them";
|
|
276
423
|
manifest = await readExternalManifest(installed.dir);
|
|
277
424
|
}
|
|
@@ -288,7 +435,10 @@ async function problem(installed) {
|
|
|
288
435
|
missing.push(program);
|
|
289
436
|
if (missing.length)
|
|
290
437
|
return `needs ${missing.join(", ")}, which ${missing.length === 1 ? "isn't" : "aren't"} on this computer (on the PATH)`;
|
|
291
|
-
|
|
438
|
+
const options = optionsProblem(manifest, await readOptionValues(installed.privateDir, installed.name, manifest.userConfig, installed.lock.options));
|
|
439
|
+
if (options)
|
|
440
|
+
return options;
|
|
441
|
+
return (await serversProblem(manifest, installed.dir, { cache, locked: installed.lock.packages })) ?? manifest;
|
|
292
442
|
}
|
|
293
443
|
const exec = promisify(execFile);
|
|
294
444
|
/** Whether `source` names a git repository rather than a folder. */
|
|
@@ -300,9 +450,14 @@ export function isGitSource(source) {
|
|
|
300
450
|
* a commit, and `subdir` for one inside it): copies its files (never runs a script), checks its
|
|
301
451
|
* manifest, and locks it, enabled, with the marketplace it came from when it did (`origin`, for a
|
|
302
452
|
* temporary folder, is what the lock says it came from). An extension with the same name in that
|
|
303
|
-
* scope is replaced.
|
|
453
|
+
* scope is replaced; its data folder stays. With `link`, a folder is registered where it is
|
|
454
|
+
* instead, to develop it: not copied, and its files not checked. The packages its npx servers
|
|
455
|
+
* run are fetched into `packages` (#54), the agent's package cache (`packageCacheDir()`; by
|
|
456
|
+
* default the scope's).
|
|
304
457
|
*/
|
|
305
458
|
export async function addExtension(source, scopeDir, options = {}) {
|
|
459
|
+
if (options.link && isGitSource(source))
|
|
460
|
+
throw new Error("Only a folder can be linked: a git repository is installed as a copy.");
|
|
306
461
|
let from = path.resolve(source);
|
|
307
462
|
let commit;
|
|
308
463
|
let clone;
|
|
@@ -326,16 +481,44 @@ export async function addExtension(source, scopeDir, options = {}) {
|
|
|
326
481
|
if (servers)
|
|
327
482
|
throw new Error(`${manifest.name} can't be installed: it ${servers}.`);
|
|
328
483
|
const target = path.join(scopeDir, manifest.name);
|
|
484
|
+
// Replacing what's installed there would delete the source itself.
|
|
485
|
+
if (path.resolve(from) === path.resolve(target))
|
|
486
|
+
throw new Error(`${from} is where ${manifest.name} is installed: install it from another folder.`);
|
|
487
|
+
const packages = await fetchServerPackages(manifest, options.packages ?? path.join(scopeDir, ".npm"));
|
|
488
|
+
const locked = Object.keys(packages).length ? { packages } : {};
|
|
329
489
|
const lock = await readLock(scopeDir);
|
|
330
490
|
const replaced = Boolean(lock[manifest.name]);
|
|
491
|
+
// Reinstalled, it keeps the values it had for the options it still declares, as it keeps its data.
|
|
492
|
+
const kept = Object.fromEntries(Object.entries(lock[manifest.name]?.options ?? {}).filter(([key]) => manifest.userConfig[key] && !manifest.userConfig[key].sensitive));
|
|
493
|
+
if (Object.keys(kept).length)
|
|
494
|
+
locked.options = kept;
|
|
331
495
|
await rm(target, { recursive: true, force: true });
|
|
332
496
|
await mkdir(scopeDir, { recursive: true });
|
|
497
|
+
const origin = options.origin ?? (isGitSource(source) ? source : from);
|
|
498
|
+
const provenance = { ...(commit ? { commit } : {}), ...(options.subdir ? { subdir: options.subdir } : {}), ...(options.marketplace ? { marketplace: options.marketplace } : {}) };
|
|
499
|
+
if (options.store && !options.link) {
|
|
500
|
+
// By content, in the agent's store: two workspaces with the same version share one copy.
|
|
501
|
+
const sha256 = await hashExtension(from);
|
|
502
|
+
const stored = storedCodeDir(options.store, manifest.name, sha256);
|
|
503
|
+
if (!(await stat(stored).catch(() => null))?.isDirectory() || (await hashExtension(stored)) !== sha256) {
|
|
504
|
+
await rm(stored, { recursive: true, force: true });
|
|
505
|
+
await cp(from, stored, { recursive: true, filter: (file) => path.basename(file) !== ".git" });
|
|
506
|
+
}
|
|
507
|
+
lock[manifest.name] = { source: origin, ...provenance, sha256, stored: true, ...locked, enabled: true, installed: new Date().toISOString() };
|
|
508
|
+
await writeLock(scopeDir, lock);
|
|
509
|
+
return { name: manifest.name, replaced, ...(commit ? { commit } : {}) };
|
|
510
|
+
}
|
|
511
|
+
if (options.link) {
|
|
512
|
+
lock[manifest.name] = { source: from, linked: true, sha256: "", ...locked, enabled: true, installed: new Date().toISOString() };
|
|
513
|
+
await writeLock(scopeDir, lock);
|
|
514
|
+
return { name: manifest.name, replaced };
|
|
515
|
+
}
|
|
333
516
|
await cp(from, target, { recursive: true, filter: (file) => path.basename(file) !== ".git" });
|
|
334
517
|
lock[manifest.name] = {
|
|
335
|
-
source:
|
|
336
|
-
...
|
|
337
|
-
...(options.marketplace ? { marketplace: options.marketplace } : {}),
|
|
518
|
+
source: origin,
|
|
519
|
+
...provenance,
|
|
338
520
|
sha256: await hashExtension(target),
|
|
521
|
+
...locked,
|
|
339
522
|
enabled: true,
|
|
340
523
|
installed: new Date().toISOString(),
|
|
341
524
|
};
|
|
@@ -347,16 +530,36 @@ export async function addExtension(source, scopeDir, options = {}) {
|
|
|
347
530
|
await rm(clone, { recursive: true, force: true });
|
|
348
531
|
}
|
|
349
532
|
}
|
|
350
|
-
/**
|
|
351
|
-
|
|
533
|
+
/**
|
|
534
|
+
* Removes an extension from a scope (its files and its lock entry; a linked one's folder is left
|
|
535
|
+
* where it is), and its data folder with `deleteData`; false when it wasn't there.
|
|
536
|
+
*/
|
|
537
|
+
export async function removeExtension(scopeDir, name, options = {}) {
|
|
538
|
+
const privateDir = options.privateDir ?? scopeDir;
|
|
352
539
|
const lock = await readLock(scopeDir);
|
|
353
540
|
if (!lock[name])
|
|
354
541
|
return false;
|
|
355
542
|
delete lock[name];
|
|
356
543
|
await rm(path.join(scopeDir, name), { recursive: true, force: true });
|
|
544
|
+
// A stored one's code stays in the store: another workspace may use the same version.
|
|
545
|
+
if (options.deleteData)
|
|
546
|
+
await rm(extensionDataDir(privateDir, name), { recursive: true, force: true });
|
|
547
|
+
await removeSecrets(privateDir, name);
|
|
357
548
|
await writeLock(scopeDir, lock);
|
|
358
549
|
return true;
|
|
359
550
|
}
|
|
551
|
+
/** The size in bytes of the files under `dir` (0 when it doesn't exist). */
|
|
552
|
+
export async function folderSize(dir) {
|
|
553
|
+
let size = 0;
|
|
554
|
+
for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
555
|
+
const full = path.join(dir, entry.name);
|
|
556
|
+
if (entry.isDirectory())
|
|
557
|
+
size += await folderSize(full);
|
|
558
|
+
else if (entry.isFile())
|
|
559
|
+
size += (await stat(full)).size;
|
|
560
|
+
}
|
|
561
|
+
return size;
|
|
562
|
+
}
|
|
360
563
|
/** Enables or disables an installed extension in a scope; false when it isn't there. */
|
|
361
564
|
export async function setExtensionEnabled(scopeDir, name, enabled) {
|
|
362
565
|
const lock = await readLock(scopeDir);
|
|
@@ -366,3 +569,46 @@ export async function setExtensionEnabled(scopeDir, name, enabled) {
|
|
|
366
569
|
await writeLock(scopeDir, lock);
|
|
367
570
|
return true;
|
|
368
571
|
}
|
|
572
|
+
/**
|
|
573
|
+
* Sets an installed extension's options (#55), each from what the person typed (`undefined`
|
|
574
|
+
* clears it), checked against its `userConfig`: sensitive ones into the scope's secrets, the rest
|
|
575
|
+
* into its lock. A running session's servers take them when they start again.
|
|
576
|
+
*/
|
|
577
|
+
export async function setExtensionOptions(where, name, typed) {
|
|
578
|
+
// A scope's folder (the agent's, whose code and secrets are in it), or an installed one, which knows where they are.
|
|
579
|
+
const scopeDir = typeof where === "string" ? where : where.scopeDir;
|
|
580
|
+
const privateDir = typeof where === "string" ? where : where.privateDir;
|
|
581
|
+
const lock = await readLock(scopeDir);
|
|
582
|
+
const entry = lock[name];
|
|
583
|
+
if (!entry)
|
|
584
|
+
throw new Error(`${name} isn't installed in ${scopeDir}.`);
|
|
585
|
+
const manifest = (typeof where === "string" ? undefined : where.manifest) ?? (await readExternalManifest(entry.linked ? entry.source : path.join(scopeDir, name)));
|
|
586
|
+
const changes = {};
|
|
587
|
+
for (const [key, text] of Object.entries(typed)) {
|
|
588
|
+
const option = manifest.userConfig[key];
|
|
589
|
+
if (!option)
|
|
590
|
+
throw new Error(`${name} has no option "${key}"${Object.keys(manifest.userConfig).length ? `: it has ${Object.keys(manifest.userConfig).join(", ")}` : ""}.`);
|
|
591
|
+
try {
|
|
592
|
+
changes[key] = text === undefined ? undefined : parseOptionValue(option, text);
|
|
593
|
+
}
|
|
594
|
+
catch (error) {
|
|
595
|
+
throw new Error(`${key}: ${error instanceof Error ? error.message : String(error)}.`, { cause: error });
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const options = await storeOptionValues(privateDir, name, manifest.userConfig, entry.options ?? {}, changes);
|
|
599
|
+
lock[name] = { ...entry, options };
|
|
600
|
+
if (!Object.keys(options).length)
|
|
601
|
+
delete lock[name].options;
|
|
602
|
+
await writeLock(scopeDir, lock);
|
|
603
|
+
}
|
|
604
|
+
/** An installed extension's options, as they are now. */
|
|
605
|
+
export async function extensionOptionStates(installed) {
|
|
606
|
+
const declared = installed.manifest?.userConfig ?? {};
|
|
607
|
+
const values = await readOptionValues(installed.privateDir, installed.name, declared, installed.lock.options);
|
|
608
|
+
const secrets = await readOptionValues(installed.privateDir, installed.name, Object.fromEntries(Object.entries(declared).map(([key, option]) => [key, { ...option, default: undefined }])), installed.lock.options);
|
|
609
|
+
return Object.entries(declared).map(([key, option]) => {
|
|
610
|
+
const value = values[key];
|
|
611
|
+
const own = secrets[key];
|
|
612
|
+
return { key, option, ...(value !== undefined && !option.sensitive ? { value } : {}), set: value !== undefined && value !== "", isDefault: value !== undefined && own === undefined };
|
|
613
|
+
});
|
|
614
|
+
}
|
|
@@ -9,6 +9,8 @@ export interface PlanScope {
|
|
|
9
9
|
isReadOnlyTool?(toolName: string, input: Record<string, unknown>): boolean;
|
|
10
10
|
/** The enabled extensions' MCP tools that only read (`mcp__<server>__<tool>`). */
|
|
11
11
|
readOnlyTools?: readonly string[];
|
|
12
|
+
/** Whether an MCP tool's own server marks it `readOnlyHint`, for the extensions whose manifest lists none (readOnlyHints.ts). */
|
|
13
|
+
isReadOnlyHinted?(toolName: string): Promise<boolean>;
|
|
12
14
|
}
|
|
13
15
|
/**
|
|
14
16
|
* The reason to deny `toolName` with `input` in plan mode, or `undefined` to let it through.
|
|
@@ -41,6 +41,9 @@ export function createPlanGate(scope, isActive) {
|
|
|
41
41
|
const reason = checkPlanScope(scope, pre.tool_name, (pre.tool_input ?? {}));
|
|
42
42
|
if (!reason)
|
|
43
43
|
return {};
|
|
44
|
+
// Asked last, and only for an MCP tool nothing else let through: it's a call to the session.
|
|
45
|
+
if (pre.tool_name.startsWith("mcp__") && (await scope.isReadOnlyHinted?.(pre.tool_name)))
|
|
46
|
+
return {};
|
|
44
47
|
return {
|
|
45
48
|
hookSpecificOutput: {
|
|
46
49
|
hookEventName: pre.hook_event_name,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { FormAnswer, FormRequest } from "./elicitation.js";
|
|
1
2
|
/** What a checkpoint shows the person: a title, detail lines and, in a plain terminal, the question. */
|
|
2
3
|
export interface ApprovalPrompt {
|
|
3
4
|
title: string;
|
|
@@ -43,6 +44,12 @@ export interface InteractionPort {
|
|
|
43
44
|
* `askDecision()`, with the options numbered in the prompt.
|
|
44
45
|
*/
|
|
45
46
|
askChoice?(prompt: ApprovalPrompt, choice: ChoiceSettings, signal: AbortSignal): Promise<string>;
|
|
47
|
+
/**
|
|
48
|
+
* A form an MCP server asks for (elicitation, #58), whole: its fields, and the person's
|
|
49
|
+
* values, or that they declined or cancelled. Optional: without it the kit asks field by
|
|
50
|
+
* field through the other methods. Never written to a log: the answer is the server's.
|
|
51
|
+
*/
|
|
52
|
+
askForm?(form: FormRequest, signal: AbortSignal): Promise<FormAnswer>;
|
|
46
53
|
/** A one-way message for the person. */
|
|
47
54
|
notify(message: string): void;
|
|
48
55
|
}
|
|
@@ -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;
|
|
@@ -49,3 +63,14 @@ export interface LiveTargets {
|
|
|
49
63
|
export type ExtensionSwitch = (name: string, enabled: boolean) => Promise<boolean>;
|
|
50
64
|
/** The switch for a session's installed extensions. */
|
|
51
65
|
export declare function createExtensionSwitch(extensions: readonly LiveExtension[], targets: LiveTargets): ExtensionSwitch;
|
|
66
|
+
/**
|
|
67
|
+
* Restarts `name`'s MCP servers in the running session, so they take its options as they are now
|
|
68
|
+
* (#55): the launcher reads them when it starts a server. `reconnectMcpServer()` ends a server's
|
|
69
|
+
* process and starts a new one with the session's config (confirmed empirically; `setMcpServers()`
|
|
70
|
+
* replaced every server of the session and left the old process running). False when it can't
|
|
71
|
+
* be done here (not running in this session, or the session hasn't started): the next session
|
|
72
|
+
* takes them.
|
|
73
|
+
*/
|
|
74
|
+
export type ExtensionRestart = (name: string) => Promise<boolean>;
|
|
75
|
+
/** The restart for a session's installed extensions. */
|
|
76
|
+
export declare function createExtensionRestart(extensions: readonly LiveExtension[], targets: LiveTargets): ExtensionRestart;
|