@falkenslab/agent-kit 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,5 @@
1
1
  <h1>
2
- <picture>
3
- <source media="(prefers-color-scheme: dark)" srcset="docs/assets/logo-readme-dark.svg">
4
- <img src="docs/assets/logo-readme.svg" alt="agent-kit" height="64">
5
- </picture>
2
+ <img src="https://raw.githubusercontent.com/falkenslab/agent-kit/main/docs/assets/logo-readme-any.svg" alt="agent-kit" height="64">
6
3
  </h1>
7
4
 
8
5
  [![npm](https://img.shields.io/npm/v/@falkenslab/agent-kit?logo=npm)](https://www.npmjs.com/package/@falkenslab/agent-kit)
@@ -273,6 +273,7 @@ export declare function createChatController(options: Options | SessionOpener, s
273
273
  confirmed?: boolean;
274
274
  }): Promise<{
275
275
  name?: string;
276
+ missing?: string[];
276
277
  confirm?: {
277
278
  marketplace: string;
278
279
  description?: string;
@@ -605,7 +605,10 @@ export async function createChatController(options, settings = {}) {
605
605
  const result = await installFromCatalog(current.extensions.dirs, spec, { ...official, ...(options.scope ? { scope: options.scope } : {}) });
606
606
  await refreshInstalled();
607
607
  notice(t().extensionInstalled(result.name));
608
- return { name: result.name };
608
+ // What it needs and this computer lacks (#63): the person installs it, the kit never does.
609
+ for (const line of result.missing)
610
+ notice(t().extensionLacks(result.name, line));
611
+ return { name: result.name, missing: result.missing };
609
612
  },
610
613
  /** Updates one installed extension from its marketplace, or every one with a newer version there; the names updated. */
611
614
  async updateExtension(name) {
@@ -3,6 +3,12 @@ import type { ExtensionsStatus } from "../core/session.js";
3
3
  import { type CatalogEntry } from "../core/extensionCatalog.js";
4
4
  /** One line for an installed extension: name and version, scope, on or off, its author, where it came from. */
5
5
  export declare function describe(extension: InstalledExtension): string;
6
+ /**
7
+ * What the active extensions let the agent do (#62), in the session's language: a heading per
8
+ * extension with capabilities, a line per capability, and the tools a capability names that its
9
+ * server doesn't offer, once the session has started and knows them.
10
+ */
11
+ export declare function describeCapabilities(status: ExtensionsStatus): string[];
6
12
  /** What the chat does for `/extensions install|update|remove|reload` (#56). */
7
13
  export type ExtensionAction = {
8
14
  type: "install";
@@ -12,10 +12,33 @@ export function describe(extension) {
12
12
  const author = extension.manifest?.author ? ` by ${extension.manifest.author.name}` : "";
13
13
  return `${extension.name}${version} [${extension.scope}] ${state}${author} from ${source}`;
14
14
  }
15
+ /**
16
+ * What the active extensions let the agent do (#62), in the session's language: a heading per
17
+ * extension with capabilities, a line per capability, and the tools a capability names that its
18
+ * server doesn't offer, once the session has started and knows them.
19
+ */
20
+ export function describeCapabilities(status) {
21
+ const offered = new Set(status.sessionTools?.() ?? []);
22
+ return status.active.flatMap((name) => {
23
+ const capabilities = status.about[name]?.capabilities ?? [];
24
+ const unavailable = status.about[name]?.unavailable ?? [];
25
+ if (!capabilities.length && !unavailable.length)
26
+ return [];
27
+ return [
28
+ t().extensionCan(name),
29
+ ...capabilities.map(({ label, description, tools }) => {
30
+ const missing = offered.size ? [...new Set(tools.filter((tool) => !offered.has(tool)).map((tool) => tool.replace(/^mcp__.+?__/, "")))] : [];
31
+ return ` - ${label}${description ? `: ${description}` : ""}${missing.length ? ` ${t().capabilityToolsMissing(missing.join(", "))}` : ""}`;
32
+ }),
33
+ // What it can't do on this computer, with what to install (#63).
34
+ ...unavailable.map(({ label, reason }) => ` - ${t().capabilityUnavailable(label, reason)}`),
35
+ ];
36
+ });
37
+ }
15
38
  /** One line for a catalog entry: what installs it, its version, whether it's installed or has an update, what it is. */
16
39
  export function describeEntry(entry) {
17
40
  const state = entry.installed ? (entry.update ? ` ${t().extensionUpdateAvailable(entry.installed.manifest?.version ?? "?")}` : ` ${t().extensionIsInstalled}`) : "";
18
- return `${entry.spec}${entry.version ? ` ${entry.version}` : ""}${state}${entry.plugin.description ? ` ${entry.plugin.description}` : ""}`;
41
+ return `${entry.spec}${entry.version ? ` ${entry.version}` : ""}${state}${entry.description ? ` ${entry.description}` : ""}`;
19
42
  }
20
43
  /** An installed extension's options, one line each: its value, its default, or whether a sensitive one is set (never its value). */
21
44
  export async function describeOptions(extension) {
@@ -83,6 +106,8 @@ export async function chatExtensionsCommand(line, status, canReopen) {
83
106
  }
84
107
  if (!action) {
85
108
  const lines = [status?.active.length ? t().extensionsRunning(status.active.join(", ")) : t().extensionsNone];
109
+ if (status)
110
+ lines.push(...describeCapabilities(status));
86
111
  for (const { name: off, reason } of status?.inactive ?? [])
87
112
  lines.push(t().extensionOff(off, reason));
88
113
  if (status?.installed.length)
@@ -1,4 +1,5 @@
1
1
  import { type ExtensionDirs, type ExtensionScope, type InstalledExtension } from "./externalExtensions.js";
2
+ import { type DescribedCapability } from "./extensions.js";
2
3
  import { type KnownMarketplace, type MarketplacePlugin } from "./marketplaces.js";
3
4
  /**
4
5
  * The agent's catalog of extensions (#56): what its known marketplaces offer, what's installed
@@ -21,6 +22,8 @@ export interface CatalogEntry {
21
22
  /** From the agent's own marketplace: installed without a confirmation. */
22
23
  official: boolean;
23
24
  plugin: MarketplacePlugin;
25
+ /** What it is, in the kit's language (#67): its own there, else the marketplace entry's, else its `plugin.json`'s, in English. */
26
+ description: string;
24
27
  /** The version it offers: its `plugin.json`'s when the marketplace has its folder, else the marketplace's entry's. */
25
28
  version?: string;
26
29
  /** Installed (and used: not shadowed), from wherever it came. */
@@ -32,6 +35,13 @@ export interface CatalogEntry {
32
35
  /** Its logo and banner (#59), as files of the marketplace's copy, when it has them. */
33
36
  logo?: string;
34
37
  banner?: string;
38
+ /** What it lets the agent do (#62), in the kit's language: from its `plugin.json` when the marketplace has its folder. */
39
+ capabilities: DescribedCapability[];
40
+ /** The programs it needs on the computer (#63), by name ("Docker"), with the capabilities that need them (none: all of it). */
41
+ requirements: {
42
+ name: string;
43
+ for: string[];
44
+ }[];
35
45
  }
36
46
  /** Whether `offered` is newer than `installed` (x.y.z); a version that can't be compared isn't. */
37
47
  export declare function isNewer(offered: string | undefined, installed: string | undefined): boolean;
@@ -61,6 +71,7 @@ export declare function installExtension(dirs: ExtensionDirs, spec: string, opti
61
71
  replaced: boolean;
62
72
  marketplace: string;
63
73
  commit?: string;
74
+ missing: string[];
64
75
  }>;
65
76
  /**
66
77
  * Updates an installed extension from the marketplace it came from: a fresh copy of the
@@ -4,6 +4,8 @@ import path from "node:path";
4
4
  import { addExtension, codeStoreDir, hashExtension, inKitRange, installTarget, isGitSource, listInstalled, readExternalManifest, readLock, storedCodeDir } from "./externalExtensions.js";
5
5
  import { cachePackage, cachedPackageDir } from "./npxPackages.js";
6
6
  import { trustWorkspace } from "./workspaceTrust.js";
7
+ import { describeCapability, describeInLanguage } from "./extensions.js";
8
+ import { getLanguage } from "./messages/index.js";
7
9
  import { addMarketplace, findPlugin, installFromMarketplace, listMarketplaces, resolvePluginSource, updateMarketplace } from "./marketplaces.js";
8
10
  /**
9
11
  * The agent's catalog of extensions (#56): what its known marketplaces offer, what's installed
@@ -64,13 +66,16 @@ async function offered(marketplace, plugin) {
64
66
  };
65
67
  const [logo, banner] = [await image(manifest?.catalog?.logo), await image(manifest?.catalog?.banner)];
66
68
  const version = manifest?.version ?? plugin.version;
67
- return { ...(version ? { version } : {}), keywords: manifest?.keywords ?? [], ...(logo ? { logo } : {}), ...(banner ? { banner } : {}) };
69
+ const capabilities = (manifest?.capabilities ?? []).map((capability) => describeCapability(capability, getLanguage()));
70
+ const requirements = (manifest?.requirements ?? []).map((requirement) => ({ name: requirement.name, for: requirement.for }));
71
+ const description = describeInLanguage(plugin.description ?? manifest?.description ?? "", manifest?.descriptions, getLanguage());
72
+ return { ...(version ? { version } : {}), keywords: manifest?.keywords ?? [], ...(logo ? { logo } : {}), ...(banner ? { banner } : {}), capabilities, requirements, description };
68
73
  }
69
74
  }
70
75
  catch {
71
76
  // A source the kit doesn't install: the entry's own fields.
72
77
  }
73
- return { ...(plugin.version ? { version: plugin.version } : {}), keywords: [] };
78
+ return { ...(plugin.version ? { version: plugin.version } : {}), keywords: [], capabilities: [], requirements: [], description: plugin.description ?? "" };
74
79
  }
75
80
  /** Whether `offered` is newer than `installed` (x.y.z); a version that can't be compared isn't. */
76
81
  export function isNewer(offered, installed) {
@@ -91,7 +96,8 @@ export async function extensionCatalog(dirs, options = {}) {
91
96
  const topics = [...new Set([...look.keywords, ...(plugin.tags ?? []), ...(plugin.keywords ?? []), ...(plugin.category ? [plugin.category] : [])].map((topic) => topic.toLowerCase()))];
92
97
  if (wanted && !topics.some((topic) => wanted.has(topic)))
93
98
  continue;
94
- const text = [plugin.name, plugin.description, ...topics].join(" ").toLowerCase();
99
+ // Its capabilities count too: a search for "grading" finds what grades.
100
+ const text = [plugin.name, plugin.description, look.description, ...topics, ...look.capabilities.flatMap(({ id, label, description }) => [id, label, description])].join(" ").toLowerCase();
95
101
  if (!words.every((word) => text.includes(word)))
96
102
  continue;
97
103
  const version = look.version;
@@ -99,6 +105,7 @@ export async function extensionCatalog(dirs, options = {}) {
99
105
  const fromHere = mine?.lock.marketplace === `${plugin.name}@${marketplace.name}`;
100
106
  entries.push({
101
107
  spec: `${plugin.name}@${marketplace.name}`,
108
+ description: look.description,
102
109
  marketplace: marketplace.name,
103
110
  official: marketplace.official,
104
111
  plugin,
@@ -108,6 +115,8 @@ export async function extensionCatalog(dirs, options = {}) {
108
115
  topics,
109
116
  ...(look.logo ? { logo: look.logo } : {}),
110
117
  ...(look.banner ? { banner: look.banner } : {}),
118
+ capabilities: look.capabilities,
119
+ requirements: look.requirements,
111
120
  });
112
121
  }
113
122
  }
@@ -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,5 +1,6 @@
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
  /**
@@ -90,27 +91,100 @@ export interface Extension {
90
91
  external?: boolean;
91
92
  /** An installed one's scope folder, where its lock is (its plugin is elsewhere when it's linked). */
92
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
+ }[];
93
99
  /** Why it can't run in this session (e.g. "needs a folder (`dir`)"), or `undefined` when it can. */
94
100
  missing?(context: ExtensionContext): string | undefined;
95
101
  /** What it brings to the session, once it's active. */
96
102
  contribute(context: ExtensionContext): Promise<ExtensionContribution>;
97
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>;
138
+ /**
139
+ * A manifest's `metadata["agent-kit"].description` (#67): its description in other languages than
140
+ * English, which stays the top-level `description` Claude Code reads. Only languages the kit speaks.
141
+ */
142
+ export declare function parseDescriptions(raw: unknown): Partial<Record<Language, string>>;
143
+ /** An extension's description in `language`: its own there, else the English one. */
144
+ export declare function describeInLanguage(english: string, descriptions: Partial<Record<Language, string>> | undefined, language: Language): string;
98
145
  /** An extension's manifest, as the kit reads it. */
99
146
  export interface ExtensionManifest {
100
147
  name: string;
101
148
  description: string;
102
- /** Capabilities it lets the agent use. */
149
+ /** Capabilities it lets the agent use: its `provides`, and its `capabilities`' ids. */
103
150
  provides: string[];
104
151
  /** Capabilities it needs another enabled extension to provide. */
105
152
  requires: string[];
153
+ /** What it lets the agent do, described for the person (#62). */
154
+ capabilities: ExtensionCapability[];
106
155
  }
107
156
  /** Reads an extension's manifest from its plugin. */
108
157
  export declare function readExtensionManifest(pluginRoot: string): ExtensionManifest;
158
+ /** An active extension as a view lists it: its capabilities in the session's language, with their tools by full name. */
159
+ export interface AboutExtension {
160
+ description: string;
161
+ provides: string[];
162
+ capabilities: (DescribedCapability & {
163
+ tools: string[];
164
+ })[];
165
+ /** What it can't do on this computer, with why (#63). */
166
+ unavailable: (DescribedCapability & {
167
+ reason: string;
168
+ })[];
169
+ /** Its MCP servers, by name. */
170
+ servers: string[];
171
+ }
172
+ /** What a view says of an active extension: its capabilities' tools under each of its servers' names. */
173
+ export declare function aboutExtension(manifest: ExtensionManifest, servers: readonly string[], language: Language, unavailable?: readonly UnavailableCapability[]): AboutExtension;
174
+ /** What a manifest provides: its `provides` and its capabilities' ids, once each. */
175
+ export declare function providedBy(provides: readonly string[] | undefined, capabilities: readonly ExtensionCapability[]): string[];
176
+ /** A capability an active extension has but can't use on this computer, with why (#63). */
177
+ export interface UnavailableCapability {
178
+ capability: ExtensionCapability;
179
+ reason: string;
180
+ }
109
181
  /** The extensions a session runs with, and the ones it can't, with why. */
110
182
  export interface ResolvedExtensions {
183
+ /** Each with its manifest as the session sees it (without what's unavailable here) and what's unavailable. */
111
184
  active: {
112
185
  extension: Extension;
113
186
  manifest: ExtensionManifest;
187
+ unavailable: UnavailableCapability[];
114
188
  }[];
115
189
  inactive: {
116
190
  name: string;
@@ -2,10 +2,66 @@ 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
+ }
52
+ /**
53
+ * A manifest's `metadata["agent-kit"].description` (#67): its description in other languages than
54
+ * English, which stays the top-level `description` Claude Code reads. Only languages the kit speaks.
55
+ */
56
+ export function parseDescriptions(raw) {
57
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
58
+ return {};
59
+ return Object.fromEntries(Object.entries(raw).filter(([language, text]) => SUPPORTED_LANGUAGES.includes(language) && typeof text === "string" && text.length > 0));
60
+ }
61
+ /** An extension's description in `language`: its own there, else the English one. */
62
+ export function describeInLanguage(english, descriptions, language) {
63
+ return descriptions?.[language] ?? english;
64
+ }
9
65
  /** Reads an extension's manifest from its plugin. */
10
66
  export function readExtensionManifest(pluginRoot) {
11
67
  const file = path.join(pluginRoot, ".claude-plugin", "plugin.json");
@@ -13,7 +69,22 @@ export function readExtensionManifest(pluginRoot) {
13
69
  if (!raw.name)
14
70
  throw new Error(`${file} has no "name".`);
15
71
  const kit = raw.metadata?.["agent-kit"];
16
- return { name: raw.name, description: raw.description ?? "", provides: kit?.provides ?? [], requires: kit?.requires ?? [] };
72
+ const capabilities = parseCapabilities(kit?.capabilities);
73
+ return { name: raw.name, description: raw.description ?? "", provides: providedBy(kit?.provides, capabilities), requires: kit?.requires ?? [], capabilities };
74
+ }
75
+ /** What a view says of an active extension: its capabilities' tools under each of its servers' names. */
76
+ export function aboutExtension(manifest, servers, language, unavailable = []) {
77
+ return {
78
+ description: manifest.description,
79
+ provides: manifest.provides,
80
+ capabilities: manifest.capabilities.map((capability) => ({ ...describeCapability(capability, language), tools: servers.flatMap((server) => capability.tools.map((tool) => `mcp__${server}__${tool}`)) })),
81
+ unavailable: unavailable.map(({ capability, reason }) => ({ ...describeCapability(capability, language), reason })),
82
+ servers: [...servers],
83
+ };
84
+ }
85
+ /** What a manifest provides: its `provides` and its capabilities' ids, once each. */
86
+ export function providedBy(provides, capabilities) {
87
+ return [...new Set([...(provides ?? []), ...capabilities.map((capability) => capability.id)])];
17
88
  }
18
89
  /**
19
90
  * Which of the `extensions` run in this session, in the order given: those the session lacks
@@ -24,14 +95,18 @@ export function resolveExtensions(extensions, context) {
24
95
  const inactive = [];
25
96
  let active = [];
26
97
  for (const extension of extensions) {
27
- const manifest = readExtensionManifest(extension.plugin);
28
- if (manifest.name !== extension.name)
29
- throw new Error(`The extension "${extension.name}" has the plugin "${manifest.name}": they must have the same name.`);
98
+ const read = readExtensionManifest(extension.plugin);
99
+ if (read.name !== extension.name)
100
+ throw new Error(`The extension "${extension.name}" has the plugin "${read.name}": they must have the same name.`);
101
+ // What it can't do here isn't provided: what requires it is left out, as for a missing extension.
102
+ const unavailable = (extension.unavailable ?? []).flatMap(({ capabilities, reason }) => capabilities.map((id) => ({ capability: read.capabilities.find((capability) => capability.id === id) ?? { id, skills: [], tools: [], texts: {} }, reason })));
103
+ const off = new Set(unavailable.map(({ capability }) => capability.id));
104
+ const manifest = off.size ? { ...read, provides: read.provides.filter((id) => !off.has(id)), capabilities: read.capabilities.filter((capability) => !off.has(capability.id)) } : read;
30
105
  const reason = extension.missing?.(context);
31
106
  if (reason)
32
107
  inactive.push({ name: extension.name, reason });
33
108
  else
34
- active.push({ extension, manifest });
109
+ active.push({ extension, manifest, unavailable });
35
110
  }
36
111
  // Requirements: drop what can't be met, again and again, since dropping one may starve another.
37
112
  for (let changed = true; changed;) {
@@ -51,7 +126,16 @@ export function resolveExtensions(extensions, context) {
51
126
  }
52
127
  /** The prompt's "Extensions" section: what's on, and what isn't and why (ADR-025). */
53
128
  export function extensionsPromptSection(resolved) {
54
- const lines = resolved.active.map(({ manifest }) => `- **${manifest.name}**: ${manifest.description}${manifest.provides.length ? ` Provides: ${manifest.provides.join(", ")}.` : ""}`);
129
+ // A capability by its id and, when it has one, its English label (the model reads English, ADR-019).
130
+ const provided = (manifest) => manifest.provides.map((id) => {
131
+ const capability = manifest.capabilities.find((candidate) => candidate.id === id);
132
+ const label = capability ? describeCapability(capability, "en").label : id;
133
+ return label === id ? id : `${id} (${label})`;
134
+ });
135
+ const lines = resolved.active.map(({ manifest, unavailable }) => {
136
+ 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.`);
137
+ return `- **${manifest.name}**: ${manifest.description}${manifest.provides.length ? ` Provides: ${provided(manifest).join(", ")}.` : ""}${cant.join("")}`;
138
+ });
55
139
  const off = resolved.inactive.map(({ name, reason }) => `- **${name}** isn't available: it ${reason}. If the person asks for what it does, tell them why.`);
56
140
  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")}`;
57
141
  }