@falkenslab/agent-kit 0.21.0 → 0.23.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 (57) hide show
  1. package/README.md +1 -4
  2. package/assets/extension-launcher.mjs +28 -5
  3. package/dist/chat/chatController.d.ts +60 -0
  4. package/dist/chat/chatController.js +212 -2
  5. package/dist/chat/extensions.d.ts +36 -2
  6. package/dist/chat/extensions.js +87 -3
  7. package/dist/chat/runs.d.ts +3 -1
  8. package/dist/chat/runs.js +1 -1
  9. package/dist/core/agentSpec.d.ts +11 -0
  10. package/dist/core/elicitation.d.ts +54 -0
  11. package/dist/core/elicitation.js +120 -0
  12. package/dist/core/extensionCatalog.d.ts +103 -0
  13. package/dist/core/extensionCatalog.js +235 -0
  14. package/dist/core/extensionOptions.d.ts +56 -0
  15. package/dist/core/extensionOptions.js +139 -0
  16. package/dist/core/extensionRequirements.d.ts +66 -0
  17. package/dist/core/extensionRequirements.js +112 -0
  18. package/dist/core/extensions.d.ts +75 -2
  19. package/dist/core/extensions.js +78 -6
  20. package/dist/core/externalExtensions.d.ts +101 -11
  21. package/dist/core/externalExtensions.js +263 -51
  22. package/dist/core/hooks/planGate.d.ts +2 -0
  23. package/dist/core/hooks/planGate.js +3 -0
  24. package/dist/core/interaction.d.ts +7 -0
  25. package/dist/core/liveExtensions.d.ts +15 -0
  26. package/dist/core/liveExtensions.js +33 -2
  27. package/dist/core/marketplaces.d.ts +7 -2
  28. package/dist/core/marketplaces.js +6 -4
  29. package/dist/core/messages/de.js +37 -1
  30. package/dist/core/messages/en.d.ts +44 -0
  31. package/dist/core/messages/en.js +37 -1
  32. package/dist/core/messages/es.js +37 -1
  33. package/dist/core/messages/fr.js +37 -1
  34. package/dist/core/npxPackages.d.ts +48 -0
  35. package/dist/core/npxPackages.js +121 -0
  36. package/dist/core/pluginDownloads.d.ts +2 -1
  37. package/dist/core/pluginDownloads.js +2 -2
  38. package/dist/core/readOnlyHints.d.ts +13 -0
  39. package/dist/core/readOnlyHints.js +26 -0
  40. package/dist/core/runner.js +2 -0
  41. package/dist/core/session.d.ts +17 -5
  42. package/dist/core/session.js +44 -11
  43. package/dist/core/sessionFacts.d.ts +11 -1
  44. package/dist/core/workspaceTrust.d.ts +12 -0
  45. package/dist/core/workspaceTrust.js +60 -0
  46. package/dist/extensions/awareness/tools.js +6 -2
  47. package/dist/index.d.ts +9 -2
  48. package/dist/index.js +7 -2
  49. package/dist/tui/extensionCommand.d.ts +10 -2
  50. package/dist/tui/extensionCommand.js +199 -34
  51. package/extensions/awareness/.claude-plugin/plugin.json +13 -5
  52. package/extensions/knowledge/.claude-plugin/plugin.json +13 -5
  53. package/extensions/memory/.claude-plugin/plugin.json +13 -5
  54. package/extensions/sources/.claude-plugin/plugin.json +13 -5
  55. package/package.json +7 -5
  56. package/schemas/agent-kit-metadata.schema.json +120 -0
  57. package/schemas/marketplace-catalog.schema.json +74 -0
@@ -0,0 +1,54 @@
1
+ import type { ElicitationRequest, ElicitationResult } from "@anthropic-ai/claude-agent-sdk";
2
+ /**
3
+ * MCP elicitation in the kit's chats (#58): an MCP server asks the person for something in the
4
+ * middle of a tool (a form, or a page to open), and the answer goes back to the server, never
5
+ * through the model. The SDK hands each request to `onElicitation` (without one it declines
6
+ * them all); the kit's asks through the interaction port: a whole form when the port can show
7
+ * one (`askForm`, the web and desktop chats), field by field with its usual panels otherwise
8
+ * (the terminal), and the response file as for any checkpoint. Nothing the person answers is
9
+ * written to the transcript or the session log; an autonomous session, with no one to ask,
10
+ * declines.
11
+ */
12
+ /** One field of a form a server asks for, from its JSON Schema (MCP's flat, primitive one). */
13
+ export interface FormField {
14
+ key: string;
15
+ title: string;
16
+ description?: string;
17
+ type: "string" | "number" | "integer" | "boolean";
18
+ /** The values it may take (a choice), and what each is called, when the schema names them. */
19
+ options?: {
20
+ value: string;
21
+ label: string;
22
+ }[];
23
+ /** Several of `options` (an array of an enum). */
24
+ multiple?: boolean;
25
+ required: boolean;
26
+ default?: string | number | boolean | string[];
27
+ minimum?: number;
28
+ maximum?: number;
29
+ }
30
+ /** A form a server asks for, as the port shows it. */
31
+ export interface FormRequest {
32
+ /** Who asks: the MCP server's name (the person must always see it). */
33
+ server: string;
34
+ message: string;
35
+ fields: FormField[];
36
+ }
37
+ /** The person's answer to a form: its values when accepted. */
38
+ export type FormAnswer = {
39
+ action: "accept" | "decline" | "cancel";
40
+ content?: Record<string, string | number | boolean | string[]>;
41
+ };
42
+ /** A form's fields from MCP's `requestedSchema`: an object of primitive properties. */
43
+ export declare function formFields(schema: Record<string, unknown> | undefined): FormField[];
44
+ /** A typed value for `field` from what the person typed, or why it isn't one. */
45
+ export declare function fieldValue(field: FormField, typed: string): string | number | boolean | string[] | {
46
+ problem: string;
47
+ };
48
+ /**
49
+ * The session's `onElicitation`: in every mode but autonomous (`asks()`), the request goes to the
50
+ * person through the interaction port; otherwise, or with no port, it's declined.
51
+ */
52
+ export declare function createElicitationHandler(runDir: string, asks: () => boolean): (request: ElicitationRequest, options: {
53
+ signal: AbortSignal;
54
+ }) => Promise<ElicitationResult>;
@@ -0,0 +1,120 @@
1
+ import { askForChoice, askForDecision, askForManualIntervention, askForText } from "./hooks/humanInput.js";
2
+ import { getInteractionPort } from "./interaction.js";
3
+ import { t } from "./messages/index.js";
4
+ /** A form's fields from MCP's `requestedSchema`: an object of primitive properties. */
5
+ export function formFields(schema) {
6
+ const properties = (schema?.properties ?? {});
7
+ const required = new Set(Array.isArray(schema?.required) ? schema.required : []);
8
+ return Object.entries(properties).map(([key, property]) => {
9
+ const items = property.items;
10
+ const multiple = property.type === "array";
11
+ const choices = (multiple ? items : property) ?? {};
12
+ // A choice: `enum` (with `enumNames`), or `oneOf`/`anyOf` of `{ const, title }`.
13
+ const titled = (choices.oneOf ?? choices.anyOf);
14
+ const values = Array.isArray(choices.enum) ? choices.enum.map(String) : titled?.map((option) => String(option.const));
15
+ const names = Array.isArray(choices.enumNames) ? choices.enumNames.map(String) : titled?.map((option) => option.title ?? String(option.const));
16
+ const type = multiple ? "string" : (["number", "integer", "boolean"].includes(String(property.type)) ? property.type : "string");
17
+ return {
18
+ key,
19
+ title: typeof property.title === "string" ? property.title : key,
20
+ ...(typeof property.description === "string" ? { description: property.description } : {}),
21
+ type,
22
+ ...(values?.length ? { options: values.map((value, index) => ({ value, label: names?.[index] ?? value })) } : {}),
23
+ ...(multiple ? { multiple: true } : {}),
24
+ required: required.has(key),
25
+ ...(property.default !== undefined ? { default: property.default } : {}),
26
+ ...(typeof property.minimum === "number" ? { minimum: property.minimum } : {}),
27
+ ...(typeof property.maximum === "number" ? { maximum: property.maximum } : {}),
28
+ };
29
+ });
30
+ }
31
+ /** A typed value for `field` from what the person typed, or why it isn't one. */
32
+ export function fieldValue(field, typed) {
33
+ const text = typed.trim();
34
+ if (field.type === "boolean")
35
+ return /^(y|yes|true|1|s|si|sí|oui|ja)$/i.test(text);
36
+ if (field.type === "number" || field.type === "integer") {
37
+ const number = Number(text);
38
+ if (!text || Number.isNaN(number) || (field.type === "integer" && !Number.isInteger(number)))
39
+ return { problem: t().elicitNotANumber(text) };
40
+ if ((field.minimum !== undefined && number < field.minimum) || (field.maximum !== undefined && number > field.maximum))
41
+ return { problem: t().elicitOutOfRange(field.minimum, field.maximum) };
42
+ return number;
43
+ }
44
+ return text;
45
+ }
46
+ /** Asks for a form field by field, with the port's usual panels: a choice for options and yes/no, text for the rest. */
47
+ async function askFieldByField(runDir, request) {
48
+ const shown = await askForDecision(runDir, {
49
+ title: t().elicitTitle(request.server),
50
+ lines: [request.message, "", ...request.fields.map((field) => `- ${field.title}${field.required ? " *" : ""}${field.description ? `: ${field.description}` : ""}`)],
51
+ question: t().elicitQuestion,
52
+ });
53
+ if (shown === "n")
54
+ return { action: "decline" };
55
+ if (shown !== "y" && shown !== "")
56
+ return { action: "cancel" };
57
+ const content = {};
58
+ for (const field of request.fields) {
59
+ const prompt = { title: `${request.server}: ${field.title}${field.required ? " *" : ""}`, lines: field.description ? [field.description] : [] };
60
+ if (field.options || field.type === "boolean") {
61
+ const options = field.options ?? [
62
+ { value: "true", label: t().yes },
63
+ { value: "false", label: t().no },
64
+ ];
65
+ const answer = await askForChoice(runDir, prompt, options.map((option) => option.label), Boolean(field.multiple));
66
+ const chosen = answer.chosen.map((label) => options.find((option) => option.label === label).value);
67
+ if (!chosen.length) {
68
+ if (field.required)
69
+ return { action: "decline" };
70
+ continue;
71
+ }
72
+ content[field.key] = field.multiple ? chosen : field.type === "boolean" ? chosen[0] === "true" : field.type === "string" ? chosen[0] : Number(chosen[0]);
73
+ continue;
74
+ }
75
+ // Typed: asked again while it isn't a value of its type.
76
+ for (let tries = 0;; tries++) {
77
+ const typed = await askForText(runDir, tries ? { ...prompt, lines: [...prompt.lines, t().elicitAgain] } : prompt);
78
+ if (!typed.trim()) {
79
+ if (field.required)
80
+ return { action: "decline" };
81
+ break;
82
+ }
83
+ const value = fieldValue(field, typed);
84
+ if (typeof value === "object" && !Array.isArray(value)) {
85
+ if (tries >= 2)
86
+ return { action: "decline" };
87
+ continue;
88
+ }
89
+ content[field.key] = value;
90
+ break;
91
+ }
92
+ }
93
+ return { action: "accept", content };
94
+ }
95
+ /** A page a server asks the person to open (a sign-in elsewhere): shown, never opened for them; only https, or http on this computer. */
96
+ async function askToOpen(runDir, request) {
97
+ const url = request.url ?? "";
98
+ const safe = /^https:\/\//i.test(url) || /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i.test(url);
99
+ if (!safe)
100
+ return { action: "decline" };
101
+ const answer = await askForManualIntervention(runDir, { title: t().elicitUrlTitle(request.serverName), lines: [request.message, "", url], question: t().elicitUrlQuestion });
102
+ return answer === "n" || answer === "q" ? { action: "decline" } : { action: "accept" };
103
+ }
104
+ /**
105
+ * The session's `onElicitation`: in every mode but autonomous (`asks()`), the request goes to the
106
+ * person through the interaction port; otherwise, or with no port, it's declined.
107
+ */
108
+ export function createElicitationHandler(runDir, asks) {
109
+ return async (request, { signal }) => {
110
+ const port = getInteractionPort();
111
+ if (!asks() || !port)
112
+ return { action: "decline" };
113
+ if (request.mode === "url")
114
+ return await askToOpen(runDir, request);
115
+ const form = { server: request.displayName ?? request.serverName, message: request.message, fields: formFields(request.requestedSchema) };
116
+ if (port.askForm)
117
+ return await port.askForm(form, signal);
118
+ return await askFieldByField(runDir, form);
119
+ };
120
+ }
@@ -0,0 +1,103 @@
1
+ import { type ExtensionDirs, type ExtensionScope, type InstalledExtension } from "./externalExtensions.js";
2
+ import { type DescribedCapability } from "./extensions.js";
3
+ import { type KnownMarketplace, type MarketplacePlugin } from "./marketplaces.js";
4
+ /**
5
+ * The agent's catalog of extensions (#56): what its known marketplaces offer, what's installed
6
+ * from them and what has a newer version, and installing and updating from it. The kit's
7
+ * command and the chats (terminal, web, desktop) share it, so a person without a shell can do
8
+ * what `extension add`, `search` and `update` do.
9
+ */
10
+ /** Where an agent keeps its marketplaces: with its extensions (the project's when it has no scope of its own). */
11
+ export declare function marketplacesDir(dirs: ExtensionDirs): string | undefined;
12
+ /**
13
+ * The marketplaces the agent knows, its `official` one among them: added without asking the
14
+ * first time, and a folder one copied afresh each time (it's the agent's own code).
15
+ */
16
+ export declare function knownMarketplaces(dirs: ExtensionDirs, official?: string): Promise<KnownMarketplace[]>;
17
+ /** One extension a marketplace offers, and how it stands in the agent. */
18
+ export interface CatalogEntry {
19
+ /** `<name>@<marketplace>`, what installs it. */
20
+ spec: string;
21
+ marketplace: string;
22
+ /** From the agent's own marketplace: installed without a confirmation. */
23
+ official: boolean;
24
+ plugin: MarketplacePlugin;
25
+ /** The version it offers: its `plugin.json`'s when the marketplace has its folder, else the marketplace's entry's. */
26
+ version?: string;
27
+ /** Installed (and used: not shadowed), from wherever it came. */
28
+ installed?: InstalledExtension;
29
+ /** Installed from this marketplace with an older version than it offers. */
30
+ update: boolean;
31
+ /** What it's about and who it's for (#59): its `plugin.json`'s `keywords` and its entry's `tags`, `keywords` and `category`, lowercased. */
32
+ topics: string[];
33
+ /** Its logo and banner (#59), as files of the marketplace's copy, when it has them. */
34
+ logo?: string;
35
+ banner?: string;
36
+ /** What it lets the agent do (#62), in the kit's language: from its `plugin.json` when the marketplace has its folder. */
37
+ capabilities: DescribedCapability[];
38
+ /** The programs it needs on the computer (#63), by name ("Docker"), with the capabilities that need them (none: all of it). */
39
+ requirements: {
40
+ name: string;
41
+ for: string[];
42
+ }[];
43
+ }
44
+ /** Whether `offered` is newer than `installed` (x.y.z); a version that can't be compared isn't. */
45
+ export declare function isNewer(offered: string | undefined, installed: string | undefined): boolean;
46
+ /** Everything the known marketplaces offer, matching `words` (in its name, description, category, tags or keywords) when given. */
47
+ export declare function extensionCatalog(dirs: ExtensionDirs, options?: {
48
+ official?: string;
49
+ words?: readonly string[];
50
+ topics?: readonly string[];
51
+ }): Promise<CatalogEntry[]>;
52
+ /** Whether installing `spec` needs the person's confirmation first: a plugin of a marketplace that isn't the agent's own. */
53
+ export declare function needsConfirmation(dirs: ExtensionDirs, spec: string, official?: string): Promise<{
54
+ marketplace: KnownMarketplace;
55
+ plugin: MarketplacePlugin;
56
+ } | undefined>;
57
+ /**
58
+ * Installs a plugin of a known marketplace (`<name>@<marketplace>`, or a name only one offers)
59
+ * into a scope: the agent's by default (the project's when it has none), the packages its npx
60
+ * servers run into the agent's cache. Asking the person first, for one that isn't the agent's
61
+ * own (`needsConfirmation()`), is the caller's.
62
+ */
63
+ export declare function installExtension(dirs: ExtensionDirs, spec: string, options?: {
64
+ scope?: ExtensionScope;
65
+ official?: string;
66
+ }): Promise<{
67
+ name: string;
68
+ scope: ExtensionScope;
69
+ replaced: boolean;
70
+ marketplace: string;
71
+ commit?: string;
72
+ missing: string[];
73
+ }>;
74
+ /**
75
+ * Updates an installed extension from the marketplace it came from: a fresh copy of the
76
+ * marketplace, then installed again in the same scope (its data and options kept). One that
77
+ * didn't come from a marketplace is installed again with `extension add`.
78
+ */
79
+ export declare function updateExtension(dirs: ExtensionDirs, name: string, options?: {
80
+ official?: string;
81
+ }): Promise<{
82
+ name: string;
83
+ from?: string;
84
+ to?: string;
85
+ scope: ExtensionScope;
86
+ }>;
87
+ /**
88
+ * Gets what a workspace's lock lists and this computer lacks (#57): each extension's code into
89
+ * the agent's store, from where it came (its marketplace, its git repository at its commit, its
90
+ * folder), checked against the lock's hash, and the packages its npx servers run into the
91
+ * agent's cache. It trusts nothing: the workspace still asks before they run. What can't be got
92
+ * says why (a marketplace the agent doesn't know, another version there now, a folder that isn't
93
+ * on this computer).
94
+ */
95
+ export declare function restoreExtensions(dirs: ExtensionDirs, options?: {
96
+ official?: string;
97
+ }): Promise<{
98
+ restored: string[];
99
+ failed: {
100
+ name: string;
101
+ reason: string;
102
+ }[];
103
+ }>;
@@ -0,0 +1,235 @@
1
+ import { mkdtemp, rm, stat } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { addExtension, codeStoreDir, hashExtension, inKitRange, installTarget, isGitSource, listInstalled, readExternalManifest, readLock, storedCodeDir } from "./externalExtensions.js";
5
+ import { cachePackage, cachedPackageDir } from "./npxPackages.js";
6
+ import { trustWorkspace } from "./workspaceTrust.js";
7
+ import { describeCapability } from "./extensions.js";
8
+ import { getLanguage } from "./messages/index.js";
9
+ import { addMarketplace, findPlugin, installFromMarketplace, listMarketplaces, resolvePluginSource, updateMarketplace } from "./marketplaces.js";
10
+ /**
11
+ * The agent's catalog of extensions (#56): what its known marketplaces offer, what's installed
12
+ * from them and what has a newer version, and installing and updating from it. The kit's
13
+ * command and the chats (terminal, web, desktop) share it, so a person without a shell can do
14
+ * what `extension add`, `search` and `update` do.
15
+ */
16
+ /** Where an agent keeps its marketplaces: with its extensions (the project's when it has no scope of its own). */
17
+ export function marketplacesDir(dirs) {
18
+ return dirs.agent ?? dirs.project;
19
+ }
20
+ /**
21
+ * The marketplaces the agent knows, its `official` one among them: added without asking the
22
+ * first time, and a folder one copied afresh each time (it's the agent's own code).
23
+ */
24
+ export async function knownMarketplaces(dirs, official) {
25
+ const dir = marketplacesDir(dirs);
26
+ if (!dir)
27
+ throw new Error("This agent has no folder for extensions.");
28
+ // Calls at the same time share one look (a page asking for several images at once): two copies
29
+ // of the same marketplace taken together would clash.
30
+ const key = path.resolve(dir);
31
+ const pending = looking.get(key);
32
+ if (pending)
33
+ return pending;
34
+ const look = lookAt(dir, official).finally(() => looking.delete(key));
35
+ looking.set(key, look);
36
+ return look;
37
+ }
38
+ const looking = new Map();
39
+ async function lookAt(dir, official) {
40
+ const known = await listMarketplaces(dir);
41
+ if (official && !(isGitSource(official) && known.some((marketplace) => marketplace.official))) {
42
+ await addMarketplace(official, dir, { official: true });
43
+ return listMarketplaces(dir);
44
+ }
45
+ return known;
46
+ }
47
+ /** The images a catalog shows: these types only. */
48
+ const IMAGE = /\.(svg|png|jpe?g|webp)$/i;
49
+ /**
50
+ * What a marketplace offers of a plugin, from its folder in the marketplace's copy when it has one:
51
+ * the version (its `plugin.json` wins over the entry, as Claude Code computes it), its keywords,
52
+ * and its look, an image inside its folder only.
53
+ */
54
+ async function offered(marketplace, plugin) {
55
+ try {
56
+ const resolved = resolvePluginSource(marketplace, plugin);
57
+ if ("source" in resolved && !isGitSource(resolved.source) && (await stat(resolved.source).catch(() => null))?.isDirectory()) {
58
+ const manifest = await readExternalManifest(resolved.source).catch(() => undefined);
59
+ const image = async (relative) => {
60
+ if (!relative || !IMAGE.test(relative))
61
+ return undefined;
62
+ const file = path.resolve(resolved.source, relative);
63
+ if (path.relative(resolved.source, file).startsWith("..") || !(await stat(file).catch(() => null))?.isFile())
64
+ return undefined;
65
+ return file;
66
+ };
67
+ const [logo, banner] = [await image(manifest?.catalog?.logo), await image(manifest?.catalog?.banner)];
68
+ const version = manifest?.version ?? plugin.version;
69
+ const capabilities = (manifest?.capabilities ?? []).map((capability) => describeCapability(capability, getLanguage()));
70
+ const requirements = (manifest?.requirements ?? []).map((requirement) => ({ name: requirement.name, for: requirement.for }));
71
+ return { ...(version ? { version } : {}), keywords: manifest?.keywords ?? [], ...(logo ? { logo } : {}), ...(banner ? { banner } : {}), capabilities, requirements };
72
+ }
73
+ }
74
+ catch {
75
+ // A source the kit doesn't install: the entry's own fields.
76
+ }
77
+ return { ...(plugin.version ? { version: plugin.version } : {}), keywords: [], capabilities: [], requirements: [] };
78
+ }
79
+ /** Whether `offered` is newer than `installed` (x.y.z); a version that can't be compared isn't. */
80
+ export function isNewer(offered, installed) {
81
+ if (!offered || !installed || !/^\d+\.\d+\.\d+/.test(offered) || !/^\d+\.\d+\.\d+/.test(installed))
82
+ return false;
83
+ return inKitRange(offered, `>${installed.split("-")[0]}`);
84
+ }
85
+ /** Everything the known marketplaces offer, matching `words` (in its name, description, category, tags or keywords) when given. */
86
+ export async function extensionCatalog(dirs, options = {}) {
87
+ const installed = (await listInstalled(dirs)).filter((extension) => !extension.shadowed);
88
+ const words = (options.words ?? []).map((word) => word.toLowerCase());
89
+ // What the agent offers its users (#59): only what has one of its topics.
90
+ const wanted = options.topics?.length ? new Set(options.topics.map((topic) => topic.toLowerCase())) : undefined;
91
+ const entries = [];
92
+ for (const marketplace of await knownMarketplaces(dirs, options.official)) {
93
+ for (const plugin of marketplace.manifest?.plugins ?? []) {
94
+ const look = await offered(marketplace, plugin);
95
+ const topics = [...new Set([...look.keywords, ...(plugin.tags ?? []), ...(plugin.keywords ?? []), ...(plugin.category ? [plugin.category] : [])].map((topic) => topic.toLowerCase()))];
96
+ if (wanted && !topics.some((topic) => wanted.has(topic)))
97
+ continue;
98
+ // Its capabilities count too: a search for "grading" finds what grades.
99
+ const text = [plugin.name, plugin.description, ...topics, ...look.capabilities.flatMap(({ id, label, description }) => [id, label, description])].join(" ").toLowerCase();
100
+ if (!words.every((word) => text.includes(word)))
101
+ continue;
102
+ const version = look.version;
103
+ const mine = installed.find((extension) => extension.name === plugin.name);
104
+ const fromHere = mine?.lock.marketplace === `${plugin.name}@${marketplace.name}`;
105
+ entries.push({
106
+ spec: `${plugin.name}@${marketplace.name}`,
107
+ marketplace: marketplace.name,
108
+ official: marketplace.official,
109
+ plugin,
110
+ ...(version ? { version } : {}),
111
+ ...(mine ? { installed: mine } : {}),
112
+ update: Boolean(fromHere && isNewer(version, mine?.manifest?.version)),
113
+ topics,
114
+ ...(look.logo ? { logo: look.logo } : {}),
115
+ ...(look.banner ? { banner: look.banner } : {}),
116
+ capabilities: look.capabilities,
117
+ requirements: look.requirements,
118
+ });
119
+ }
120
+ }
121
+ return entries;
122
+ }
123
+ /** Whether installing `spec` needs the person's confirmation first: a plugin of a marketplace that isn't the agent's own. */
124
+ export async function needsConfirmation(dirs, spec, official) {
125
+ await knownMarketplaces(dirs, official);
126
+ const found = await findPlugin(marketplacesDir(dirs), spec);
127
+ return found.marketplace.official ? undefined : found;
128
+ }
129
+ /**
130
+ * Installs a plugin of a known marketplace (`<name>@<marketplace>`, or a name only one offers)
131
+ * into a scope: the agent's by default (the project's when it has none), the packages its npx
132
+ * servers run into the agent's cache. Asking the person first, for one that isn't the agent's
133
+ * own (`needsConfirmation()`), is the caller's.
134
+ */
135
+ export async function installExtension(dirs, spec, options = {}) {
136
+ const scope = options.scope ?? (dirs.agent ? "agent" : "project");
137
+ const scopeDir = dirs[scope];
138
+ if (!scopeDir)
139
+ throw new Error(`This agent has no ${scope} scope for extensions.`);
140
+ await knownMarketplaces(dirs, options.official);
141
+ const result = await installFromMarketplace(marketplacesDir(dirs), spec, scopeDir, installTarget(dirs, scope));
142
+ // Installed into a workspace by the person: trusted there as it is (#57).
143
+ if (scope === "project")
144
+ await trustWorkspace(dirs, [result.name]);
145
+ return { ...result, scope };
146
+ }
147
+ /**
148
+ * Updates an installed extension from the marketplace it came from: a fresh copy of the
149
+ * marketplace, then installed again in the same scope (its data and options kept). One that
150
+ * didn't come from a marketplace is installed again with `extension add`.
151
+ */
152
+ export async function updateExtension(dirs, name, options = {}) {
153
+ const installed = (await listInstalled(dirs)).find((extension) => extension.name === name && !extension.shadowed);
154
+ if (!installed)
155
+ throw new Error(`${name} isn't installed.`);
156
+ const from = installed.lock.marketplace;
157
+ if (!from)
158
+ throw new Error(`${name} didn't come from a marketplace (${path.normalize(installed.lock.source)}): install it again from there to update it.`);
159
+ const marketplace = from.slice(from.indexOf("@") + 1);
160
+ await knownMarketplaces(dirs, options.official);
161
+ await updateMarketplace(marketplacesDir(dirs), marketplace);
162
+ await installFromMarketplace(marketplacesDir(dirs), from, installed.scopeDir, installTarget(dirs, installed.scope));
163
+ if (installed.scope === "project")
164
+ await trustWorkspace(dirs, [name]);
165
+ const after = (await listInstalled(dirs)).find((extension) => extension.name === name && extension.scopeDir === installed.scopeDir);
166
+ return { name, scope: installed.scope, ...(installed.manifest?.version ? { from: installed.manifest.version } : {}), ...(after?.manifest?.version ? { to: after.manifest.version } : {}) };
167
+ }
168
+ /**
169
+ * Gets what a workspace's lock lists and this computer lacks (#57): each extension's code into
170
+ * the agent's store, from where it came (its marketplace, its git repository at its commit, its
171
+ * folder), checked against the lock's hash, and the packages its npx servers run into the
172
+ * agent's cache. It trusts nothing: the workspace still asks before they run. What can't be got
173
+ * says why (a marketplace the agent doesn't know, another version there now, a folder that isn't
174
+ * on this computer).
175
+ */
176
+ export async function restoreExtensions(dirs, options = {}) {
177
+ const restored = [];
178
+ const failed = [];
179
+ const store = codeStoreDir(dirs);
180
+ if (!dirs.project || !store)
181
+ return { restored, failed };
182
+ const target = installTarget(dirs, "project");
183
+ for (const [name, lock] of Object.entries(await readLock(dirs.project))) {
184
+ if (lock.linked)
185
+ continue;
186
+ try {
187
+ const stored = storedCodeDir(store, name, lock.sha256);
188
+ const have = (await stat(stored).catch(() => null))?.isDirectory() && (await hashExtension(stored)) === lock.sha256;
189
+ if (!have) {
190
+ // Installed again into a scratch scope, which puts its code in the store; its hash must be the lock's.
191
+ const scratch = await mkdtemp(path.join(os.tmpdir(), "agent-kit-restore-"));
192
+ try {
193
+ if (lock.marketplace) {
194
+ await knownMarketplaces(dirs, options.official);
195
+ await installFromMarketplace(marketplacesDir(dirs), lock.marketplace, scratch, target);
196
+ }
197
+ else if (isGitSource(lock.source)) {
198
+ await addExtension(lock.source, scratch, { ...target, ...(lock.commit ? { sha: lock.commit } : {}), ...(lock.subdir ? { subdir: lock.subdir } : {}) });
199
+ }
200
+ else if ((await stat(lock.source).catch(() => null))?.isDirectory()) {
201
+ await addExtension(lock.source, scratch, target);
202
+ }
203
+ else {
204
+ throw new Error(`it came from a folder that isn't on this computer (${lock.source})`);
205
+ }
206
+ const got = (await readLock(scratch))[name]?.sha256;
207
+ if (got !== lock.sha256)
208
+ throw new Error(`where it came from has another version now${lock.marketplace ? `: \`extension update ${name}\` takes it` : ""}`);
209
+ }
210
+ finally {
211
+ await rm(scratch, { recursive: true, force: true });
212
+ }
213
+ }
214
+ // Its npx packages, the versions the lock names, checked against their hashes there.
215
+ const cache = target.packages;
216
+ for (const [spec, cached] of Object.entries(lock.packages ?? {})) {
217
+ const at = spec.lastIndexOf("@");
218
+ const [pkg, version] = [spec.slice(0, at), spec.slice(at + 1)];
219
+ if (!cache)
220
+ throw new Error("this agent has no package cache");
221
+ const dir = cachedPackageDir(cache, pkg, version);
222
+ if ((await stat(dir).catch(() => null))?.isDirectory() && (await hashExtension(dir)) === cached.sha256)
223
+ continue;
224
+ if ((await cachePackage(cache, pkg, version)).sha256 !== cached.sha256)
225
+ throw new Error(`its package ${spec} isn't what the workspace locked`);
226
+ }
227
+ if (!have || Object.keys(lock.packages ?? {}).length)
228
+ restored.push(name);
229
+ }
230
+ catch (error) {
231
+ failed.push({ name, reason: error instanceof Error ? error.message.replace(/\.$/, "") : String(error) });
232
+ }
233
+ }
234
+ return { restored, failed };
235
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * An installed extension's options (#55): what its `plugin.json` declares under `userConfig`, as
3
+ * Claude Code does, and the values the person gives them. Its servers take them as
4
+ * `${user_config.KEY}` in their `args` and `env`; the launcher puts the values in when it starts a
5
+ * server, read from disk then, so a value changed while a session runs reaches the server when
6
+ * it starts again (the chat restarts it: `reconnectMcpServer()`). Non-sensitive values live in the
7
+ * scope's lock; sensitive ones in a file of the scope's `.secrets/` folder, which the session's
8
+ * file tools never reach: neither is ever in the prompt, the transcript or a tool's result.
9
+ */
10
+ /** A value an option takes: by its type, a list when it's `multiple`. */
11
+ export type OptionValue = string | number | boolean | string[];
12
+ /** One of an extension's options, as Claude Code's `userConfig` declares it. */
13
+ export interface ExtensionOption {
14
+ type: "string" | "number" | "boolean" | "directory" | "file";
15
+ /** What it's called where the person is asked. */
16
+ title: string;
17
+ description: string;
18
+ /** Without a value (its own or a default), the extension doesn't run. */
19
+ required?: boolean;
20
+ default?: OptionValue;
21
+ /** A secret (a token, a password): asked without echo, kept apart, never shown back. */
22
+ sensitive?: boolean;
23
+ /** The values it may take, when it's a choice. */
24
+ options?: (string | number)[];
25
+ /** Several values (comma-separated where they're typed). */
26
+ multiple?: boolean;
27
+ /** A number's bounds. */
28
+ min?: number;
29
+ max?: number;
30
+ }
31
+ /**
32
+ * A manifest's `userConfig`, checked as Claude Code checks it (an option with an unknown field
33
+ * makes the plugin fail to load there): throws saying what's wrong.
34
+ */
35
+ export declare function parseUserConfig(raw: unknown): Record<string, ExtensionOption>;
36
+ /** A typed value for `option` from what the person typed; throws saying why it isn't one. */
37
+ export declare function parseOptionValue(option: ExtensionOption, typed: string): OptionValue;
38
+ /** A value as it goes into a server's argument or variable: a list comma-separated. */
39
+ export declare function optionText(value: OptionValue | undefined): string;
40
+ /** Where a scope keeps an extension's sensitive values: outside its plugin, its lock and its data. */
41
+ export declare function extensionSecretsPath(scopeDir: string, name: string): string;
42
+ /** The folder of a scope's sensitive values, which the session's file tools never reach. */
43
+ export declare function secretsDir(scopeDir: string): string;
44
+ /** The values an installed extension's options have now: what the person set (the lock's and the secrets'), else their defaults. */
45
+ export declare function readOptionValues(scopeDir: string, name: string, declared: Record<string, ExtensionOption>, stored?: Record<string, OptionValue>): Promise<Record<string, OptionValue>>;
46
+ /** The required options without a value. */
47
+ export declare function missingOptions(declared: Record<string, ExtensionOption>, values: Record<string, OptionValue>): string[];
48
+ /**
49
+ * Sets (or with `undefined`, clears) some of an extension's option values: the sensitive ones in
50
+ * its secrets file, the others returned for its lock entry (the caller writes the lock).
51
+ */
52
+ export declare function storeOptionValues(scopeDir: string, name: string, declared: Record<string, ExtensionOption>, stored: Record<string, OptionValue>, changes: Record<string, OptionValue | undefined>): Promise<Record<string, OptionValue>>;
53
+ /** Removes an extension's sensitive values. */
54
+ export declare function removeSecrets(scopeDir: string, name: string): Promise<void>;
55
+ /** The `${user_config.KEY}` a server's values name. */
56
+ export declare function optionsNamed(values: readonly string[]): string[];