@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.
Files changed (46) hide show
  1. package/assets/extension-launcher.mjs +9 -2
  2. package/dist/chat/chatController.d.ts +4 -1
  3. package/dist/chat/chatController.js +43 -17
  4. package/dist/chat/extensions.d.ts +7 -3
  5. package/dist/chat/extensions.js +12 -9
  6. package/dist/chat/runs.d.ts +3 -0
  7. package/dist/chat/runs.js +1 -1
  8. package/dist/core/agentSpec.d.ts +20 -56
  9. package/dist/core/extensions.d.ts +23 -12
  10. package/dist/core/extensions.js +8 -18
  11. package/dist/core/externalExtensions.d.ts +49 -9
  12. package/dist/core/externalExtensions.js +89 -18
  13. package/dist/core/liveExtensions.d.ts +65 -0
  14. package/dist/core/liveExtensions.js +121 -0
  15. package/dist/core/marketplaces.d.ts +118 -0
  16. package/dist/core/marketplaces.js +242 -0
  17. package/dist/core/messages/de.js +1 -0
  18. package/dist/core/messages/en.d.ts +2 -0
  19. package/dist/core/messages/en.js +1 -0
  20. package/dist/core/messages/es.js +1 -0
  21. package/dist/core/messages/fr.js +1 -0
  22. package/dist/core/pluginDownloads.d.ts +14 -0
  23. package/dist/core/pluginDownloads.js +141 -0
  24. package/dist/core/runner.js +6 -1
  25. package/dist/core/session.d.ts +11 -4
  26. package/dist/core/session.js +73 -21
  27. package/dist/core/sessionFacts.d.ts +5 -0
  28. package/dist/core/sessionFacts.js +1 -0
  29. package/dist/extensions/awareness/index.d.ts +12 -3
  30. package/dist/extensions/awareness/index.js +18 -15
  31. package/dist/extensions/awareness/tools.d.ts +1 -1
  32. package/dist/extensions/awareness/tools.js +3 -3
  33. package/dist/extensions/knowledge/index.d.ts +20 -6
  34. package/dist/extensions/knowledge/index.js +32 -29
  35. package/dist/extensions/knowledge/knowledgeStore.d.ts +1 -1
  36. package/dist/extensions/knowledge/prompt.d.ts +4 -4
  37. package/dist/extensions/knowledge/prompt.js +4 -4
  38. package/dist/extensions/memory/index.d.ts +15 -6
  39. package/dist/extensions/memory/index.js +34 -31
  40. package/dist/extensions/sources/index.d.ts +17 -5
  41. package/dist/extensions/sources/index.js +31 -29
  42. package/dist/index.d.ts +7 -2
  43. package/dist/index.js +6 -1
  44. package/dist/tui/extensionCommand.d.ts +14 -3
  45. package/dist/tui/extensionCommand.js +164 -12
  46. 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}` (the extension's folder) and `${VAR}` (the agent's variable) in a server's values. */
102
- function expand(value, root) {
103
- return value.replace(/\$\{(\w+)\}/g, (_, name) => (name === "CLAUDE_PLUGIN_ROOT" ? root : (process.env[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, root) {
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, root));
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
- return { entry, args, env: Object.fromEntries(Object.entries(server.env ?? {}).map(([key, value]) => [key, expand(String(value), root)])) };
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`, and `subdir`
280
- * for one inside it): copies its files (never runs a script), checks its manifest, and locks it,
281
- * enabled. An extension with the same name in that scope is replaced.
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
- await exec("git", ["clone", "--depth", "1", ...(ref ? ["--branch", ref] : []), url, clone]);
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
- /** Removes an extension from a scope (its files and its lock entry); false when it wasn't there. */
323
- export async function removeExtension(scopeDir, name) {
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
+ }>;