@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,139 @@
1
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ const TYPES = new Set(["string", "number", "boolean", "directory", "file"]);
4
+ const FIELDS = new Set(["type", "title", "description", "required", "default", "sensitive", "options", "multiple", "min", "max"]);
5
+ /**
6
+ * A manifest's `userConfig`, checked as Claude Code checks it (an option with an unknown field
7
+ * makes the plugin fail to load there): throws saying what's wrong.
8
+ */
9
+ export function parseUserConfig(raw) {
10
+ if (raw === undefined)
11
+ return {};
12
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
13
+ throw new Error("its userConfig isn't an object");
14
+ const options = {};
15
+ for (const [key, value] of Object.entries(raw)) {
16
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
17
+ throw new Error(`its userConfig option "${key}" isn't a valid name (letters, digits and _)`);
18
+ if (!value || typeof value !== "object")
19
+ throw new Error(`its userConfig option "${key}" isn't an object`);
20
+ const option = value;
21
+ const unknown = Object.keys(option).filter((field) => !FIELDS.has(field));
22
+ if (unknown.length)
23
+ throw new Error(`its userConfig option "${key}" has unknown fields (${unknown.join(", ")})`);
24
+ if (typeof option.type !== "string" || !TYPES.has(option.type))
25
+ throw new Error(`its userConfig option "${key}" has no valid type (string, number, boolean, directory or file)`);
26
+ if (typeof option.description !== "string")
27
+ throw new Error(`its userConfig option "${key}" has no description`);
28
+ options[key] = { ...option, title: typeof option.title === "string" ? option.title : key };
29
+ }
30
+ return options;
31
+ }
32
+ /** A typed value for `option` from what the person typed; throws saying why it isn't one. */
33
+ export function parseOptionValue(option, typed) {
34
+ const one = (text) => {
35
+ const value = text.trim();
36
+ if (option.type === "number") {
37
+ const number = Number(value);
38
+ if (!value || Number.isNaN(number))
39
+ throw new Error(`"${value}" isn't a number`);
40
+ if (option.min !== undefined && number < option.min)
41
+ throw new Error(`${number} is below ${option.min}`);
42
+ if (option.max !== undefined && number > option.max)
43
+ throw new Error(`${number} is above ${option.max}`);
44
+ if (option.options && !option.options.includes(number))
45
+ throw new Error(`${number} isn't one of ${option.options.join(", ")}`);
46
+ return number;
47
+ }
48
+ if (option.type === "boolean") {
49
+ if (/^(true|yes|y|1|on)$/i.test(value))
50
+ return true;
51
+ if (/^(false|no|n|0|off)$/i.test(value))
52
+ return false;
53
+ throw new Error(`"${value}" isn't true or false`);
54
+ }
55
+ if (option.options && !option.options.map(String).includes(value))
56
+ throw new Error(`"${value}" isn't one of ${option.options.join(", ")}`);
57
+ return option.type === "directory" || option.type === "file" ? path.resolve(value) : value;
58
+ };
59
+ if (option.multiple)
60
+ return typed.split(",").filter((part) => part.trim()).map((part) => String(one(part)));
61
+ return one(typed);
62
+ }
63
+ /** A value as it goes into a server's argument or variable: a list comma-separated. */
64
+ export function optionText(value) {
65
+ return value === undefined ? "" : Array.isArray(value) ? value.join(",") : String(value);
66
+ }
67
+ /** Where a scope keeps an extension's sensitive values: outside its plugin, its lock and its data. */
68
+ export function extensionSecretsPath(scopeDir, name) {
69
+ return path.join(scopeDir, ".secrets", `${name}.json`);
70
+ }
71
+ /** The folder of a scope's sensitive values, which the session's file tools never reach. */
72
+ export function secretsDir(scopeDir) {
73
+ return path.join(scopeDir, ".secrets");
74
+ }
75
+ async function readJson(file) {
76
+ try {
77
+ return JSON.parse(await readFile(file, "utf8"));
78
+ }
79
+ catch (error) {
80
+ if (error.code === "ENOENT")
81
+ return {};
82
+ throw error;
83
+ }
84
+ }
85
+ /** The values an installed extension's options have now: what the person set (the lock's and the secrets'), else their defaults. */
86
+ export async function readOptionValues(scopeDir, name, declared, stored = {}) {
87
+ const secrets = (await readJson(extensionSecretsPath(scopeDir, name)));
88
+ const values = {};
89
+ for (const [key, option] of Object.entries(declared)) {
90
+ const value = option.sensitive ? secrets[key] : stored[key];
91
+ if (value !== undefined)
92
+ values[key] = value;
93
+ else if (option.default !== undefined)
94
+ values[key] = option.default;
95
+ }
96
+ return values;
97
+ }
98
+ /** The required options without a value. */
99
+ export function missingOptions(declared, values) {
100
+ return Object.entries(declared)
101
+ .filter(([key, option]) => option.required && (values[key] === undefined || values[key] === ""))
102
+ .map(([key]) => key);
103
+ }
104
+ /**
105
+ * Sets (or with `undefined`, clears) some of an extension's option values: the sensitive ones in
106
+ * its secrets file, the others returned for its lock entry (the caller writes the lock).
107
+ */
108
+ export async function storeOptionValues(scopeDir, name, declared, stored, changes) {
109
+ const next = { ...stored };
110
+ const file = extensionSecretsPath(scopeDir, name);
111
+ const secrets = (await readJson(file));
112
+ let secretsChanged = false;
113
+ for (const [key, value] of Object.entries(changes)) {
114
+ const option = declared[key];
115
+ if (!option)
116
+ throw new Error(`${name} has no option "${key}"`);
117
+ const target = option.sensitive ? secrets : next;
118
+ if (value === undefined)
119
+ delete target[key];
120
+ else
121
+ target[key] = value;
122
+ if (option.sensitive)
123
+ secretsChanged = true;
124
+ }
125
+ if (secretsChanged) {
126
+ await mkdir(path.dirname(file), { recursive: true });
127
+ await writeFile(file, `${JSON.stringify(secrets, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
128
+ await chmod(file, 0o600).catch(() => undefined);
129
+ }
130
+ return next;
131
+ }
132
+ /** Removes an extension's sensitive values. */
133
+ export async function removeSecrets(scopeDir, name) {
134
+ await rm(extensionSecretsPath(scopeDir, name), { force: true });
135
+ }
136
+ /** The `${user_config.KEY}` a server's values name. */
137
+ export function optionsNamed(values) {
138
+ return [...new Set(values.flatMap((value) => [...value.matchAll(/\$\{user_config\.([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((match) => match[1])))];
139
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The programs an extension needs on the computer (#63): Docker for a lab, ffmpeg for videos.
3
+ * The kit installs an extension's files and never a program (ADR-025): it checks them, says what's
4
+ * missing, what for and how to install it on this system, and keeps off only what needs them.
5
+ */
6
+ /** A program an extension needs on the computer, and how the person installs it. */
7
+ export interface ExtensionRequirement {
8
+ /** What the person reads: "Docker". */
9
+ name: string;
10
+ /** The program, by its bare name, found on the `PATH` (`docker`; on Windows with `PATHEXT`'s extensions). */
11
+ program: string;
12
+ /** How to check its version: its arguments (options or `version` only) and the oldest it works with. */
13
+ version?: {
14
+ args: string[];
15
+ min: string;
16
+ };
17
+ /** How to install it on each system: a command, or a link. */
18
+ install: {
19
+ windows?: string;
20
+ macos?: string;
21
+ linux?: string;
22
+ url?: string;
23
+ };
24
+ /** The capabilities that need it (its manifest's ids); without them, the whole extension does. */
25
+ for: string[];
26
+ }
27
+ /** What checking a requirement found. */
28
+ export interface RequirementCheck {
29
+ requirement: ExtensionRequirement;
30
+ /** On the `PATH`. */
31
+ found: boolean;
32
+ /** The version it said, when it was asked and said one. */
33
+ version?: string;
34
+ /** There, and new enough (or its version couldn't be read). */
35
+ ok: boolean;
36
+ }
37
+ /** A manifest's `requirements`: each as given, for `requirementsProblem()` to judge. */
38
+ export declare function parseRequirements(raw: unknown): ExtensionRequirement[];
39
+ /**
40
+ * Why an extension's requirements can't be checked safely or don't hold together (a program given
41
+ * as a path, a version argument that isn't an option, a capability it doesn't provide), or nothing.
42
+ */
43
+ export declare function requirementsProblem(requirements: readonly ExtensionRequirement[], provides: readonly string[]): string | undefined;
44
+ /** Where a program is on the `PATH`, as a shell would find it (with `PATHEXT`'s extensions on Windows), or nothing. */
45
+ export declare function findOnPath(program: string, env?: NodeJS.ProcessEnv): Promise<string | undefined>;
46
+ /** Whether `version` is `min` or later, comparing x.y.z (missing parts are 0). */
47
+ export declare function atLeast(version: string, min: string): boolean;
48
+ /**
49
+ * Checks one requirement: finds its program on the `PATH` and, when it says how, runs it with its
50
+ * version arguments only: no shell, nothing else on the command line, and stopped after
51
+ * `timeoutMs`. A Windows script (`.cmd`, `.bat`) can't run without a shell, and a program that
52
+ * says no version can't be compared: either counts as there.
53
+ */
54
+ export declare function checkRequirement(requirement: ExtensionRequirement, options?: {
55
+ timeoutMs?: number;
56
+ env?: NodeJS.ProcessEnv;
57
+ }): Promise<RequirementCheck>;
58
+ /** Checks an extension's requirements, all at once. */
59
+ export declare function checkRequirements(requirements: readonly ExtensionRequirement[], options?: {
60
+ timeoutMs?: number;
61
+ env?: NodeJS.ProcessEnv;
62
+ }): Promise<RequirementCheck[]>;
63
+ /** How to install a requirement on this system: its command, else its link. */
64
+ export declare function installHint(requirement: ExtensionRequirement, platform?: NodeJS.Platform): string | undefined;
65
+ /** What's missing, as a reason reads ("Docker (docker 24.0.0 or later), which isn't on this computer: install it with …"). */
66
+ export declare function describeMissing(check: RequirementCheck, platform?: NodeJS.Platform): string;
@@ -0,0 +1,112 @@
1
+ import { execFile } from "node:child_process";
2
+ import { stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ /** A program's name: a bare one, never a path, so the check can only find it on the `PATH`. */
5
+ const PROGRAM = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
6
+ /** A version check's arguments: options and `version`, never what would do something (`docker run …`). */
7
+ const VERSION_ARG = /^(--?[A-Za-z][A-Za-z0-9-]*|version)$/;
8
+ const VERSION = /^\d+(\.\d+){0,2}$/;
9
+ /** A manifest's `requirements`: each as given, for `requirementsProblem()` to judge. */
10
+ export function parseRequirements(raw) {
11
+ if (!Array.isArray(raw))
12
+ return [];
13
+ const text = (value) => (typeof value === "string" && value ? value : undefined);
14
+ return raw.flatMap((item) => {
15
+ if (!item || typeof item !== "object")
16
+ return [];
17
+ const { name, program, version, install, for: needed } = item;
18
+ const programName = text(program);
19
+ if (!programName)
20
+ return [];
21
+ const check = version && typeof version === "object" ? version : undefined;
22
+ const how = install && typeof install === "object" ? install : {};
23
+ return [
24
+ {
25
+ name: text(name) ?? programName,
26
+ program: programName,
27
+ ...(check ? { version: { args: Array.isArray(check.args) ? check.args.map(String) : [], min: String(check.min ?? "") } } : {}),
28
+ install: Object.fromEntries(["windows", "macos", "linux", "url"].flatMap((key) => (text(how[key]) ? [[key, text(how[key])]] : []))),
29
+ for: Array.isArray(needed) ? needed.filter((id) => typeof id === "string") : [],
30
+ },
31
+ ];
32
+ });
33
+ }
34
+ /**
35
+ * Why an extension's requirements can't be checked safely or don't hold together (a program given
36
+ * as a path, a version argument that isn't an option, a capability it doesn't provide), or nothing.
37
+ */
38
+ export function requirementsProblem(requirements, provides) {
39
+ for (const requirement of requirements) {
40
+ if (!PROGRAM.test(requirement.program))
41
+ return `needs the program "${requirement.program}", which isn't a bare name found on the PATH`;
42
+ if (requirement.version) {
43
+ const unsafe = requirement.version.args.find((arg) => !VERSION_ARG.test(arg));
44
+ if (unsafe !== undefined)
45
+ return `checks ${requirement.program}'s version with "${unsafe}": only options (--version, -v) or version`;
46
+ if (!VERSION.test(requirement.version.min))
47
+ return `wants ${requirement.program} ${requirement.version.min || "(no version)"}, which isn't a version like 24.0.0`;
48
+ }
49
+ const unknown = requirement.for.find((id) => !provides.includes(id));
50
+ if (unknown)
51
+ return `needs ${requirement.program} for ${unknown}, a capability it doesn't provide`;
52
+ }
53
+ return undefined;
54
+ }
55
+ /** Where a program is on the `PATH`, as a shell would find it (with `PATHEXT`'s extensions on Windows), or nothing. */
56
+ export async function findOnPath(program, env = process.env) {
57
+ const dirs = (env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean);
58
+ const extensions = process.platform === "win32" ? ["", ...(env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").map((extension) => extension.toLowerCase())] : [""];
59
+ for (const dir of dirs) {
60
+ for (const extension of extensions) {
61
+ const file = path.join(dir, `${program}${extension}`);
62
+ if (await stat(file).then((entry) => entry.isFile(), () => false))
63
+ return file;
64
+ }
65
+ }
66
+ return undefined;
67
+ }
68
+ /** Whether `version` is `min` or later, comparing x.y.z (missing parts are 0). */
69
+ export function atLeast(version, min) {
70
+ const parts = (value) => value.split(".").map((part) => Number.parseInt(part, 10) || 0);
71
+ const [a, b] = [parts(version), parts(min)];
72
+ for (let index = 0; index < 3; index++)
73
+ if ((a[index] ?? 0) !== (b[index] ?? 0))
74
+ return (a[index] ?? 0) > (b[index] ?? 0);
75
+ return true;
76
+ }
77
+ /**
78
+ * Checks one requirement: finds its program on the `PATH` and, when it says how, runs it with its
79
+ * version arguments only: no shell, nothing else on the command line, and stopped after
80
+ * `timeoutMs`. A Windows script (`.cmd`, `.bat`) can't run without a shell, and a program that
81
+ * says no version can't be compared: either counts as there.
82
+ */
83
+ export async function checkRequirement(requirement, options = {}) {
84
+ const file = PROGRAM.test(requirement.program) ? await findOnPath(requirement.program, options.env) : undefined;
85
+ if (!file)
86
+ return { requirement, found: false, ok: false };
87
+ if (!requirement.version || /\.(cmd|bat)$/i.test(file))
88
+ return { requirement, found: true, ok: true };
89
+ const said = await new Promise((resolve) => {
90
+ execFile(file, requirement.version.args, { timeout: options.timeoutMs ?? 5000, windowsHide: true, shell: false, ...(options.env ? { env: options.env } : {}) }, (_error, stdout, stderr) => resolve(`${stdout ?? ""}\n${stderr ?? ""}`));
91
+ });
92
+ const version = said.match(/\d+\.\d+(?:\.\d+)?/)?.[0];
93
+ return { requirement, found: true, ...(version ? { version } : {}), ok: !version || atLeast(version, requirement.version.min) };
94
+ }
95
+ /** Checks an extension's requirements, all at once. */
96
+ export function checkRequirements(requirements, options = {}) {
97
+ return Promise.all(requirements.map((requirement) => checkRequirement(requirement, options)));
98
+ }
99
+ /** How to install a requirement on this system: its command, else its link. */
100
+ export function installHint(requirement, platform = process.platform) {
101
+ const system = platform === "win32" ? requirement.install.windows : platform === "darwin" ? requirement.install.macos : requirement.install.linux;
102
+ return system ?? requirement.install.url;
103
+ }
104
+ /** What's missing, as a reason reads ("Docker (docker 24.0.0 or later), which isn't on this computer: install it with …"). */
105
+ export function describeMissing(check, platform = process.platform) {
106
+ const { requirement } = check;
107
+ const wanted = `${requirement.name} (${requirement.program}${requirement.version ? ` ${requirement.version.min} or later` : ""})`;
108
+ const state = check.found ? `, and this computer has ${check.version}` : ", which isn't on this computer";
109
+ const hint = installHint(requirement, platform);
110
+ const how = !hint ? "" : /^https?:\/\//.test(hint) ? `: see ${hint}` : `: install it with \`${hint}\``;
111
+ return `${wanted}${state}${how}`;
112
+ }
@@ -1,10 +1,11 @@
1
1
  import type { HookCallbackMatcher, HookEvent, McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
2
2
  import type { AgentSpec, BaseSessionConfig, Mode } from "./agentSpec.js";
3
+ import { type Language } from "./language.js";
3
4
  import type { ToolLabels } from "./toolLabels.js";
4
5
  import type { SessionFacts } from "./sessionFacts.js";
5
6
  /**
6
7
  * Extensions (ADR-025): what an agent is besides the kit's core. An extension is a Claude Code
7
- * plugin with the kit's data in its manifest (`.claude-plugin/plugin.json`, key `"agent-kit"`:
8
+ * plugin with the kit's data in its manifest (`.claude-plugin/plugin.json`, under `metadata["agent-kit"]`:
8
9
  * the capabilities it `provides` and `requires`), plus, for an internal one, the code that
9
10
  * says what it brings to a session. An agent enables the ones it wants (`AgentSpec.extensions`),
10
11
  * each made with its own options (the kit's through their factories, `knowledge({ dir })`…);
@@ -57,6 +58,12 @@ export interface ExtensionContribution {
57
58
  }[];
58
59
  /** Its MCP tools that only read: plan mode lets them through. */
59
60
  readOnlyTools?: string[];
61
+ /**
62
+ * Its MCP servers whose tools that only read plan mode learns from the servers themselves
63
+ * (`readOnlyHint`), asked while the session runs: an installed extension whose manifest lists
64
+ * no `readOnlyTools`. A list in the manifest wins: its servers aren't here.
65
+ */
66
+ readOnlyHintServers?: string[];
60
67
  /** Its MCP tools that ask the person themselves: interactive mode's step gate doesn't ask first. */
61
68
  selfAskingTools?: string[];
62
69
  /** How the chat shows its tools, by full name, in the kit's language: its line and how it counts in a folded group. */
@@ -84,27 +91,93 @@ export interface Extension {
84
91
  external?: boolean;
85
92
  /** An installed one's scope folder, where its lock is (its plugin is elsewhere when it's linked). */
86
93
  installedIn?: string;
94
+ /** What it can't do on this computer (#63): capabilities whose programs are missing, with why. They aren't provided, and their skills are left out. */
95
+ unavailable?: {
96
+ capabilities: string[];
97
+ reason: string;
98
+ }[];
87
99
  /** Why it can't run in this session (e.g. "needs a folder (`dir`)"), or `undefined` when it can. */
88
100
  missing?(context: ExtensionContext): string | undefined;
89
101
  /** What it brings to the session, once it's active. */
90
102
  contribute(context: ExtensionContext): Promise<ExtensionContribution>;
91
103
  }
104
+ /** How a capability reads to the person, in one language. */
105
+ export interface CapabilityText {
106
+ label: string;
107
+ description: string;
108
+ }
109
+ /**
110
+ * Something the agent does well with an extension (#62): the skills that say how and the tools
111
+ * that do it, described for the person. Its `id` is what `requires` names, neutral (`lms-grading`,
112
+ * not `moodle-grading`), so an agent's skills depend on what can be done, not on which extension
113
+ * does it.
114
+ */
115
+ export interface ExtensionCapability {
116
+ id: string;
117
+ /** Its skills, by their folder in the extension's `skills/`. */
118
+ skills: string[];
119
+ /** Its tools, by short name (of any of its servers). */
120
+ tools: string[];
121
+ /** Its label and description, by language. */
122
+ texts: Partial<Record<Language, CapabilityText>>;
123
+ }
124
+ /** A capability as a person reads it: its id, and its texts in their language. */
125
+ export interface DescribedCapability extends CapabilityText {
126
+ id: string;
127
+ }
128
+ /** A manifest's `capabilities`: what has an id, with its lists and texts as far as they're well formed. */
129
+ export declare function parseCapabilities(raw: unknown): ExtensionCapability[];
130
+ /** A capability's texts in `language`: English when it has none there, then any it has, then its id. */
131
+ export declare function describeCapability(capability: ExtensionCapability, language: Language): DescribedCapability;
132
+ /**
133
+ * Why an extension's capabilities don't hold (an id twice, a skill it doesn't have), or nothing.
134
+ * Its tools can't be checked here: only a running server lists them, so `/extensions` says when
135
+ * one is missing.
136
+ */
137
+ export declare function capabilitiesProblem(pluginRoot: string, capabilities: readonly ExtensionCapability[]): Promise<string | undefined>;
92
138
  /** An extension's manifest, as the kit reads it. */
93
139
  export interface ExtensionManifest {
94
140
  name: string;
95
141
  description: string;
96
- /** Capabilities it lets the agent use. */
142
+ /** Capabilities it lets the agent use: its `provides`, and its `capabilities`' ids. */
97
143
  provides: string[];
98
144
  /** Capabilities it needs another enabled extension to provide. */
99
145
  requires: string[];
146
+ /** What it lets the agent do, described for the person (#62). */
147
+ capabilities: ExtensionCapability[];
100
148
  }
101
149
  /** Reads an extension's manifest from its plugin. */
102
150
  export declare function readExtensionManifest(pluginRoot: string): ExtensionManifest;
151
+ /** An active extension as a view lists it: its capabilities in the session's language, with their tools by full name. */
152
+ export interface AboutExtension {
153
+ description: string;
154
+ provides: string[];
155
+ capabilities: (DescribedCapability & {
156
+ tools: string[];
157
+ })[];
158
+ /** What it can't do on this computer, with why (#63). */
159
+ unavailable: (DescribedCapability & {
160
+ reason: string;
161
+ })[];
162
+ /** Its MCP servers, by name. */
163
+ servers: string[];
164
+ }
165
+ /** What a view says of an active extension: its capabilities' tools under each of its servers' names. */
166
+ export declare function aboutExtension(manifest: ExtensionManifest, servers: readonly string[], language: Language, unavailable?: readonly UnavailableCapability[]): AboutExtension;
167
+ /** What a manifest provides: its `provides` and its capabilities' ids, once each. */
168
+ export declare function providedBy(provides: readonly string[] | undefined, capabilities: readonly ExtensionCapability[]): string[];
169
+ /** A capability an active extension has but can't use on this computer, with why (#63). */
170
+ export interface UnavailableCapability {
171
+ capability: ExtensionCapability;
172
+ reason: string;
173
+ }
103
174
  /** The extensions a session runs with, and the ones it can't, with why. */
104
175
  export interface ResolvedExtensions {
176
+ /** Each with its manifest as the session sees it (without what's unavailable here) and what's unavailable. */
105
177
  active: {
106
178
  extension: Extension;
107
179
  manifest: ExtensionManifest;
180
+ unavailable: UnavailableCapability[];
108
181
  }[];
109
182
  inactive: {
110
183
  name: string;
@@ -2,17 +2,76 @@ import { readFileSync } from "node:fs";
2
2
  import { readdir, readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { frontmatter } from "./pluginAgents.js";
5
+ import { SUPPORTED_LANGUAGES } from "./language.js";
5
6
  /** The folder `option` names for this session's `config`, if any. */
6
7
  export function folderOf(option, config) {
7
8
  return typeof option === "function" ? option(config) : option;
8
9
  }
10
+ /** A manifest's `capabilities`: what has an id, with its lists and texts as far as they're well formed. */
11
+ export function parseCapabilities(raw) {
12
+ if (!Array.isArray(raw))
13
+ return [];
14
+ const names = (value) => (Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.length > 0) : []);
15
+ return raw.flatMap((item) => {
16
+ if (!item || typeof item !== "object")
17
+ return [];
18
+ const { id, skills, tools, texts } = item;
19
+ if (typeof id !== "string" || !id)
20
+ return [];
21
+ const byLanguage = Object.fromEntries(Object.entries(texts && typeof texts === "object" ? texts : {}).flatMap(([language, text]) => {
22
+ const { label, description } = (text ?? {});
23
+ return SUPPORTED_LANGUAGES.includes(language) && typeof label === "string" && label ? [[language, { label, description: typeof description === "string" ? description : "" }]] : [];
24
+ }));
25
+ return [{ id, skills: names(skills), tools: names(tools), texts: byLanguage }];
26
+ });
27
+ }
28
+ /** A capability's texts in `language`: English when it has none there, then any it has, then its id. */
29
+ export function describeCapability(capability, language) {
30
+ const text = capability.texts[language] ?? capability.texts.en ?? Object.values(capability.texts)[0];
31
+ return { id: capability.id, label: text?.label ?? capability.id, description: text?.description ?? "" };
32
+ }
33
+ /**
34
+ * Why an extension's capabilities don't hold (an id twice, a skill it doesn't have), or nothing.
35
+ * Its tools can't be checked here: only a running server lists them, so `/extensions` says when
36
+ * one is missing.
37
+ */
38
+ export async function capabilitiesProblem(pluginRoot, capabilities) {
39
+ const seen = new Set();
40
+ for (const capability of capabilities) {
41
+ if (seen.has(capability.id))
42
+ return `declares the capability ${capability.id} twice`;
43
+ seen.add(capability.id);
44
+ for (const skill of capability.skills) {
45
+ const found = await readFile(path.join(pluginRoot, "skills", skill, "SKILL.md"), "utf8").then(() => true, () => false);
46
+ if (!found)
47
+ return `names the skill ${skill} in its capability ${capability.id}, and has no skills/${skill}/SKILL.md`;
48
+ }
49
+ }
50
+ return undefined;
51
+ }
9
52
  /** Reads an extension's manifest from its plugin. */
10
53
  export function readExtensionManifest(pluginRoot) {
11
54
  const file = path.join(pluginRoot, ".claude-plugin", "plugin.json");
12
55
  const raw = JSON.parse(readFileSync(file, "utf8"));
13
56
  if (!raw.name)
14
57
  throw new Error(`${file} has no "name".`);
15
- return { name: raw.name, description: raw.description ?? "", provides: raw["agent-kit"]?.provides ?? [], requires: raw["agent-kit"]?.requires ?? [] };
58
+ const kit = raw.metadata?.["agent-kit"];
59
+ const capabilities = parseCapabilities(kit?.capabilities);
60
+ return { name: raw.name, description: raw.description ?? "", provides: providedBy(kit?.provides, capabilities), requires: kit?.requires ?? [], capabilities };
61
+ }
62
+ /** What a view says of an active extension: its capabilities' tools under each of its servers' names. */
63
+ export function aboutExtension(manifest, servers, language, unavailable = []) {
64
+ return {
65
+ description: manifest.description,
66
+ provides: manifest.provides,
67
+ capabilities: manifest.capabilities.map((capability) => ({ ...describeCapability(capability, language), tools: servers.flatMap((server) => capability.tools.map((tool) => `mcp__${server}__${tool}`)) })),
68
+ unavailable: unavailable.map(({ capability, reason }) => ({ ...describeCapability(capability, language), reason })),
69
+ servers: [...servers],
70
+ };
71
+ }
72
+ /** What a manifest provides: its `provides` and its capabilities' ids, once each. */
73
+ export function providedBy(provides, capabilities) {
74
+ return [...new Set([...(provides ?? []), ...capabilities.map((capability) => capability.id)])];
16
75
  }
17
76
  /**
18
77
  * Which of the `extensions` run in this session, in the order given: those the session lacks
@@ -23,14 +82,18 @@ export function resolveExtensions(extensions, context) {
23
82
  const inactive = [];
24
83
  let active = [];
25
84
  for (const extension of extensions) {
26
- const manifest = readExtensionManifest(extension.plugin);
27
- if (manifest.name !== extension.name)
28
- throw new Error(`The extension "${extension.name}" has the plugin "${manifest.name}": they must have the same name.`);
85
+ const read = readExtensionManifest(extension.plugin);
86
+ if (read.name !== extension.name)
87
+ throw new Error(`The extension "${extension.name}" has the plugin "${read.name}": they must have the same name.`);
88
+ // What it can't do here isn't provided: what requires it is left out, as for a missing extension.
89
+ const unavailable = (extension.unavailable ?? []).flatMap(({ capabilities, reason }) => capabilities.map((id) => ({ capability: read.capabilities.find((capability) => capability.id === id) ?? { id, skills: [], tools: [], texts: {} }, reason })));
90
+ const off = new Set(unavailable.map(({ capability }) => capability.id));
91
+ const manifest = off.size ? { ...read, provides: read.provides.filter((id) => !off.has(id)), capabilities: read.capabilities.filter((capability) => !off.has(capability.id)) } : read;
29
92
  const reason = extension.missing?.(context);
30
93
  if (reason)
31
94
  inactive.push({ name: extension.name, reason });
32
95
  else
33
- active.push({ extension, manifest });
96
+ active.push({ extension, manifest, unavailable });
34
97
  }
35
98
  // Requirements: drop what can't be met, again and again, since dropping one may starve another.
36
99
  for (let changed = true; changed;) {
@@ -50,7 +113,16 @@ export function resolveExtensions(extensions, context) {
50
113
  }
51
114
  /** The prompt's "Extensions" section: what's on, and what isn't and why (ADR-025). */
52
115
  export function extensionsPromptSection(resolved) {
53
- const lines = resolved.active.map(({ manifest }) => `- **${manifest.name}**: ${manifest.description}${manifest.provides.length ? ` Provides: ${manifest.provides.join(", ")}.` : ""}`);
116
+ // A capability by its id and, when it has one, its English label (the model reads English, ADR-019).
117
+ const provided = (manifest) => manifest.provides.map((id) => {
118
+ const capability = manifest.capabilities.find((candidate) => candidate.id === id);
119
+ const label = capability ? describeCapability(capability, "en").label : id;
120
+ return label === id ? id : `${id} (${label})`;
121
+ });
122
+ const lines = resolved.active.map(({ manifest, unavailable }) => {
123
+ const cant = unavailable.map(({ capability, reason }) => `\n - ${describeCapability(capability, "en").label} (${capability.id}) isn't available on this computer: it ${reason}. If the person asks for it, tell them what to install.`);
124
+ return `- **${manifest.name}**: ${manifest.description}${manifest.provides.length ? ` Provides: ${provided(manifest).join(", ")}.` : ""}${cant.join("")}`;
125
+ });
54
126
  const off = resolved.inactive.map(({ name, reason }) => `- **${name}** isn't available: it ${reason}. If the person asks for what it does, tell them why.`);
55
127
  return `## Extensions\nWhat you can do besides your own tools comes from these extensions; their sections below say how to use them.\n${[...lines, ...off].join("\n")}`;
56
128
  }