feat: add plugin support

This commit is contained in:
qer 2026-05-27 01:28:23 +08:00
parent d5de66e66c
commit cdd90acaff
51 changed files with 7342 additions and 15 deletions

7
.changeset/plugins-v1.md Normal file
View file

@ -0,0 +1,7 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code-sdk": minor
"@moonshot-ai/kimi-code": minor
---
Introduce the Kimi Code plugin protocol rooted at `plugin.json`, with `.kimi-plugin/plugin.json` for multi-harness repositories such as Superpowers. Plugins can contribute skills, declarative `sessionStart.skill`, `skillInstructions`, display metadata, and opt-in `mcpServers`. Plugin changes are picked up by new sessions; `/plugins reload` refreshes plugin records and diagnostics but does not hot-reload the current session. Plugin MCP servers are parsed and shown at install time but only start after the user explicitly enables a server with `/plugins mcp enable` and starts a new session. Third-party command/tool runtimes, executable hooks, legacy `config_file`/`inject` adapters, `.codex-plugin/plugin.json` fallback loading, and hard-coded Superpowers behavior are not enabled by the plugin loader.

View file

@ -68,6 +68,13 @@ export const BUILTIN_SLASH_COMMANDS = [
priority: 60,
availability: 'always',
},
{
name: 'plugins',
aliases: [],
description: 'Manage plugins',
priority: 60,
availability: 'always',
},
{
name: 'compact',
aliases: [],

View file

@ -0,0 +1,135 @@
import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk';
import chalk from 'chalk';
import type { ColorPalette } from '../../theme/colors';
export interface PluginsListPanelInput {
readonly colors: ColorPalette;
readonly plugins: readonly PluginSummary[];
}
export function buildPluginsListLines(input: PluginsListPanelInput): readonly string[] {
const muted = chalk.hex(input.colors.textDim);
const value = chalk.hex(input.colors.text);
const success = chalk.hex(input.colors.success);
const warning = chalk.hex(input.colors.warning);
if (input.plugins.length === 0) {
return [
muted('No plugins installed.'),
'',
value('Try: /plugins install <path-or-zip-url>'),
];
}
const lines: string[] = [];
for (const plugin of input.plugins) {
const enabled = plugin.enabled ? success('enabled') : muted('disabled');
const state = plugin.state === 'ok' ? '' : ` [${plugin.state}]`;
const version = plugin.version ?? '-';
const diagnostics = plugin.hasErrors ? warning(' | diagnostics: see /plugins info') : '';
lines.push(`${value(plugin.displayName)} (${muted(plugin.id)}) ${muted(version)} | ${enabled}${state}`);
const mcp =
plugin.mcpServerCount > 0
? ` | ${plugin.enabledMcpServerCount}/${plugin.mcpServerCount} mcp`
: '';
lines.push(` ${muted('skills:')} ${value(String(plugin.skillCount))}${muted(mcp)}${diagnostics}`);
}
return lines;
}
export interface PluginsInfoPanelInput {
readonly colors: ColorPalette;
readonly info: PluginInfo;
}
export function buildPluginsInfoLines(input: PluginsInfoPanelInput): readonly string[] {
const { info } = input;
const muted = chalk.hex(input.colors.textDim);
const value = chalk.hex(input.colors.text);
const success = chalk.hex(input.colors.success);
const warning = chalk.hex(input.colors.warning);
const error = chalk.hex(input.colors.error);
const status = info.enabled ? success('enabled') : muted('disabled');
const lines: string[] = [
`${value(info.displayName)} (${muted(info.id)}) ${muted(info.version ?? '')}`.trim(),
`${muted('Status:')} ${status} | ${muted('state:')} ${stateText(info.state, input.colors)}`,
`${muted('Source:')} ${value(info.source)}`,
`${muted('Root:')} ${value(info.root)}`,
];
if (info.originalSource !== undefined) lines.push(`${muted('Original source:')} ${value(info.originalSource)}`);
if (info.manifestPath !== undefined) {
const kindSuffix = info.manifestKind !== undefined ? ` ${muted(`(${info.manifestKind})`)}` : '';
lines.push(`${muted('Manifest:')} ${value(info.manifestPath)}${kindSuffix}`);
} else if (info.manifestKind !== undefined) {
lines.push(`${muted('Manifest kind:')} ${value(info.manifestKind)}`);
}
if (info.shadowedManifestPath !== undefined) {
lines.push(`${muted('Shadowed:')} ${value(info.shadowedManifestPath)}`);
}
const sessionStartSkill = info.manifest?.sessionStart?.skill;
if (sessionStartSkill !== undefined) {
lines.push(`${muted('Session start:')} ${value(sessionStartSkill)}`);
}
if (info.manifest?.skillInstructions !== undefined) {
lines.push(`${muted('Skill instructions:')} ${value('present')}`);
}
lines.push('');
lines.push(value(`Skills (${info.manifest?.skills?.length ?? 0}):`));
for (const dir of info.manifest?.skills ?? []) lines.push(` ${muted('-')} ${value(dir)}`);
if (info.mcpServers.length > 0) {
lines.push('');
lines.push(value(`MCP servers (${info.enabledMcpServerCount}/${info.mcpServerCount} enabled):`));
if (info.enabledMcpServerCount < info.mcpServerCount) {
lines.push(muted(` Enable with: /plugins mcp enable ${info.id} <server>`));
}
for (const server of info.mcpServers) {
const enabled = server.enabled ? success('enabled') : muted('disabled');
lines.push(` ${muted('-')} ${value(server.name)} ${enabled} ${muted(`(${server.runtimeName})`)}`);
if (server.transport === 'stdio') {
const args = server.args !== undefined && server.args.length > 0 ? ` ${server.args.join(' ')}` : '';
lines.push(` ${muted('command:')} ${value(`${server.command ?? ''}${args}`.trim())}`);
if (server.cwd !== undefined) lines.push(` ${muted('cwd:')} ${value(server.cwd)}`);
if (server.envKeys !== undefined && server.envKeys.length > 0) {
lines.push(` ${muted('env:')} ${value(server.envKeys.join(', '))}`);
}
} else {
lines.push(` ${muted('url:')} ${value(server.url ?? '')}`);
if (server.headerKeys !== undefined && server.headerKeys.length > 0) {
lines.push(` ${muted('headers:')} ${value(server.headerKeys.join(', '))}`);
}
}
}
}
const iface = info.manifest?.interface;
if (iface !== undefined) {
lines.push('');
lines.push(value('Display:'));
if (iface.shortDescription !== undefined) lines.push(` ${muted('-')} ${value(iface.shortDescription)}`);
if (iface.developerName !== undefined) lines.push(` ${muted('-')} ${value(`by ${iface.developerName}`)}`);
if (iface.websiteURL !== undefined) lines.push(` ${muted('-')} ${value(iface.websiteURL)}`);
if (iface.capabilities !== undefined && iface.capabilities.length > 0) {
lines.push(` ${muted('-')} ${value(`capabilities: ${iface.capabilities.join(', ')}`)}`);
}
}
if (info.manifest?.keywords !== undefined && info.manifest.keywords.length > 0) {
lines.push('');
lines.push(muted(`Keywords: ${info.manifest.keywords.join(', ')}`));
}
if (info.diagnostics.length > 0) {
lines.push('');
lines.push(value('Diagnostics:'));
for (const d of info.diagnostics) {
const paint = d.severity === 'error' ? error : d.severity === 'warn' ? warning : muted;
lines.push(` ${paint(`[${d.severity}]`)} ${value(d.code)}: ${d.message}`);
}
}
return lines;
}
function stateText(state: PluginInfo['state'], colors: ColorPalette): string {
if (state === 'ok') return chalk.hex(colors.success)(state);
return chalk.hex(colors.error)(state);
}

View file

@ -9,9 +9,9 @@
import { writeFileSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { release as osRelease, type as osType } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { homedir as osHomedir, release as osRelease, type as osType } from 'node:os';
import {
Container,
@ -175,6 +175,10 @@ import {
import { buildStatusReportLines } from './components/messages/status-panel';
import { ThinkingComponent } from './components/messages/thinking';
import { ToolCallComponent } from './components/messages/tool-call';
import {
buildPluginsInfoLines,
buildPluginsListLines,
} from './components/messages/plugins-status-panel';
import {
buildUsageReportLines,
UsagePanelComponent,
@ -1550,6 +1554,9 @@ export class KimiTUI {
case 'mcp':
void this.showMcpServers();
return;
case 'plugins':
void this.handlePluginsCommand(args);
return;
case 'editor':
await this.handleEditorCommand(args, {});
return;
@ -5295,6 +5302,113 @@ export class KimiTUI {
this.state.ui.requestRender();
}
private async handlePluginsCommand(rawArgs: string): Promise<void> {
// 临时阅读注释:/plugins 的所有用户入口都在这里分发TUI 只负责解析命令和展示结果,真正状态变更交给 SDK/RPC。
const args = rawArgs.trim().split(/\s+/).filter((part) => part.length > 0);
const sub = args[0];
const rest = args.slice(1);
const session = this.requireSession();
try {
if (sub === undefined || sub === 'list') {
// 临时阅读注释:默认 /plugins 就是 list用面板展示当前 installed.json 里的插件快照。
const plugins = await session.listPlugins();
const lines = buildPluginsListLines({ colors: this.state.theme.colors, plugins });
const title = ` Plugins (${plugins.length}) `;
const panel = new UsagePanelComponent(lines, this.state.theme.colors.primary, title);
this.state.transcriptContainer.addChild(panel);
this.state.ui.requestRender();
return;
}
if (sub === 'install') {
// 临时阅读注释:安装接受本地路径或 zip URL成功后不会热更新当前 session需要 /new 重新加载 skills。
const source = rest[0];
if (source === undefined) {
this.showError('Usage: /plugins install <path-or-zip-url>');
return;
}
const summary = await session.installPlugin(
resolvePluginInstallSource(source, this.state.appState.workDir),
);
const mcpHint =
summary.mcpServerCount > summary.enabledMcpServerCount
? ` It declares ${summary.mcpServerCount} MCP server${summary.mcpServerCount === 1 ? '' : 's'}; enable one with /plugins mcp enable ${summary.id} <server>.`
: '';
this.showStatus(
`Installed ${summary.displayName} (${summary.id}).${mcpHint} Run /new to apply plugin changes.`,
);
return;
}
if (sub === 'info') {
// 临时阅读注释info 是排查入口manifest 路径、ignored 字段、diagnostics 都靠这里暴露给用户。
const id = rest[0];
if (id === undefined) {
this.showError('Usage: /plugins info <id>');
return;
}
const info = await session.getPluginInfo(id);
const lines = buildPluginsInfoLines({ colors: this.state.theme.colors, info });
const panel = new UsagePanelComponent(lines, this.state.theme.colors.primary, ` ${info.id} `);
this.state.transcriptContainer.addChild(panel);
this.state.ui.requestRender();
return;
}
if (sub === 'mcp') {
const action = rest[0];
if (action !== 'enable' && action !== 'disable') {
this.showError('Usage: /plugins mcp enable|disable <id> <server>');
return;
}
const id = rest[1];
const server = rest[2];
if (id === undefined || server === undefined) {
this.showError('Usage: /plugins mcp enable|disable <id> <server>');
return;
}
await session.setPluginMcpServerEnabled(id, server, action === 'enable');
this.showStatus(
`${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /new to apply.`,
);
return;
}
if (sub === 'enable' || sub === 'disable') {
// 临时阅读注释enable/disable 只改安装记录;新的 skill 集合同样要等下一次 /new 生效。
const id = rest[0];
if (id === undefined) {
this.showError(`Usage: /plugins ${sub} <id>`);
return;
}
await session.setPluginEnabled(id, sub === 'enable');
this.showStatus(
`${sub === 'enable' ? 'Enabled' : 'Disabled'} ${id}. Run /new to apply.`,
);
return;
}
if (sub === 'remove') {
// 临时阅读注释remove 不删除插件源码目录,只从 installed.json 里摘掉这条记录。
const id = rest[0];
if (id === undefined) {
this.showError('Usage: /plugins remove <id>');
return;
}
await session.removePlugin(id);
this.showStatus(`Removed ${id} (source directory left in place).`);
return;
}
if (sub === 'reload') {
// 临时阅读注释reload 重读 installed.json 和 manifest已存在 session 不会被热更新。
const summary = await session.reloadPlugins();
const line = `Reload: +${summary.added.length} -${summary.removed.length}` +
(summary.errors.length > 0 ? ` (${summary.errors.length} errors)` : '');
this.showStatus(line);
return;
}
this.showError(`Unknown /plugins subcommand: ${sub}`);
} catch (error) {
this.showError(`/plugins ${sub ?? ''} failed: ${formatErrorMessage(error)}`);
}
}
// Loads and renders current MCP server status.
private async showMcpServers(): Promise<void> {
let servers: readonly McpServerInfo[];
@ -6253,3 +6367,28 @@ export class KimiTUI {
});
}
}
function resolvePluginInstallSource(source: string, workDir: string): string {
const trimmed = source.trim();
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return trimmed;
if (trimmed === '~') return osHomedir();
if (trimmed.startsWith('~/')) return join(osHomedir(), trimmed.slice(2));
return isAbsolute(trimmed) ? trimmed : resolve(workDir, trimmed);
}
function formatHookResultMarkdown(event: HookResultEvent): string {
return `*${formatHookResultTitle(event)}*\n\n${formatHookResultBody(event)}`;
}
function formatHookResultPlain(event: HookResultEvent): string {
return `${formatHookResultTitle(event)}\n\n${formatHookResultBody(event)}`;
}
function formatHookResultTitle(event: HookResultEvent): string {
return `${event.hookEvent} hook${event.blocked === true ? ' blocked' : ''}`;
}
function formatHookResultBody(event: HookResultEvent): string {
const content = event.content.trim();
return content.length === 0 ? '(empty)' : content;
}

View file

@ -56,6 +56,7 @@ export default withMermaid(defineConfig({
items: [
{ text: 'Model Context Protocol', link: '/zh/customization/mcp' },
{ text: 'Agent Skills', link: '/zh/customization/skills' },
{ text: 'Plugins', link: '/zh/customization/plugins' },
{ text: 'Agent 与子 Agent', link: '/zh/customization/agents' },
{ text: 'Hooks', link: '/zh/customization/hooks' },
],
@ -129,6 +130,7 @@ export default withMermaid(defineConfig({
items: [
{ text: 'Model Context Protocol', link: '/en/customization/mcp' },
{ text: 'Agent Skills', link: '/en/customization/skills' },
{ text: 'Plugins', link: '/en/customization/plugins' },
{ text: 'Agents and Subagents', link: '/en/customization/agents' },
{ text: 'Hooks', link: '/en/customization/hooks' },
],

View file

@ -7,7 +7,7 @@ This repository uses VitePress for the documentation site. Most user-facing page
- Locales live under `docs/en/` and `docs/zh/` with mirrored paths and filenames.
- Main sections (nav + sidebar) are:
- Guides: getting-started, migration, use-cases, interaction, sessions
- Customization: mcp, skills, agents, hooks
- Customization: mcp, skills, plugins, agents, hooks
- Configuration: config-files, providers, overrides, env-vars, data-locations
- Reference: kimi-command, tools, slash-commands, keyboard
- FAQ

View file

@ -28,6 +28,9 @@ A typical layout under the data root looks like:
$KIMI_CODE_HOME (default ~/.kimi-code)
├── config.toml # User config
├── mcp.json # User-level MCP server declarations (optional)
├── plugins/
│ ├── installed.json # Installed plugin records and capability state
│ └── managed/ # Zip-installed plugins managed by Kimi Code CLI
├── session_index.jsonl # Session index
├── credentials/ # OAuth credential root (directory 0o700, files 0o600)
│ ├── <name>.json # Hosted Kimi / Open Platform provider OAuth credentials
@ -69,6 +72,8 @@ The tree above shows a typical layout under the default data root (`~/.kimi-code
`mcp.json` holds user-level MCP server declarations and is merged with the project-local `.kimi-code/mcp.json` at load time. The fields are the same as the project-level file; see [MCP](../customization/mcp.md) for details.
`plugins/installed.json` records installed plugins, whether each plugin is enabled, and explicit capability state such as plugin MCP servers enabled via `/plugins mcp enable`. Local plugin installs store only the source path. Zip URL installs are extracted under `plugins/managed/<id>/`. See [Plugins](../customization/plugins.md) for details.
OAuth credentials are stored as files under the `credentials/` subdirectory of the data root. The parent directory uses mode `0o700` and each credential file uses mode `0o600`, readable and writable only by the current user. There are two sub-locations:
- **Hosted Kimi / Open Platform provider OAuth credentials** live at `credentials/<name>.json`, for example `~/.kimi-code/credentials/managed:kimi-code.json`.
@ -128,4 +133,5 @@ To clean up only part of the data:
| Clear hosted Kimi / Open Platform OAuth login state | Run `/logout` (clears only the current provider's OAuth), or delete the corresponding `~/.kimi-code/credentials/<name>.json` |
| Clear MCP server OAuth login state | Delete the `~/.kimi-code/credentials/mcp/` directory; `/logout` **does not** clear MCP OAuth credentials |
| Remove user-level MCP declarations | Delete `~/.kimi-code/mcp.json` |
| Clear plugin install records and managed zip plugins | Delete the `~/.kimi-code/plugins/` directory; local plugin source directories are not deleted |
| Clear user-level Skills | Delete the `~/.kimi-code/skills/` directory |

View file

@ -17,6 +17,8 @@ Project entries override user-level entries with the same name.
The easiest entry point is running `/mcp-config` in the TUI, which guides you through adding, editing, or removing servers. To check connection status, run `/mcp`.
Plugins can also declare MCP servers in `plugin.json`. Plugin-declared servers are not started when the plugin is installed; enable each server explicitly with `/plugins mcp enable <plugin-id> <server>` and start a new session. See [Plugins](./plugins.md) for details.
The top-level shape of `mcp.json` is:
```json

View file

@ -0,0 +1,152 @@
# Plugins
Plugins package reusable Kimi Code CLI behavior around a `plugin.json` manifest. A plugin can contribute skills, add plugin-specific instructions to those skills, declare a session-start skill, and declare MCP servers that the user can enable explicitly. Multi-harness repositories can put the same Kimi manifest under `.kimi-plugin/plugin.json` instead of occupying the repository root.
Kimi Code CLI plugins are data bundles, not arbitrary command runtimes. Installing a plugin does not execute plugin-provided Python, Node.js, Shell, or hook scripts. If a workflow needs external tools or live data, prefer skills that guide the agent to use existing Kimi Code tools, or declare an MCP server and enable it explicitly.
## Installing and managing plugins
Use `/plugins` inside the TUI:
```sh
/plugins
/plugins install /absolute/path/to/plugin
/plugins install ./relative-plugin
/plugins install https://example.com/plugin.zip
/plugins info <id>
/plugins enable <id>
/plugins disable <id>
/plugins remove <id>
/plugins reload
/plugins mcp enable <id> <server>
/plugins mcp disable <id> <server>
```
Local directories are registered in `installed.json`; they are not copied. Zip URLs are downloaded, extracted, and stored under Kimi Code CLI's managed plugin directory. Removing a plugin only removes the install record; it does not delete the original local source directory.
Plugin changes apply to new sessions. After installing, enabling, disabling, removing, reloading, or enabling a plugin MCP server, start a fresh session with `/new` for the change to affect the available skills, `sessionStart.skill`, and MCP servers. Existing sessions keep the snapshot they started with.
`/plugins reload` re-reads `installed.json` and each plugin manifest so that `/plugins` and `/plugins info <id>` show the latest install state and diagnostics. It is not a hot reload for the current session's skills or MCP connections.
## Manifest format
Kimi Code CLI treats a root `plugin.json` as the primary plugin manifest:
```text
<plugin_root>/plugin.json
```
If `plugin.json` is absent, Kimi Code CLI reads the Kimi-scoped manifest:
```text
<plugin_root>/.kimi-plugin/plugin.json
```
Kimi Code CLI does not read `.codex-plugin/plugin.json`. If both `plugin.json` and `.kimi-plugin/plugin.json` exist, the root `plugin.json` wins and the `.kimi-plugin` manifest is shown as shadowed in `/plugins info`.
A typical plugin manifest looks like this:
```json
{
"name": "kimi-finance",
"version": "1.0.0",
"description": "Finance data and analysis workflows for Kimi Code CLI",
"keywords": ["finance", "mcp"],
"skills": "./skills/",
"sessionStart": {
"skill": "using-finance"
},
"skillInstructions": "Prefer finance MCP tools for live market data. Do not invent live prices.",
"mcpServers": {
"finance": {
"command": "uvx",
"args": ["kimi-finance-mcp"]
}
},
"interface": {
"displayName": "Kimi Finance",
"shortDescription": "Market data and financial analysis workflows"
}
}
```
Supported fields:
| Field | Description |
| --- | --- |
| `name` | Required plugin id source. Must match `[a-z0-9][a-z0-9_-]{0,63}`. |
| `version`, `description`, `keywords`, `author`, `homepage`, `license` | Display metadata. |
| `skills` | One path or an array of paths. Each path must start with `./` and stay inside the plugin root after symlinks are resolved. |
| root `SKILL.md` | If `skills` is omitted and the plugin root contains `SKILL.md`, the root is treated as a single skill root. |
| `sessionStart.skill` | Declaratively injects the named skill into the main agent at the start of a new or resumed session. |
| `skillInstructions` | Extra instructions prepended whenever a skill from this plugin is loaded. |
| `mcpServers` | MCP server declarations. They are displayed after install, but each server stays disabled until the user enables it. |
| `interface` | Display fields for `/plugins info`, such as `displayName`, `shortDescription`, `longDescription`, `developerName`, `capabilities`, `websiteURL`, and `defaultPrompt`. |
Unsupported legacy fields such as `tools`, `configFile`, `config_file`, `inject`, `bootstrap`, `hooks`, and `apps` are reported as diagnostics and ignored.
## Skills and session start
Plugin skills use the same `SKILL.md` format as ordinary [Agent Skills](./skills.md). The common layout is:
```text
my-plugin/
plugin.json
skills/
using-my-plugin/
SKILL.md
another-workflow/
SKILL.md
```
`sessionStart.skill` is a declarative session-start rule: it loads a skill into the main agent's context once at the start of a session. It does not execute code. Use it when the plugin needs to establish workflow rules before the first user task, such as mapping another tool harness's terminology to Kimi Code CLI tools.
`skillInstructions` stays next to the skill content whenever the skill is loaded, whether the skill was loaded by `sessionStart.skill`, by `/skill:<name>`, or by the model's automatic skill invocation.
## MCP servers in plugins
Plugin MCP servers reuse the same server schema as [MCP](./mcp.md). They can be stdio servers:
```json
{
"mcpServers": {
"finance": {
"command": "uvx",
"args": ["kimi-finance-mcp"]
}
}
}
```
Or HTTP servers:
```json
{
"mcpServers": {
"docs": {
"url": "https://example.com/mcp"
}
}
}
```
For stdio servers, `command` may be a command found on `PATH`, or a `./` path inside the plugin root. If `cwd` is set, it must also start with `./` and stay inside the plugin root. Plugin MCP servers inherit the current process environment; values written under `env` are literal overrides, not `${VAR}` interpolation.
Installing a plugin never starts its MCP servers. Enable a server explicitly:
```sh
/plugins mcp enable kimi-finance finance
/new
```
The enabled state is stored in `$KIMI_CODE_HOME/plugins/installed.json`. Once a new session starts, enabled plugin MCP servers go through the normal MCP lifecycle, status events, tool naming, and permission approval flow.
## Security model
Plugins are loaded conservatively:
- Only `plugin.json`, `.kimi-plugin/plugin.json`, and Markdown skill files are read during install and session startup.
- Plugin-provided scripts, commands, hooks, and legacy tool runtimes are not executed by the plugin loader.
- Plugin paths must stay inside the plugin root after symlinks are resolved.
- MCP servers declared by a plugin are opt-in and only start in a new session after `/plugins mcp enable`.
- Bad manifests or unsafe paths produce diagnostics shown by `/plugins info <id>` and do not crash unrelated sessions.

View file

@ -55,6 +55,7 @@ Some commands are only available in the idle state. Running them while the sessi
| `/usage` | — | Show token usage, context consumption, and quota information. | Yes |
| `/status` | — | Show the current session runtime status, including version, model, working directory, and permission mode. | Yes |
| `/mcp` | — | List the MCP servers in the current session and their connection status. | Yes |
| `/plugins` | — | List, install, inspect, enable, disable, remove, and reload plugins. Also enables or disables plugin-declared MCP servers with `/plugins mcp enable\|disable <id> <server>`. | Yes |
| `/version` | — | Show the Kimi Code CLI version number. | Yes |
| `/feedback` | — | Submit feedback to help improve Kimi Code CLI. | Yes |

View file

@ -28,6 +28,9 @@ export KIMI_CODE_HOME="$HOME/.config/kimi-code"
$KIMI_CODE_HOME (默认 ~/.kimi-code)
├── config.toml # 用户配置
├── mcp.json # 用户级 MCP server 声明(可选)
├── plugins/
│ ├── installed.json # 已安装 plugin 记录与能力状态
│ └── managed/ # Kimi Code CLI 管理的 zip 安装 plugins
├── session_index.jsonl # 会话索引
├── credentials/ # OAuth 凭据根目录(目录 0o700、文件 0o600
│ ├── <name>.json # 托管 Kimi / Open Platform 等 provider OAuth 凭据
@ -69,6 +72,8 @@ $KIMI_CODE_HOME (默认 ~/.kimi-code)
`mcp.json` 是用户级 MCP server 声明,会与项目内的 `.kimi-code/mcp.json` 合并加载。字段与项目级文件相同,详见 [MCP](../customization/mcp.md)。
`plugins/installed.json` 记录已安装的 plugins、每个 plugin 是否启用,以及通过 `/plugins mcp enable` 显式启用的 plugin MCP servers 等能力状态。本地 plugin 安装只保存源路径Zip URL 安装会解压到 `plugins/managed/<id>/`。详见 [Plugins](../customization/plugins.md)。
OAuth 凭据以文件形式存放在数据根下的 `credentials/` 子目录,目录权限 `0o700`、文件权限 `0o600`,仅当前用户可读写。其中:
- **托管 Kimi / Open Platform 等供应商的 OAuth 凭据**位于 `credentials/<name>.json`,例如 `~/.kimi-code/credentials/managed:kimi-code.json`
@ -128,4 +133,5 @@ Kimi Code CLI 在首次需要 ripgrep 时会自动下载并缓存。下载过程
| 清除托管 Kimi / Open Platform OAuth 登录态 | 运行 `/logout`(仅清理当前供应商的 OAuth或删除对应 `~/.kimi-code/credentials/<name>.json` |
| 清除 MCP server OAuth 登录态 | 删除 `~/.kimi-code/credentials/mcp/` 目录;`/logout` **不会**清理 MCP 的 OAuth 凭据 |
| 移除用户级 MCP 声明 | 删除 `~/.kimi-code/mcp.json` |
| 清理 plugin 安装记录和托管 zip plugins | 删除 `~/.kimi-code/plugins/` 目录;本地 plugin 源码目录不会被删除 |
| 清空用户级 Skills | 删除 `~/.kimi-code/skills/` 目录 |

View file

@ -17,6 +17,8 @@ MCP server 配置写在 `mcp.json` 中,分为两层:
最方便的入口是在 TUI 中运行 `/mcp-config`,它会引导你新增、编辑或删除 server。要查看当前连接状态可运行 `/mcp`
Plugins 也可以在 `plugin.json` 中声明 MCP servers。Plugin 声明的 servers 不会在安装时启动;需要通过 `/plugins mcp enable <plugin-id> <server>` 显式启用,并开启新会话。详见 [Plugins](./plugins.md)。
`mcp.json` 的顶层结构如下:
```json

View file

@ -0,0 +1,152 @@
# Plugins
Plugins 围绕 `plugin.json` manifest 打包可复用的 Kimi Code CLI 行为。一个 plugin 可以贡献 Skills为这些 Skills 增加插件级说明,声明会话开始时要加载的 Skill也可以声明需要用户显式启用的 MCP servers。多宿主仓库可以把同一份 Kimi manifest 放在 `.kimi-plugin/plugin.json`,避免占用仓库根目录。
Kimi Code CLI plugins 是数据包,不是任意命令运行时。安装 plugin 不会执行 plugin 提供的 Python、Node.js、Shell 或 hook 脚本。如果一个流程需要外部工具或实时数据,优先用 Skill 指导 Agent 调用 Kimi Code CLI 现有工具,或者声明 MCP server 并让用户显式启用。
## 安装与管理 plugins
在 TUI 中使用 `/plugins`
```sh
/plugins
/plugins install /absolute/path/to/plugin
/plugins install ./relative-plugin
/plugins install https://example.com/plugin.zip
/plugins info <id>
/plugins enable <id>
/plugins disable <id>
/plugins remove <id>
/plugins reload
/plugins mcp enable <id> <server>
/plugins mcp disable <id> <server>
```
本地目录只会登记到 `installed.json`不会被复制。Zip URL 会被下载、解压,并保存到 Kimi Code CLI 管理的 plugin 目录中。移除 plugin 只删除安装记录,不会删除原始本地源码目录。
Plugin 变更只对新会话生效。安装、启用、禁用、移除、重载 plugin或启用 plugin MCP server 后,需要通过 `/new` 开启新会话,新的 Skills、`sessionStart.skill` 和 MCP servers 才会进入会话。已有会话继续使用启动时的快照。
`/plugins reload` 会重新读取 `installed.json` 和每个 plugin manifest`/plugins``/plugins info <id>` 展示最新安装状态和 diagnostics。它不会热更新当前会话里的 Skills 或 MCP 连接。
## Manifest 格式
Kimi Code CLI 把根目录 `plugin.json` 作为优先 plugin manifest
```text
<plugin_root>/plugin.json
```
如果没有 `plugin.json`Kimi Code CLI 会读取 Kimi 专属 manifest
```text
<plugin_root>/.kimi-plugin/plugin.json
```
Kimi Code CLI 不读取 `.codex-plugin/plugin.json`。如果同时存在 `plugin.json``.kimi-plugin/plugin.json`,根目录 `plugin.json` 胜出,`.kimi-plugin` manifest 会在 `/plugins info` 中显示为 shadowed。
一个典型的 plugin manifest 如下:
```json
{
"name": "kimi-finance",
"version": "1.0.0",
"description": "Finance data and analysis workflows for Kimi Code CLI",
"keywords": ["finance", "mcp"],
"skills": "./skills/",
"sessionStart": {
"skill": "using-finance"
},
"skillInstructions": "Prefer finance MCP tools for live market data. Do not invent live prices.",
"mcpServers": {
"finance": {
"command": "uvx",
"args": ["kimi-finance-mcp"]
}
},
"interface": {
"displayName": "Kimi Finance",
"shortDescription": "Market data and financial analysis workflows"
}
}
```
支持的字段:
| 字段 | 说明 |
| --- | --- |
| `name` | 必填,作为 plugin id 来源。必须匹配 `[a-z0-9][a-z0-9_-]{0,63}`。 |
| `version``description``keywords``author``homepage``license` | 展示元数据。 |
| `skills` | 一个路径或路径数组。每个路径必须以 `./` 开头,并且符号链接解析后仍位于 plugin 根目录内。 |
| 根目录 `SKILL.md` | 如果省略 `skills`,且 plugin 根目录存在 `SKILL.md`,则根目录会作为单 Skill root 处理。 |
| `sessionStart.skill` | 声明式地在新会话或恢复会话开始时,把指定 Skill 注入到主 Agent。 |
| `skillInstructions` | 每次加载此 plugin 的 Skill 时,附加到 Skill 内容前面的额外说明。 |
| `mcpServers` | MCP server 声明。安装后会展示,但每个 server 默认禁用,直到用户显式启用。 |
| `interface` | `/plugins info` 的展示字段,例如 `displayName``shortDescription``longDescription``developerName``capabilities``websiteURL``defaultPrompt`。 |
`tools``configFile``config_file``inject``bootstrap``hooks``apps` 等旧字段只会产生 diagnostics 并被忽略。
## Skills 与 session start
Plugin Skills 使用和普通 [Agent Skills](./skills.md) 相同的 `SKILL.md` 格式。常见目录布局如下:
```text
my-plugin/
plugin.json
skills/
using-my-plugin/
SKILL.md
another-workflow/
SKILL.md
```
`sessionStart.skill` 是声明式会话启动规则:它会在会话开始时,把某个 Skill 一次性加载到主 Agent 上下文中。它不会执行代码。适合用于 plugin 需要在第一个用户任务前建立工作规则的场景,例如把另一个工具环境里的术语映射到 Kimi Code CLI 工具。
无论 Skill 是通过 `sessionStart.skill``/skill:<name>`,还是模型自动调用加载,`skillInstructions` 都会跟 Skill 内容放在一起。
## Plugin 中的 MCP servers
Plugin MCP servers 复用 [MCP](./mcp.md) 的 server schema。可以声明 stdio server
```json
{
"mcpServers": {
"finance": {
"command": "uvx",
"args": ["kimi-finance-mcp"]
}
}
}
```
也可以声明 HTTP server
```json
{
"mcpServers": {
"docs": {
"url": "https://example.com/mcp"
}
}
}
```
对于 stdio server`command` 可以是 `PATH` 上的命令,也可以是 plugin 根目录内以 `./` 开头的路径。如果设置 `cwd`,它也必须以 `./` 开头,并且位于 plugin 根目录内。Plugin MCP servers 会继承当前进程环境变量;写在 `env` 里的值是字面量覆盖,不是 `${VAR}` 插值。
安装 plugin 不会启动它的 MCP servers。需要显式启用
```sh
/plugins mcp enable kimi-finance finance
/new
```
启用状态保存在 `$KIMI_CODE_HOME/plugins/installed.json`。新会话启动后,已启用的 plugin MCP servers 会进入普通 MCP 生命周期,包括状态事件、工具命名和权限审批流程。
## 安全模型
Plugins 会被保守加载:
- 安装和会话启动时,只读取 `plugin.json``.kimi-plugin/plugin.json` 与 Markdown Skill 文件。
- Plugin 提供的脚本、命令、hooks 和旧式工具运行时不会由 plugin loader 执行。
- Plugin 路径在解析符号链接后必须仍位于 plugin 根目录内。
- Plugin 声明的 MCP servers 默认不启用,只有 `/plugins mcp enable` 后的新会话才会启动。
- 损坏的 manifest 或不安全路径会变成 `/plugins info <id>` 中的 diagnostics不会让无关会话崩溃。

View file

@ -55,6 +55,7 @@
| `/usage` | — | 显示 token 用量、上下文占用以及配额信息。 | 是 |
| `/status` | — | 显示当前会话运行时状态,包括版本、模型、工作目录和权限模式等。 | 是 |
| `/mcp` | — | 列出当前会话中的 MCP server 及其连接状态。 | 是 |
| `/plugins` | — | 列出、安装、查看、启用、禁用、移除和重载 plugins也可通过 `/plugins mcp enable\|disable <id> <server>` 启用或禁用 plugin 声明的 MCP servers。 | 是 |
| `/version` | — | 显示 Kimi Code CLI 版本号。 | 是 |
| `/feedback` | — | 提交反馈以改进 Kimi Code CLI。 | 是 |

View file

@ -12,6 +12,8 @@ import {
type Tool,
} from '@moonshot-ai/kosong';
import type { EnabledPluginSessionStart } from '#/plugin';
import type { McpConnectionManager } from '../mcp';
import {
resolveSystemPromptCwd,
@ -81,12 +83,14 @@ export interface AgentConfig {
/** Parent logger; the agent appends its own ctx (agentId already bound by session). */
readonly log?: Logger;
readonly telemetry?: TelemetryClient | undefined;
readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[];
}
export class Agent {
readonly runtime: RuntimeConfig;
readonly homedir?: string;
readonly skills?: SkillManager;
readonly pluginSessionStarts: readonly EnabledPluginSessionStart[];
readonly rawGenerate: typeof generate;
readonly rpc: SDKAgentRPC;
readonly telemetry: TelemetryClient;
@ -119,6 +123,7 @@ export class Agent {
if (config.skills !== undefined) {
this.skills = new SkillManager(this, config.skills);
}
this.pluginSessionStarts = config.pluginSessionStarts ?? [];
this.rawGenerate = config.generate ?? generate;
this.providerManager =
config.sessionId === undefined

View file

@ -1,13 +1,18 @@
import type { Agent } from '..';
import type { DynamicInjector } from './injector';
import { PermissionModeInjector } from './permission-mode';
import { PluginSessionStartInjector } from './plugin-session-start';
import { PlanModeInjector } from './plan-mode';
export class InjectionManager {
private readonly injectors: DynamicInjector[];
constructor(protected readonly agent: Agent) {
this.injectors = [new PlanModeInjector(agent), new PermissionModeInjector(agent)];
this.injectors = [
new PluginSessionStartInjector(agent),
new PlanModeInjector(agent),
new PermissionModeInjector(agent),
];
}
async inject(): Promise<void> {

View file

@ -0,0 +1,57 @@
import type { EnabledPluginSessionStart } from '../../plugin/types';
import type { SkillDefinition } from '../../skill';
import { DynamicInjector } from './injector';
export class PluginSessionStartInjector extends DynamicInjector {
protected override readonly injectionVariant = 'plugin_session_start';
protected override async getInjection(): Promise<string | undefined> {
// 临时阅读注释sessionStart 是一次性 skill 注入,不会执行插件脚本。
if (this.injectedAt !== null) return undefined;
// 临时阅读注释resume/replay 时如果历史里已经有过 plugin sessionStart就不重复注入。
const replayedAt = this.agent.context.history.findIndex(
(message) =>
message.origin?.kind === 'injection' &&
message.origin.variant === this.injectionVariant,
);
if (replayedAt >= 0) {
this.injectedAt = replayedAt;
return undefined;
}
const sessionStarts = this.agent.pluginSessionStarts ?? [];
if (sessionStarts.length === 0) return undefined;
const registry = this.agent.skills?.registry;
if (registry === undefined) return undefined;
const blocks: string[] = [];
for (const sessionStart of sessionStarts) {
const skill = registry.getSkill(sessionStart.skillName);
if (skill === undefined) {
// 插件 enabled 了但 manifest 声明的 sessionStart skill 在 registry 里找不到。
this.agent.log.warn('plugin sessionStart skill not found', {
pluginId: sessionStart.pluginId,
skillName: sessionStart.skillName,
});
continue;
}
// 临时阅读注释:必须走 renderSkillPrompt这样插件的 kimi-plugin-instructions 也会一起被注入。
blocks.push(renderSessionStartBlock(sessionStart, skill, registry.renderSkillPrompt(skill, '')));
}
if (blocks.length === 0) return undefined;
return blocks.join('\n');
}
}
function renderSessionStartBlock(
sessionStart: EnabledPluginSessionStart,
skill: SkillDefinition,
skillContent: string,
): string {
return (
`<plugin_session_start plugin="${escapeAttr(sessionStart.pluginId)}" ` +
`skill="${escapeAttr(skill.name)}">\n${skillContent}\n</plugin_session_start>`
);
}
function escapeAttr(value: string): string {
return value.replaceAll('"', '&quot;');
}

View file

@ -58,6 +58,9 @@ export const ErrorCodes = {
MCP_STARTUP_FAILED: 'mcp.startup_failed',
MCP_TOOL_NAME_COLLISION: 'mcp.tool_name_collision',
PLUGIN_NOT_FOUND: 'plugin.not_found',
PLUGIN_LOAD_FAILED: 'plugin.load_failed',
REQUEST_INVALID: 'request.invalid',
REQUEST_WORK_DIR_REQUIRED: 'request.work_dir_required',
REQUEST_PROMPT_INPUT_EMPTY: 'request.prompt_input_empty',
@ -336,6 +339,19 @@ export const KIMI_ERROR_INFO = {
action: 'Rename one of the colliding MCP tools or servers so their qualified names are unique.',
},
'plugin.not_found': {
title: 'Plugin not found',
retryable: false,
public: true,
action: 'List installed plugins via /plugins and check the requested id.',
},
'plugin.load_failed': {
title: 'Plugin state failed to load',
retryable: true,
public: true,
action: 'Fix the installed.json file under $KIMI_CODE_HOME/plugins/ and run /plugins reload.',
},
'request.invalid': {
title: 'Invalid request',
retryable: false,

View file

@ -5,6 +5,7 @@ export * from './config';
export * from './session/export';
export * from './telemetry';
export * from './errors';
export * from './plugin';
export {
flushDiagnosticLogs,
getRootLogger,

View file

@ -0,0 +1 @@
export * from './plugin/index';

View file

@ -0,0 +1,143 @@
import { createWriteStream } from 'node:fs';
import { mkdir, readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { type Entry, fromBuffer as yauzlFromBuffer } from 'yauzl';
export async function downloadZip(url: string, signal?: AbortSignal): Promise<Buffer> {
const controller = new AbortController();
const timeoutHandle = setTimeout(() => {
controller.abort();
}, 5 * 60 * 1000);
try {
const resp = await fetch(url, { signal: signal ?? controller.signal });
if (!resp.ok) {
throw new Error(`Failed to download zip: HTTP ${resp.status} ${resp.statusText}`);
}
return Buffer.from(await resp.arrayBuffer());
} finally {
clearTimeout(timeoutHandle);
}
}
export async function extractZip(buffer: Buffer, destDir: string): Promise<string> {
await mkdir(destDir, { recursive: true });
// yauzl 已经会 reject 绝对路径、反斜杠、`..` 路径组件;这里再用 path.resolve 兜底,
// 防 yauzl 行为变化或链接型 entry 漏过去,同时避免把 foo..bar.txt 这种合法名误杀。
const destDirResolved = path.resolve(destDir);
let settled = false;
await new Promise<void>((resolve, reject) => {
yauzlFromBuffer(buffer, { lazyEntries: true }, (openErr, zipfile) => {
if (openErr !== null || zipfile === undefined) {
reject(new Error(`Failed to open zip: ${openErr?.message ?? 'unknown error'}`));
return;
}
const onEntry = (entry: Entry): void => {
const fileName = entry.fileName;
const destPath = path.resolve(destDir, fileName);
if (destPath !== destDirResolved && !destPath.startsWith(destDirResolved + path.sep)) {
if (!settled) {
settled = true;
reject(new Error(`Path traversal detected in zip entry: ${fileName}`));
}
zipfile.close();
return;
}
if (fileName.endsWith('/')) {
mkdir(destPath, { recursive: true })
.then(() => {
zipfile.readEntry();
})
.catch((error) => {
if (!settled) {
settled = true;
reject(error);
}
zipfile.close();
});
return;
}
zipfile.openReadStream(entry, (streamErr, stream) => {
if (streamErr !== null || stream === undefined) {
if (!settled) {
settled = true;
reject(
new Error(
`Failed to read ${fileName} from archive: ${streamErr?.message ?? 'unknown error'}`,
),
);
}
zipfile.close();
return;
}
mkdir(path.dirname(destPath), { recursive: true })
.then(() => pipeline(stream, createWriteStream(destPath)))
.then(() => {
zipfile.readEntry();
})
.catch((error) => {
if (!settled) {
settled = true;
reject(error);
}
zipfile.close();
});
});
};
zipfile.on('entry', onEntry);
zipfile.on('end', () => {
if (!settled) {
settled = true;
resolve();
}
});
zipfile.on('error', (err: Error) => {
if (!settled) {
settled = true;
reject(err);
}
});
zipfile.readEntry();
});
});
return detectPluginRoot(destDir);
}
async function detectPluginRoot(dir: string): Promise<string> {
async function search(current: string): Promise<string | undefined> {
const entries = await readdir(current, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const child = path.join(current, entry.name);
if (await hasManifest(child)) return child;
const deeper = await search(child);
if (deeper !== undefined) return deeper;
}
return undefined;
}
const found = await search(dir);
return found ?? dir;
}
async function hasManifest(dir: string): Promise<boolean> {
const pluginJson = path.join(dir, 'plugin.json');
const kimiPluginJson = path.join(dir, '.kimi-plugin', 'plugin.json');
return (await isFile(pluginJson)) || (await isFile(kimiPluginJson));
}
async function isFile(p: string): Promise<boolean> {
try {
return (await stat(p)).isFile();
} catch {
return false;
}
}

View file

@ -0,0 +1,10 @@
export * from './types';
export { parseManifest } from './manifest';
export type { ParsedManifestResult } from './manifest';
export { readInstalled, writeInstalled } from './store';
export type { InstalledFile, InstalledRecord } from './store';
export { PluginManager } from './manager';
export type { PluginManagerOptions } from './manager';
export { resolveInstallSource } from './source';
export type { InstallSource, ResolvedSource } from './source';
export { downloadZip, extractZip } from './archive';

View file

@ -0,0 +1,400 @@
import { cp, mkdir, mkdtemp, realpath, rename, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type { McpServerConfig } from '../config/schema';
import type { SkillRoot } from '../skill';
import { downloadZip, extractZip } from './archive';
import { parseManifest, type ParsedManifestResult } from './manifest';
import { readInstalled, writeInstalled, type InstalledRecord } from './store';
import { resolveInstallSource } from './source';
import {
type EnabledPluginSessionStart,
type PluginCapabilityState,
type PluginInfo,
type PluginMcpServerInfo,
type PluginRecord,
type PluginSource,
type PluginSummary,
type ReloadSummary,
normalizePluginId,
} from './types';
export interface PluginManagerOptions {
readonly kimiHomeDir: string;
}
export class PluginManager {
private readonly kimiHomeDir: string;
private records = new Map<string, PluginRecord>();
constructor(options: PluginManagerOptions) {
this.kimiHomeDir = options.kimiHomeDir;
}
async load(): Promise<void> {
// 临时阅读注释:启动时从 installed.json 恢复插件列表,并重新读取每个插件当前的 manifest。
const file = await readInstalled(this.kimiHomeDir);
const next = new Map<string, PluginRecord>();
for (const entry of file.plugins) {
next.set(entry.id, await this.materialize(entry));
}
this.records = next;
}
list(): readonly PluginRecord[] {
return [...this.records.values()].toSorted((a, b) => a.id.localeCompare(b.id));
}
get(id: string): PluginRecord | undefined {
return this.records.get(normalizePluginId(id));
}
async install(source: string): Promise<PluginRecord> {
const resolved = resolveInstallSource(source);
let normalizedRoot: string;
let originalSource: string;
let sourceType: PluginSource;
let parsed: ParsedManifestResult;
if (resolved.kind === 'local-path') {
normalizedRoot = await normalizeInstallRoot(resolved.path);
originalSource = resolved.path;
sourceType = 'local-path';
parsed = await parseManifest(normalizedRoot);
} else {
// zip-url
const buffer = await downloadZip(resolved.path);
const tmpDir = await mkdtemp(path.join(tmpdir(), 'kimi-plugin-zip-'));
let detectedRoot: string;
try {
detectedRoot = await extractZip(buffer, tmpDir);
} catch (error) {
await rm(tmpDir, { recursive: true, force: true });
throw error;
}
parsed = await parseManifest(detectedRoot);
if (parsed.manifest === undefined) {
await rm(tmpDir, { recursive: true, force: true });
const msg = parsed.diagnostics.find((d) => d.severity === 'error')?.message ?? 'no manifest';
throw new Error(`Cannot install plugin from ${resolved.path}: ${msg}`);
}
const id = normalizePluginId(parsed.manifest.name);
const existing = this.records.get(id);
if (existing !== undefined) {
if (existing.source === 'local-path') {
await rm(tmpDir, { recursive: true, force: true });
throw new Error(`Plugin "${id}" is already installed from a local directory. Remove it first.`);
}
}
normalizedRoot = path.join(this.kimiHomeDir, 'plugins', 'managed', id);
const managedDir = path.dirname(normalizedRoot);
await mkdir(managedDir, { recursive: true });
const stagingRoot = await mkdtemp(path.join(managedDir, `${id}-`));
try {
await cp(detectedRoot, stagingRoot, { recursive: true });
await rm(normalizedRoot, { recursive: true, force: true });
await rename(stagingRoot, normalizedRoot);
} catch (error) {
await rm(stagingRoot, { recursive: true, force: true });
throw error;
}
if (existing !== undefined) this.records.delete(id);
normalizedRoot = await realpath(normalizedRoot);
parsed = await parseManifest(normalizedRoot);
await rm(tmpDir, { recursive: true, force: true });
originalSource = resolved.path;
sourceType = 'zip-url';
}
if (parsed.manifest === undefined) {
const msg = parsed.diagnostics.find((d) => d.severity === 'error')?.message ?? 'no manifest';
throw new Error(`Cannot install plugin at ${normalizedRoot}: ${msg}`);
}
const id = normalizePluginId(parsed.manifest.name);
if (this.records.has(id)) {
throw new Error(`Plugin "${id}" is already installed`);
}
const now = new Date().toISOString();
const record = recordFrom({
id,
root: normalizedRoot,
enabled: true,
installedAt: now,
updatedAt: now,
originalSource,
source: sourceType,
parsed,
});
this.records.set(id, record);
await this.persist();
return record;
}
async setEnabled(id: string, enabled: boolean): Promise<void> {
// 临时阅读注释enable/disable 只改持久化状态session 创建时才会重新读取这个状态。
const key = normalizePluginId(id);
const current = this.records.get(key);
if (current === undefined) throw new Error(`Plugin "${id}" is not installed`);
if (current.enabled === enabled) return;
const now = new Date().toISOString();
this.records.set(key, { ...current, enabled, updatedAt: now });
await this.persist();
}
async setMcpServerEnabled(id: string, server: string, enabled: boolean): Promise<void> {
const key = normalizePluginId(id);
const current = this.records.get(key);
if (current === undefined) throw new Error(`Plugin "${id}" is not installed`);
if (current.manifest?.mcpServers?.[server] === undefined) {
throw new Error(`Plugin "${id}" does not declare MCP server "${server}"`);
}
const currentMcpServers = current.capabilities?.mcpServers ?? {};
const nextCapabilities: PluginCapabilityState = {
...current.capabilities,
mcpServers: {
...currentMcpServers,
[server]: { enabled },
},
};
this.records.set(key, {
...current,
capabilities: nextCapabilities,
updatedAt: new Date().toISOString(),
});
await this.persist();
}
async remove(id: string): Promise<void> {
const key = normalizePluginId(id);
if (!this.records.delete(key)) {
throw new Error(`Plugin "${id}" is not installed`);
}
await this.persist();
}
async reload(): Promise<ReloadSummary> {
// 临时阅读注释reload 用于用户手动改 manifest 或 installed.json 后刷新内存快照。
const prevIds = new Set(this.records.keys());
const file = await readInstalled(this.kimiHomeDir);
const next = new Map<string, PluginRecord>();
const errors: Array<{ id: string; message: string }> = [];
for (const entry of file.plugins) {
try {
next.set(entry.id, await this.materialize(entry));
} catch (error) {
errors.push({ id: entry.id, message: (error as Error).message });
}
}
const added: string[] = [];
for (const id of next.keys()) if (!prevIds.has(id)) added.push(id);
const removed: string[] = [];
for (const id of prevIds) if (!next.has(id)) removed.push(id);
this.records = next;
return { added, removed, errors };
}
pluginSkillRoots(): readonly SkillRoot[] {
// 临时阅读注释:这里把启用插件的 skills 目录转成 SkillRoot并挂上 plugin instructions。
const roots: SkillRoot[] = [];
for (const record of this.records.values()) {
if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue;
for (const dir of record.manifest.skills ?? []) {
roots.push({
path: dir,
source: 'extra',
plugin: {
id: record.id,
instructions: record.skillInstructions,
},
});
}
}
return roots;
}
enabledSessionStarts(): readonly EnabledPluginSessionStart[] {
// 临时阅读注释sessionStart 是纯声明式 skill 注入,不执行插件脚本。
const out: EnabledPluginSessionStart[] = [];
for (const record of this.records.values()) {
if (!record.enabled || record.state !== 'ok') continue;
const skill = record.manifest?.sessionStart?.skill;
if (skill === undefined) continue;
out.push({ pluginId: record.id, skillName: skill });
}
return out;
}
enabledMcpServers(): Record<string, McpServerConfig> {
const out: Record<string, McpServerConfig> = {};
for (const record of this.records.values()) {
if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue;
for (const [name, config] of Object.entries(record.manifest.mcpServers ?? {})) {
if (!isMcpServerEnabled(record, name)) continue;
out[pluginMcpRuntimeName(record.id, name)] = config;
}
}
return out;
}
summaries(): readonly PluginSummary[] {
return this.list().map((record) => recordToSummary(record));
}
info(id: string): PluginInfo | undefined {
const record = this.get(id);
return record === undefined ? undefined : recordToInfo(record);
}
private async persist(): Promise<void> {
const installed: InstalledRecord[] = [...this.records.values()].map((record) => ({
id: record.id,
root: record.root,
source: record.source,
enabled: record.enabled,
installedAt: record.installedAt,
updatedAt: record.updatedAt,
originalSource: record.originalSource,
capabilities: record.capabilities,
}));
await writeInstalled(this.kimiHomeDir, { version: 1, plugins: installed });
}
private async materialize(entry: InstalledRecord): Promise<PluginRecord> {
const parsed = await parseManifest(entry.root);
return recordFrom({
id: entry.id,
root: entry.root,
enabled: entry.enabled,
installedAt: entry.installedAt,
updatedAt: entry.updatedAt,
originalSource: entry.originalSource,
capabilities: entry.capabilities,
source: entry.source,
parsed,
});
}
}
async function normalizeInstallRoot(rootPath: string): Promise<string> {
// 临时阅读注释:安装路径必须是绝对路径并 realpath避免 installed.json 里存相对路径造成 session 间语义不稳定。
const trimmed = rootPath.trim();
if (!path.isAbsolute(trimmed)) {
throw new Error(`Plugin root must be an absolute path (got "${rootPath}")`);
}
let resolved: string;
try {
resolved = await realpath(trimmed);
} catch (error) {
throw new Error(`Plugin root does not exist: ${trimmed}`, { cause: error });
}
if (!(await stat(resolved)).isDirectory()) {
throw new Error(`Plugin root is not a directory: ${trimmed}`);
}
return resolved;
}
function recordFrom(input: {
id: string;
root: string;
enabled: boolean;
installedAt: string;
updatedAt?: string;
originalSource?: string;
capabilities?: PluginCapabilityState;
source?: PluginSource;
parsed: ParsedManifestResult;
}): PluginRecord {
// 临时阅读注释manifest 里有 error diagnostic 时,插件仍可出现在列表里,但不会给 session 贡献 skills/sessionStart。
const { parsed } = input;
const hasError = parsed.diagnostics.some((d) => d.severity === 'error');
const base: PluginRecord = {
id: input.id,
root: input.root,
source: input.source ?? 'local-path',
enabled: input.enabled,
state: hasError || parsed.manifest === undefined ? 'error' : 'ok',
installedAt: input.installedAt,
updatedAt: input.updatedAt,
originalSource: input.originalSource,
capabilities: input.capabilities,
manifest: parsed.manifest,
manifestKind: parsed.manifestKind,
manifestPath: parsed.manifestPath,
shadowedManifestPath: parsed.shadowedManifestPath,
diagnostics: parsed.diagnostics,
skillInstructions: parsed.manifest?.skillInstructions,
};
return base;
}
function recordToSummary(record: PluginRecord): PluginSummary {
return {
id: record.id,
displayName: record.manifest?.interface?.displayName ?? record.id,
version: record.manifest?.version,
enabled: record.enabled,
state: record.state,
skillCount: record.manifest?.skills?.length ?? 0,
mcpServerCount: Object.keys(record.manifest?.mcpServers ?? {}).length,
enabledMcpServerCount: pluginMcpServersInfo(record).filter((server) => server.enabled).length,
hasErrors: record.diagnostics.some((d) => d.severity === 'error'),
};
}
function recordToInfo(record: PluginRecord): PluginInfo {
return {
...recordToSummary(record),
source: record.source,
root: record.root,
originalSource: record.originalSource,
manifestKind: record.manifestKind,
manifestPath: record.manifestPath,
manifest: record.manifest,
mcpServers: pluginMcpServersInfo(record),
shadowedManifestPath: record.shadowedManifestPath,
diagnostics: record.diagnostics,
};
}
function isMcpServerEnabled(record: PluginRecord, name: string): boolean {
return record.capabilities?.mcpServers?.[name]?.enabled === true;
}
function pluginMcpServersInfo(record: PluginRecord): readonly PluginMcpServerInfo[] {
return Object.entries(record.manifest?.mcpServers ?? {})
.map(([name, config]) => pluginMcpServerInfo(record, name, config))
.toSorted((a, b) => a.name.localeCompare(b.name));
}
function pluginMcpServerInfo(
record: PluginRecord,
name: string,
config: McpServerConfig,
): PluginMcpServerInfo {
if (config.transport === 'http') {
return {
name,
runtimeName: pluginMcpRuntimeName(record.id, name),
enabled: isMcpServerEnabled(record, name),
transport: 'http',
url: config.url,
headerKeys: config.headers === undefined ? undefined : Object.keys(config.headers).toSorted(),
};
}
return {
name,
runtimeName: pluginMcpRuntimeName(record.id, name),
enabled: isMcpServerEnabled(record, name),
transport: 'stdio',
command: config.command,
args: config.args,
cwd: config.cwd,
envKeys: config.env === undefined ? undefined : Object.keys(config.env).toSorted(),
};
}
function pluginMcpRuntimeName(pluginId: string, serverName: string): string {
return `plugin-${pluginId}-${serverName}`;
}

View file

@ -0,0 +1,438 @@
import { realpath, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { McpServerConfigSchema, type McpServerConfig } from '../config/schema';
import {
PLUGIN_NAME_REGEX,
type PluginDiagnostic,
type PluginInterface,
type PluginManifest,
type PluginManifestKind,
} from './types';
const PLUGIN_JSON_PATH = 'plugin.json';
const KIMI_PLUGIN_JSON_PATH = '.kimi-plugin/plugin.json';
export interface ParsedManifestResult {
readonly manifest?: PluginManifest;
readonly manifestKind?: PluginManifestKind;
readonly manifestPath?: string;
readonly shadowedManifestPath?: string;
readonly diagnostics: readonly PluginDiagnostic[];
}
export async function parseManifest(pluginRoot: string): Promise<ParsedManifestResult> {
const pluginJsonPath = path.join(pluginRoot, PLUGIN_JSON_PATH);
const kimiPluginJsonPath = path.join(pluginRoot, KIMI_PLUGIN_JSON_PATH);
const pluginJsonExists = await isFile(pluginJsonPath);
const kimiPluginJsonExists = await isFile(kimiPluginJsonPath);
if (!pluginJsonExists && !kimiPluginJsonExists) {
return {
diagnostics: [
{
severity: 'error',
code: 'manifest.missing',
message: `No manifest at ${PLUGIN_JSON_PATH} or ${KIMI_PLUGIN_JSON_PATH}`,
},
],
};
}
const manifestPath = pluginJsonExists ? pluginJsonPath : kimiPluginJsonPath;
const manifestKind: PluginManifestKind = pluginJsonExists ? 'plugin-json' : 'kimi-plugin';
const shadowedManifestPath =
pluginJsonExists && kimiPluginJsonExists ? kimiPluginJsonPath : undefined;
let raw: unknown;
try {
const text = await readFile(manifestPath, 'utf8');
raw = JSON.parse(text);
} catch (error) {
return {
manifestKind,
manifestPath,
shadowedManifestPath,
diagnostics: [
{
severity: 'error',
code: 'manifest.invalid_json',
message: `Failed to parse ${path.relative(pluginRoot, manifestPath)}: ${(error as Error).message}`,
},
],
};
}
if (!isObject(raw)) {
return {
manifestKind,
manifestPath,
shadowedManifestPath,
diagnostics: [
{
severity: 'error',
code: 'manifest.invalid_json',
message: 'manifest must be a JSON object',
},
],
};
}
const diagnostics: PluginDiagnostic[] = [];
const name = typeof raw['name'] === 'string' ? raw['name'].trim() : '';
if (name.length === 0) {
diagnostics.push({
severity: 'error',
code: 'manifest.missing_name',
message: '"name" is required',
});
return { manifestKind, manifestPath, shadowedManifestPath, diagnostics };
}
if (!PLUGIN_NAME_REGEX.test(name)) {
diagnostics.push({
severity: 'error',
code: 'manifest.invalid_name',
message: `"name" must match ${PLUGIN_NAME_REGEX} (got "${name}")`,
});
return { manifestKind, manifestPath, shadowedManifestPath, diagnostics };
}
let skills = await resolveSkillsField(pluginRoot, raw['skills'], diagnostics);
if (raw['skills'] === undefined) {
const rootSkillMd = path.join(pluginRoot, 'SKILL.md');
if (await isFile(rootSkillMd)) {
skills = [pluginRoot];
}
}
const skillInstructions =
typeof raw['skillInstructions'] === 'string' ? raw['skillInstructions'] : undefined;
recordUnsupportedPluginJsonFields(raw, diagnostics);
const manifest: PluginManifest = {
name,
version: stringField(raw, 'version'),
description: stringField(raw, 'description'),
keywords: stringArrayField(raw, 'keywords'),
homepage: stringField(raw, 'homepage'),
license: stringField(raw, 'license'),
author: readAuthor(raw['author']),
skills,
sessionStart: readSessionStart(raw['sessionStart'], diagnostics),
mcpServers: await readMcpServers(pluginRoot, raw['mcpServers'], diagnostics),
interface: readInterface(raw['interface']),
skillInstructions,
};
return {
manifest,
manifestKind,
manifestPath,
shadowedManifestPath,
diagnostics,
};
}
function recordUnsupportedPluginJsonFields(
raw: Record<string, unknown>,
diagnostics: PluginDiagnostic[],
): void {
for (const field of [
'tools',
'configFile',
'config_file',
'inject',
'bootstrap',
'hooks',
'apps',
] as const) {
if (raw[field] === undefined) continue;
diagnostics.push({
severity: 'info',
code: `manifest.unsupported_field.${field}`,
message: `"${field}" is present but not supported by Kimi plugins`,
});
}
}
async function resolveSkillsField(
pluginRoot: string,
raw: unknown,
diagnostics: PluginDiagnostic[],
): Promise<readonly string[]> {
if (raw === undefined) return [];
const entries: string[] = [];
if (typeof raw === 'string') {
entries.push(raw);
} else if (Array.isArray(raw) && raw.every((entry) => typeof entry === 'string')) {
entries.push(...raw);
} else {
diagnostics.push({
severity: 'error',
code: 'manifest.skills.invalid_type',
message: '"skills" must be a string or string[]',
});
return [];
}
const resolved: string[] = [];
for (const entry of entries) {
if (!entry.startsWith('./')) {
diagnostics.push({
severity: 'error',
code: 'manifest.skills.path_required_dot_slash',
message: `"skills" path must start with "./" (got "${entry}")`,
});
continue;
}
const absolute = path.resolve(pluginRoot, entry);
let real: string;
try {
real = await realpath(absolute);
} catch {
real = absolute; // missing path is allowed; we'll catch via not_a_directory below
}
const rootReal = await realpath(pluginRoot).catch(() => pluginRoot);
if (!isWithin(real, rootReal)) {
diagnostics.push({
severity: 'error',
code: 'manifest.skills.path_escape',
message: `"skills" path resolves outside the plugin (${entry})`,
});
continue;
}
if (!(await isDir(real))) {
diagnostics.push({
severity: 'warn',
code: 'manifest.skills.not_a_directory',
message: `"skills" path is not a directory (${entry})`,
});
continue;
}
resolved.push(real);
}
return resolved;
}
async function resolvePluginPathField(input: {
readonly pluginRoot: string;
readonly field: string;
readonly value: string;
readonly diagnostics: PluginDiagnostic[];
readonly codePrefix: string;
}): Promise<string | undefined> {
if (!input.value.startsWith('./')) {
input.diagnostics.push({
severity: 'warn',
code: `${input.codePrefix}.path_required_dot_slash`,
message: `"${input.field}" path must start with "./" (got "${input.value}")`,
});
return undefined;
}
const absolute = path.resolve(input.pluginRoot, input.value);
let real: string;
try {
real = await realpath(absolute);
} catch {
real = absolute;
}
const rootReal = await realpath(input.pluginRoot).catch(() => input.pluginRoot);
if (!isWithin(real, rootReal)) {
input.diagnostics.push({
severity: 'warn',
code: `${input.codePrefix}.path_escape`,
message: `"${input.field}" path resolves outside the plugin (${input.value})`,
});
return undefined;
}
return real;
}
function readSessionStart(
raw: unknown,
diagnostics: PluginDiagnostic[],
): PluginManifest['sessionStart'] {
if (raw === undefined) return undefined;
if (!isObject(raw)) {
diagnostics.push({
severity: 'warn',
code: 'manifest.sessionStart.invalid_type',
message: '"sessionStart" must be an object',
});
return undefined;
}
const skill = typeof raw['skill'] === 'string' ? raw['skill'].trim() : '';
if (skill.length === 0) {
diagnostics.push({
severity: 'warn',
code: 'manifest.sessionStart.missing_skill',
message: '"sessionStart.skill" is required when sessionStart is present',
});
return undefined;
}
return { skill };
}
async function readMcpServers(
pluginRoot: string,
raw: unknown,
diagnostics: PluginDiagnostic[],
): Promise<PluginManifest['mcpServers']> {
if (raw === undefined) return undefined;
if (!isObject(raw)) {
diagnostics.push({
severity: 'warn',
code: 'manifest.mcpServers.invalid_type',
message: '"mcpServers" must be an object',
});
return undefined;
}
const out: Record<string, McpServerConfig> = {};
for (const [name, value] of Object.entries(raw)) {
const trimmedName = name.trim();
if (trimmedName.length === 0) {
diagnostics.push({
severity: 'warn',
code: 'manifest.mcpServers.invalid_name',
message: '"mcpServers" entries must have a non-empty name',
});
continue;
}
const parsed = McpServerConfigSchema.safeParse(value);
if (!parsed.success) {
diagnostics.push({
severity: 'warn',
code: `manifest.mcpServers.${trimmedName}.invalid`,
message: `Invalid MCP server "${trimmedName}": ${parsed.error.message}`,
});
continue;
}
const normalized = await normalizePluginMcpServer({
pluginRoot,
name: trimmedName,
config: parsed.data,
diagnostics,
});
if (normalized !== undefined) out[trimmedName] = normalized;
}
return Object.keys(out).length === 0 ? undefined : out;
}
async function normalizePluginMcpServer(input: {
readonly pluginRoot: string;
readonly name: string;
readonly config: McpServerConfig;
readonly diagnostics: PluginDiagnostic[];
}): Promise<McpServerConfig | undefined> {
const { config } = input;
if (config.transport === 'http') return config;
let command = config.command;
if (command.startsWith('./')) {
const resolvedCommand = await resolvePluginPathField({
pluginRoot: input.pluginRoot,
field: `mcpServers.${input.name}.command`,
value: command,
diagnostics: input.diagnostics,
codePrefix: `manifest.mcpServers.${input.name}.command`,
});
if (resolvedCommand === undefined) return undefined;
command = resolvedCommand;
} else if (command.includes('/') || path.isAbsolute(command)) {
input.diagnostics.push({
severity: 'warn',
code: `manifest.mcpServers.${input.name}.command.path_required_dot_slash`,
message: `"mcpServers.${input.name}.command" must be a PATH command or start with "./"`,
});
return undefined;
}
let cwd = config.cwd;
if (cwd !== undefined) {
const resolvedCwd = await resolvePluginPathField({
pluginRoot: input.pluginRoot,
field: `mcpServers.${input.name}.cwd`,
value: cwd,
diagnostics: input.diagnostics,
codePrefix: `manifest.mcpServers.${input.name}.cwd`,
});
if (resolvedCwd === undefined) return undefined;
cwd = resolvedCwd;
}
return { ...config, command, cwd };
}
function readAuthor(raw: unknown): PluginManifest['author'] {
if (typeof raw === 'string') return { name: raw };
if (!isObject(raw)) return undefined;
const name = stringField(raw, 'name');
const email = stringField(raw, 'email');
if (name === undefined && email === undefined) return undefined;
return { name, email };
}
function readInterface(raw: unknown): PluginInterface | undefined {
if (!isObject(raw)) return undefined;
const out: PluginInterface = {
displayName: stringField(raw, 'displayName'),
shortDescription: stringField(raw, 'shortDescription'),
longDescription: stringField(raw, 'longDescription'),
developerName: stringField(raw, 'developerName'),
capabilities: stringArrayField(raw, 'capabilities'),
websiteURL: stringField(raw, 'websiteURL'),
defaultPrompt: defaultPromptField(raw['defaultPrompt']),
};
const hasAny = Object.values(out).some((value) => value !== undefined);
return hasAny ? out : undefined;
}
function defaultPromptField(raw: unknown): PluginInterface['defaultPrompt'] {
if (typeof raw === 'string') return raw;
if (Array.isArray(raw) && raw.every((entry) => typeof entry === 'string')) {
return raw as readonly string[];
}
return undefined;
}
function stringField(raw: Record<string, unknown>, key: string): string | undefined {
const value = raw[key];
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed.length === 0 ? undefined : trimmed;
}
function stringArrayField(raw: Record<string, unknown>, key: string): readonly string[] | undefined {
const value = raw[key];
if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) return undefined;
return value as readonly string[];
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isWithin(child: string, parent: string): boolean {
const relative = path.relative(parent, child);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
async function isFile(p: string): Promise<boolean> {
try {
return (await stat(p)).isFile();
} catch {
return false;
}
}
async function isDir(p: string): Promise<boolean> {
try {
return (await stat(p)).isDirectory();
} catch {
return false;
}
}

View file

@ -0,0 +1,21 @@
import path from 'node:path';
export type InstallSource =
| { kind: 'local-path'; path: string }
| { kind: 'zip-url'; path: string };
export interface ResolvedSource {
readonly kind: 'local-path' | 'zip-url';
readonly path: string;
}
export function resolveInstallSource(source: string): ResolvedSource {
const trimmed = source.trim();
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return { kind: 'zip-url', path: trimmed };
}
if (!path.isAbsolute(trimmed)) {
throw new Error(`Plugin root must be an absolute path (got "${source}")`);
}
return { kind: 'local-path', path: trimmed };
}

View file

@ -0,0 +1,61 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { PluginCapabilityState, PluginSource } from './types';
const INSTALLED_REL = path.join('plugins', 'installed.json');
export interface InstalledRecord {
readonly id: string;
readonly root: string;
readonly source: PluginSource;
readonly enabled: boolean;
readonly installedAt: string;
readonly updatedAt?: string;
readonly originalSource?: string;
readonly capabilities?: PluginCapabilityState;
}
export interface InstalledFile {
readonly version: 1;
readonly plugins: readonly InstalledRecord[];
}
const EMPTY: InstalledFile = { version: 1, plugins: [] };
export async function readInstalled(kimiHomeDir: string): Promise<InstalledFile> {
// 临时阅读注释installed.json 是唯一的安装状态来源;缺文件代表用户还没安装任何插件。
const filePath = path.join(kimiHomeDir, INSTALLED_REL);
let text: string;
try {
text = await readFile(filePath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return EMPTY;
throw error;
}
try {
const parsed = JSON.parse(text) as InstalledFile;
if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.plugins)) {
throw new Error('installed.json is not a valid InstalledFile object');
}
return parsed;
} catch (error) {
throw new Error(
`Failed to parse ${filePath}: ${(error as Error).message}`,
{ cause: error },
);
}
}
export async function writeInstalled(
kimiHomeDir: string,
data: InstalledFile,
): Promise<void> {
// 临时阅读注释:先写临时文件再 rename避免进程中断时留下半截 JSON。
const dir = path.join(kimiHomeDir, 'plugins');
await mkdir(dir, { recursive: true });
const final = path.join(dir, 'installed.json');
const tmp = `${final}.tmp`;
await writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
await rename(tmp, final);
}

View file

@ -0,0 +1,127 @@
import type { McpServerConfig } from '../config/schema';
export type PluginDiagnosticSeverity = 'error' | 'warn' | 'info';
export interface PluginDiagnostic {
readonly severity: PluginDiagnosticSeverity;
readonly code: string;
readonly message: string;
}
export interface PluginAuthor {
readonly name?: string;
readonly email?: string;
}
export interface PluginSessionStart {
readonly skill: string;
}
export interface PluginInterface {
readonly displayName?: string;
readonly shortDescription?: string;
readonly longDescription?: string;
readonly developerName?: string;
readonly capabilities?: readonly string[];
readonly websiteURL?: string;
readonly defaultPrompt?: readonly string[] | string;
}
export interface PluginManifest {
readonly name: string;
readonly version?: string;
readonly description?: string;
readonly keywords?: readonly string[];
readonly author?: PluginAuthor;
readonly homepage?: string;
readonly license?: string;
readonly skills?: readonly string[]; // resolved absolute paths
readonly sessionStart?: PluginSessionStart;
readonly mcpServers?: Readonly<Record<string, McpServerConfig>>;
readonly interface?: PluginInterface;
readonly skillInstructions?: string;
}
export interface PluginMcpServerState {
readonly enabled: boolean;
}
export interface PluginCapabilityState {
readonly mcpServers?: Readonly<Record<string, PluginMcpServerState>>;
}
export interface PluginMcpServerInfo {
readonly name: string;
readonly runtimeName: string;
readonly enabled: boolean;
readonly transport: 'stdio' | 'http';
readonly command?: string;
readonly args?: readonly string[];
readonly cwd?: string;
readonly url?: string;
readonly envKeys?: readonly string[];
readonly headerKeys?: readonly string[];
}
export type PluginManifestKind = 'plugin-json' | 'kimi-plugin';
export type PluginSource = 'local-path' | 'zip-url';
export type PluginState = 'ok' | 'error';
export interface PluginRecord {
readonly id: string;
readonly root: string;
readonly source: PluginSource;
readonly enabled: boolean;
readonly state: PluginState;
readonly installedAt: string;
readonly updatedAt?: string;
readonly originalSource?: string;
readonly capabilities?: PluginCapabilityState;
readonly skillInstructions?: string;
readonly manifest?: PluginManifest;
readonly manifestKind?: PluginManifestKind;
readonly manifestPath?: string;
readonly shadowedManifestPath?: string;
readonly diagnostics: readonly PluginDiagnostic[];
}
export interface PluginSummary {
readonly id: string;
readonly displayName: string;
readonly version?: string;
readonly enabled: boolean;
readonly state: PluginState;
readonly skillCount: number;
readonly mcpServerCount: number;
readonly enabledMcpServerCount: number;
readonly hasErrors: boolean;
}
export interface PluginInfo extends PluginSummary {
readonly source: PluginSource;
readonly root: string;
readonly originalSource?: string;
readonly manifestKind?: PluginManifestKind;
readonly manifestPath?: string;
readonly manifest?: PluginManifest;
readonly mcpServers: readonly PluginMcpServerInfo[];
readonly shadowedManifestPath?: string;
readonly diagnostics: readonly PluginDiagnostic[];
}
export interface EnabledPluginSessionStart {
readonly pluginId: string;
readonly skillName: string;
}
export interface ReloadSummary {
readonly added: readonly string[];
readonly removed: readonly string[];
readonly errors: ReadonlyArray<{ readonly id: string; readonly message: string }>;
}
export const PLUGIN_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
export function normalizePluginId(name: string): string {
return name.toLowerCase();
}

View file

@ -9,6 +9,7 @@ import type { SessionMeta } from '#/session';
import type { BackgroundTaskInfo } from '#/tools/builtin';
import type { ContentPart } from '@moonshot-ai/kosong';
import type { PluginInfo, PluginSummary, ReloadSummary } from '#/plugin';
import type { UsageStatus } from './events';
import type { WithAgentId, WithSessionId } from './types';
@ -214,6 +215,32 @@ export interface ReconnectMcpServerPayload {
readonly name: string;
}
export interface InstallPluginPayload {
readonly source: string;
}
export interface SetPluginEnabledPayload {
readonly id: string;
readonly enabled: boolean;
}
export interface SetPluginMcpServerEnabledPayload {
readonly id: string;
readonly server: string;
readonly enabled: boolean;
}
export interface RemovePluginPayload {
readonly id: string;
}
export interface GetPluginInfoPayload {
readonly id: string;
}
export type ReloadPluginsResult = ReloadSummary;
export type { PluginSummary, PluginInfo };
export interface RenameSessionPayload {
readonly title: string;
}
@ -284,4 +311,11 @@ export interface CoreAPI extends SessionAPIWithId {
forkSession: (payload: ForkSessionPayload) => ResumeSessionResult;
listSessions: (payload: ListSessionsPayload) => readonly SessionSummary[];
exportSession: (payload: ExportSessionPayload) => ExportSessionResult;
listPlugins: (payload: EmptyPayload) => readonly PluginSummary[];
installPlugin: (payload: InstallPluginPayload) => PluginSummary;
setPluginEnabled: (payload: SetPluginEnabledPayload) => void;
setPluginMcpServerEnabled: (payload: SetPluginMcpServerEnabledPayload) => void;
removePlugin: (payload: RemovePluginPayload) => void;
reloadPlugins: (payload: EmptyPayload) => ReloadPluginsResult;
getPluginInfo: (payload: GetPluginInfoPayload) => PluginInfo;
}

View file

@ -25,12 +25,18 @@ import type {
GetBackgroundOutputPathPayload,
GetBackgroundOutputPayload,
GetBackgroundPayload,
GetPluginInfoPayload,
InstallPluginPayload,
ListSessionsPayload,
McpServerInfo,
McpStartupMetrics,
PluginInfo,
PluginSummary,
PromptPayload,
ReconnectMcpServerPayload,
ReloadPluginsResult,
RemoveKimiProviderPayload,
RemovePluginPayload,
RenameSessionPayload,
ResumeSessionPayload,
RegisterToolPayload,
@ -39,6 +45,8 @@ import type {
SetModelPayload,
SetModelResult,
SetPermissionPayload,
SetPluginEnabledPayload,
SetPluginMcpServerEnabledPayload,
SetThinkingPayload,
SkillSummary,
SteerPayload,
@ -53,7 +61,9 @@ import type { SDKRPC } from './sdk-api';
import { proxyWithExtraPayload } from './types';
import type { PromisableMethods } from '#/utils/types';
import { resolveSessionMcpConfig } from '../mcp';
import { PluginManager } from '#/plugin';
import { resolveSessionMcpConfig, type SessionMcpConfig } from '../mcp';
import { Session, type SessionMeta, type SessionSkillConfig } from '../session';
import { SessionAPIImpl } from '../session/rpc';
import {
@ -109,6 +119,9 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
private readonly skillDirs: readonly string[];
private readonly providerManager: ProviderManager;
private readonly sessionStore: SessionStore;
readonly plugins: PluginManager;
private pluginsReady: Promise<void>;
private pluginsLoadError: Error | undefined;
constructor(
protected readonly rpcClient: CoreRPCClient,
@ -132,6 +145,15 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
resolveOAuthTokenProvider: this.resolveOAuthTokenProvider,
});
this.sessionStore = new SessionStore(this.homeDir);
this.plugins = new PluginManager({ kimiHomeDir: this.homeDir });
// 临时阅读注释:插件状态启动时异步加载;显式 /plugins RPC 会暴露加载错误,普通建 session 尽量不被坏插件状态拖垮。
// Capture the error rather than swallow it: mutators and explicit /plugins
// reads rethrow so the user sees what's wrong; createSession/resumeSession
// degrade silently (no plugin skills, no sessionStart injections) so the harness still
// starts. Reload clears the error on success.
this.pluginsReady = this.plugins.load().catch((error: unknown) => {
this.pluginsLoadError = error instanceof Error ? error : new Error(String(error));
});
this.sdk = rpcClient(this);
}
@ -144,7 +166,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
const modelName = this.providerManager.resolveSelectedModel(options.model);
const thinkingLevel = this.providerManager.resolveThinkingLevel(options.thinking);
const permissionMode = options.permission ?? config.defaultPermissionMode;
const mcpConfig = await resolveSessionMcpConfig({
const baseMcpConfig = await resolveSessionMcpConfig({
cwd: workDir,
homeDir: this.homeDir,
});
@ -157,6 +179,10 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
metadata: options.metadata,
};
await this.pluginsReady;
const pluginSessionStarts = this.plugins.enabledSessionStarts();
const mcpConfig = this.mergePluginMcpConfig(baseMcpConfig);
// Session ctor attaches its own log sink. If anything in the setup-after-
// ctor block throws, `session.close()` releases the sink (and mcp).
const session = new Session({
@ -173,6 +199,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
skills: this.resolveSessionSkillConfig(config),
mcpConfig,
telemetry: withTelemetryContext(this.telemetry, { sessionId: summary.id }),
pluginSessionStarts,
});
try {
session.metadata = {
@ -231,10 +258,13 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
}
const config = this.reloadProviderManager();
const mcpConfig = await resolveSessionMcpConfig({
const baseMcpConfig = await resolveSessionMcpConfig({
cwd: summary.workDir,
homeDir: this.homeDir,
});
await this.pluginsReady;
const pluginSessionStarts = this.plugins.enabledSessionStarts();
const mcpConfig = this.mergePluginMcpConfig(baseMcpConfig);
const session = new Session({
runtime: await this.resolveRuntime(config),
id: summary.id,
@ -250,6 +280,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
mcpConfig,
telemetry: withTelemetryContext(this.telemetry, { sessionId: summary.id }),
initializeMainAgent: false,
pluginSessionStarts,
});
let warning: string | undefined;
try {
@ -536,6 +567,81 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
return this.sessionApi(sessionId).generateAgentsMd(payload);
}
async installPlugin(payload: InstallPluginPayload): Promise<PluginSummary> {
// 临时阅读注释Core RPC 是 TUI/SDK 进入 PluginManager 的唯一通道。
await this.pluginsReady;
this.assertPluginsLoaded();
const record = await this.plugins.install(payload.source);
return this.plugins.summaries().find((s) => s.id === record.id)!;
}
async listPlugins(_: EmptyPayload): Promise<readonly PluginSummary[]> {
await this.pluginsReady;
this.assertPluginsLoaded();
return this.plugins.summaries();
}
async setPluginEnabled({ id, enabled }: SetPluginEnabledPayload): Promise<void> {
await this.pluginsReady;
this.assertPluginsLoaded();
await this.plugins.setEnabled(id, enabled);
}
async setPluginMcpServerEnabled({
id,
server,
enabled,
}: SetPluginMcpServerEnabledPayload): Promise<void> {
await this.pluginsReady;
this.assertPluginsLoaded();
await this.plugins.setMcpServerEnabled(id, server, enabled);
}
async removePlugin({ id }: RemovePluginPayload): Promise<void> {
await this.pluginsReady;
this.assertPluginsLoaded();
await this.plugins.remove(id);
}
async reloadPlugins(_: EmptyPayload): Promise<ReloadPluginsResult> {
try {
const summary = await this.plugins.reload();
this.pluginsLoadError = undefined;
return summary;
} catch (error) {
this.pluginsLoadError = error instanceof Error ? error : new Error(String(error));
throw new KimiError(
ErrorCodes.PLUGIN_LOAD_FAILED,
`Failed to reload plugins: ${this.pluginsLoadError.message}`,
{ cause: error, details: { kimiHomeDir: this.homeDir } },
);
}
}
async getPluginInfo({ id }: GetPluginInfoPayload): Promise<PluginInfo> {
await this.pluginsReady;
this.assertPluginsLoaded();
const info = this.plugins.info(id);
if (info === undefined) {
throw new KimiError(
ErrorCodes.PLUGIN_NOT_FOUND,
`Plugin "${id}" is not installed`,
{ details: { id } },
);
}
return info;
}
private assertPluginsLoaded(): void {
if (this.pluginsLoadError === undefined) return;
throw new KimiError(
ErrorCodes.PLUGIN_LOAD_FAILED,
`Plugin state failed to load: ${this.pluginsLoadError.message}. ` +
`Fix the file at ${this.homeDir}/plugins/installed.json and run /plugins reload.`,
{ cause: this.pluginsLoadError, details: { kimiHomeDir: this.homeDir } },
);
}
private async resolveRuntime(config: KimiConfig): Promise<RuntimeConfig> {
if (this.runtime !== undefined) return this.runtime;
const runtime = await createRuntimeConfig({
@ -553,10 +659,23 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
userHomeDir: this.userHomeDir,
explicitDirs,
extraDirs: config.extraSkillDirs,
// 临时阅读注释:插件 skills 用 pluginSkillRoots 传入,原因是这些 root 还携带插件级 skillInstructions。
pluginSkillRoots: this.plugins.pluginSkillRoots(),
mergeAllAvailableSkills: config.mergeAllAvailableSkills,
};
}
private mergePluginMcpConfig(base: SessionMcpConfig | undefined): SessionMcpConfig | undefined {
const pluginServers = this.plugins.enabledMcpServers();
if (Object.keys(pluginServers).length === 0) return base;
return {
servers: {
...(base?.servers ?? {}),
...pluginServers,
},
};
}
private sessionApi(sessionId: string): SessionAPIImpl {
const session = this.sessions.get(sessionId);
if (session === undefined) {

View file

@ -18,6 +18,7 @@ import {
type McpServerEntry,
type SessionMcpConfig,
} from '../mcp';
import type { EnabledPluginSessionStart } from '../plugin';
import {
DEFAULT_AGENT_PROFILES,
DEFAULT_INIT_PROMPT,
@ -32,6 +33,7 @@ import {
resolveSkillRoots,
SkillRegistry,
summarizeSkill,
type SkillRoot,
type SkillSummary,
} from '../skill';
import { noopTelemetryClient, type TelemetryClient } from '../telemetry';
@ -52,12 +54,14 @@ export interface SessionConfig {
readonly skills?: SessionSkillConfig;
readonly mcpConfig?: SessionMcpConfig;
readonly telemetry?: TelemetryClient | undefined;
readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[];
}
export interface SessionSkillConfig {
readonly userHomeDir?: string;
readonly explicitDirs?: readonly string[];
readonly extraDirs?: readonly string[];
readonly pluginSkillRoots?: readonly SkillRoot[];
readonly mergeAllAvailableSkills?: boolean;
readonly builtinDir?: string;
}
@ -325,6 +329,7 @@ export class Session {
},
explicitDirs: this.config.skills?.explicitDirs,
extraDirs: this.config.skills?.extraDirs,
pluginSkillRoots: this.config.skills?.pluginSkillRoots,
mergeAllAvailableSkills: this.config.skills?.mergeAllAvailableSkills,
builtinDir: this.config.skills?.builtinDir,
});
@ -420,6 +425,7 @@ export class Session {
permission: this.permissionOptions(parentAgentId, config.permission),
telemetry: this.telemetry,
log: this.log.createChild({ agentId: id }),
pluginSessionStarts: type === 'main' ? this.config.pluginSessionStarts : undefined,
});
}

View file

@ -67,12 +67,23 @@ export class SkillRegistry {
}
renderSkillPrompt(skill: SkillDefinition, rawArgs: string): string {
// 临时阅读注释:所有 Skill 工具加载 skill 的内容最终都会走这里,包括插件贡献的 skill。
const argumentNames = skillArgumentNames(skill.metadata);
return expandSkillParameters(skill.content, rawArgs, {
const content = expandSkillParameters(skill.content, rawArgs, {
skillDir: skill.dir,
sessionId: this.sessionId,
argumentNames,
});
const plugin = skill.plugin;
if (plugin === undefined) return content;
const instructions = plugin.instructions;
if (instructions === undefined || instructions.trim().length === 0) return content;
// 临时阅读注释:插件专属说明必须贴在 skill 正文旁边;这比只在 session 开头提醒更稳定。
return (
`<kimi-plugin-instructions plugin="${escapeAttr(plugin.id)}">\n` +
`${instructions}\n` +
`</kimi-plugin-instructions>\n\n${content}`
);
}
listSkills(): readonly SkillDefinition[] {
@ -148,3 +159,7 @@ function formatModelSkill(skill: SkillDefinition): readonly string[] {
function truncate(value: string, max: number): string {
return value.length > max ? value.slice(0, max) : value;
}
function escapeAttr(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;');
}

View file

@ -24,6 +24,7 @@ export interface ResolveSkillRootsOptions {
readonly builtinDir?: string;
readonly explicitDirs?: readonly string[];
readonly extraDirs?: readonly string[];
readonly pluginSkillRoots?: readonly SkillRoot[];
readonly mergeAllAvailableSkills?: boolean;
readonly realpath?: (p: string) => Promise<string>;
readonly isDir?: (p: string) => Promise<boolean>;
@ -105,6 +106,13 @@ export async function resolveSkillRoots(
);
}
if (options.pluginSkillRoots !== undefined) {
// 临时阅读注释:插件 skills 不能只当普通 extraDirs 处理,因为它们还带 plugin instructions。
for (const root of options.pluginSkillRoots) {
await pushProvidedRoot(roots, root, isDir, realpath);
}
}
if (options.builtinDir !== undefined) {
await pushExistingRoot(roots, options.builtinDir, 'builtin', isDir, realpath);
}
@ -125,7 +133,7 @@ export async function discoverSkills(
async function walkSkillDir(
dirPath: string,
source: SkillSource,
root: SkillRoot,
isTopLevel: boolean,
depth: number,
): Promise<void> {
@ -160,7 +168,7 @@ export async function discoverSkills(
byName,
skillMdPath: path.join(dirPath, entry, 'SKILL.md'),
skillDirName: entry,
source,
root,
warn,
skip,
});
@ -169,6 +177,24 @@ export async function discoverSkills(
// Flat .md skills count only at a root's top level; deeper .md files are
// skill payload (e.g. references/foo.md), not skills.
if (isTopLevel) {
// A SKILL.md placed directly at a plugin skill root (e.g. plugin root fallback)
// is treated as a single skill bundle. This only applies to plugin-derived roots,
// not to user/project skill directories.
if (root.plugin !== undefined) {
const rootSkillMd = path.join(dirPath, 'SKILL.md');
if (await isFile(rootSkillMd)) {
await parseAndRegister({
parse,
byName,
skillMdPath: rootSkillMd,
skillDirName: path.basename(dirPath),
root,
warn,
skip,
});
}
}
for (const entry of entries) {
if (!entry.endsWith('.md')) continue;
if (entry === 'SKILL.md') continue;
@ -186,7 +212,7 @@ export async function discoverSkills(
byName,
skillMdPath,
skillDirName: skillName,
source,
root,
warn,
skip,
});
@ -194,12 +220,12 @@ export async function discoverSkills(
}
for (const entry of subdirs) {
await walkSkillDir(path.join(dirPath, entry), source, false, depth + 1);
await walkSkillDir(path.join(dirPath, entry), root, false, depth + 1);
}
}
for (const root of options.roots) {
await walkSkillDir(root.path, root.source, true, 0);
await walkSkillDir(root.path, root, true, 0);
}
return sortSkills([...byName.values()]);
@ -287,12 +313,33 @@ async function pushExistingRoot(
return true;
}
async function pushProvidedRoot(
out: SkillRoot[],
root: SkillRoot,
isDir: (p: string) => Promise<boolean>,
realpath: (p: string) => Promise<string>,
): Promise<boolean> {
// 临时阅读注释plugin root 跟普通 extraDirs 可能指向同一路径;去重时要保留 plugin 元数据。
if (!(await isDir(root.path))) return false;
const resolved = await realpath(root.path);
const existingIndex = out.findIndex((existing) => existing.path === resolved);
if (existingIndex < 0) {
out.push({ ...root, path: resolved });
return true;
}
const existing = out[existingIndex];
if (existing !== undefined && existing.plugin === undefined && root.plugin !== undefined) {
out[existingIndex] = { ...existing, plugin: root.plugin };
}
return true;
}
async function parseAndRegister(input: {
readonly parse: NonNullable<DiscoverSkillsOptions['parse']>;
readonly byName: Map<string, SkillDefinition>;
readonly skillMdPath: string;
readonly skillDirName: string;
readonly source: SkillSource;
readonly root: SkillRoot;
readonly warn: (message: string, cause?: unknown) => void;
readonly skip: (skill: SkippedSkill) => void;
}): Promise<void> {
@ -300,10 +347,16 @@ async function parseAndRegister(input: {
const skill = await input.parse({
skillMdPath: input.skillMdPath,
skillDirName: input.skillDirName,
source: input.source,
source: input.root.source,
});
const key = normalizeSkillName(skill.name);
if (!input.byName.has(key)) input.byName.set(key, skill);
if (!input.byName.has(key)) {
// 临时阅读注释:这里把 root 上的 plugin 信息贴到每个 skill 上,后面 SkillRegistry 才能渲染插件专属说明。
input.byName.set(key, input.root.plugin === undefined ? skill : {
...skill,
plugin: input.root.plugin,
});
}
} catch (error) {
if (error instanceof UnsupportedSkillTypeError) {
input.skip({

View file

@ -19,6 +19,7 @@ export interface SkillDefinition {
readonly content: string;
readonly metadata: SkillMetadata;
readonly source: SkillSource;
readonly plugin?: SkillPluginContext;
readonly mermaid?: string | undefined;
readonly d2?: string;
}
@ -35,6 +36,12 @@ export interface SkillSummary {
export interface SkillRoot {
readonly path: string;
readonly source: SkillSource;
readonly plugin?: SkillPluginContext;
}
export interface SkillPluginContext {
readonly id: string;
readonly instructions?: string;
}
export interface SkippedSkill {

View file

@ -0,0 +1,189 @@
import { describe, expect, it } from 'vitest';
import type { Agent } from '../../../src/agent';
import type { PromptOrigin } from '../../../src/agent/context';
import { PluginSessionStartInjector } from '../../../src/agent/injection/plugin-session-start';
import type { EnabledPluginSessionStart } from '../../../src/plugin/types';
import type { SkillDefinition } from '../../../src/skill/types';
interface StubSessionStartAgent {
pluginSessionStarts: readonly EnabledPluginSessionStart[];
skills: {
registry: {
getSkill: (name: string) => SkillDefinition | undefined;
renderSkillPrompt: (skill: SkillDefinition, args: string) => string;
};
};
log: {
warn: (message: string, payload?: unknown) => void;
info: (message: string, payload?: unknown) => void;
debug: (message: string, payload?: unknown) => void;
error: (message: string, payload?: unknown) => void;
};
context: {
history: unknown[];
appendSystemReminder: (content: string, origin: PromptOrigin) => void;
};
}
function skill(
name: string,
body: string,
plugin?: SkillDefinition['plugin'],
): SkillDefinition {
return {
name,
description: '',
path: `/fake/${name}/SKILL.md`,
dir: `/fake/${name}`,
content: body,
metadata: {},
source: 'extra',
plugin,
};
}
interface CapturedWarn {
readonly message: string;
readonly payload?: unknown;
}
function sessionStartAgent(input: {
sessionStarts: readonly EnabledPluginSessionStart[];
skills: readonly SkillDefinition[];
history?: unknown[];
}): { agent: Agent; warnings: readonly CapturedWarn[] } {
const byName = new Map(input.skills.map((s) => [s.name.toLowerCase(), s]));
const history: unknown[] = [...(input.history ?? [])];
const warnings: CapturedWarn[] = [];
const agent: StubSessionStartAgent = {
pluginSessionStarts: input.sessionStarts,
skills: {
registry: {
getSkill: (name) => byName.get(name.toLowerCase()),
renderSkillPrompt: (skill) => {
const plugin = skill.plugin;
if (plugin === undefined) return skill.content;
const instructions = plugin.instructions;
if (instructions === undefined) return skill.content;
return `<kimi-plugin-instructions plugin="${plugin.id}">\n${instructions}\n</kimi-plugin-instructions>\n\n${skill.content}`;
},
},
},
log: {
warn: (message, payload) => warnings.push({ message, payload }),
info: () => {},
debug: () => {},
error: () => {},
},
context: {
history,
appendSystemReminder: (content: string, origin: PromptOrigin) => {
history.push({ role: 'user', content: [{ type: 'text', text: content }], origin });
},
},
};
return { agent: agent as unknown as Agent, warnings };
}
function lastReminder(agent: Agent): string {
const history = (agent.context as unknown as { history: Array<{ role: string; content?: ReadonlyArray<{ text?: string }> }> }).history;
const last = history.findLast((message) => message.role === 'user');
return last?.content?.map((part) => part.text ?? '').join('') ?? '';
}
describe('PluginSessionStartInjector', () => {
it('injects one <plugin_session_start> block per declared sessionStart on first call', async () => {
const { agent } = sessionStartAgent({
sessionStarts: [{ pluginId: 'superpowers', skillName: 'using-superpowers' }],
skills: [
skill('using-superpowers', 'body of skill', {
id: 'superpowers',
instructions: 'Use AskUserQuestion and TodoList.',
}),
],
});
const injector = new PluginSessionStartInjector(agent);
await injector.inject();
const text = lastReminder(agent);
expect(text).toContain('<plugin_session_start plugin="superpowers" skill="using-superpowers">');
expect(text).toContain('<kimi-plugin-instructions plugin="superpowers">');
expect(text).toContain('AskUserQuestion');
expect(text).toContain('TodoList');
expect(text).toContain('body of skill');
expect(text).toContain('</plugin_session_start>');
});
it('does not hard-code Superpowers guidance when the skill has no plugin instructions', async () => {
const { agent } = sessionStartAgent({
sessionStarts: [{ pluginId: 'superpowers', skillName: 'using-superpowers' }],
skills: [skill('using-superpowers', 'body')],
});
const injector = new PluginSessionStartInjector(agent);
await injector.inject();
const text = lastReminder(agent);
expect(text).toContain('<plugin_session_start plugin="superpowers" skill="using-superpowers">');
expect(text).toContain('body');
expect(text).not.toContain('<kimi-plugin-instructions plugin="superpowers">');
expect(text).not.toContain('AskUserQuestion');
});
it('does not re-inject on subsequent calls within the same session', async () => {
const { agent } = sessionStartAgent({
sessionStarts: [{ pluginId: 'superpowers', skillName: 'using-superpowers' }],
skills: [skill('using-superpowers', 'body')],
});
const injector = new PluginSessionStartInjector(agent);
await injector.inject();
await injector.inject();
const history = (agent.context as unknown as { history: unknown[] }).history;
expect(history).toHaveLength(1);
});
it('does not re-inject when a replayed history already contains plugin sessionStart', async () => {
const { agent } = sessionStartAgent({
sessionStarts: [{ pluginId: 'superpowers', skillName: 'using-superpowers' }],
skills: [skill('using-superpowers', 'body')],
history: [
{
role: 'user',
content: [{ type: 'text', text: '<system-reminder>old</system-reminder>' }],
origin: { kind: 'injection', variant: 'plugin_session_start' },
},
],
});
const injector = new PluginSessionStartInjector(agent);
await injector.inject();
const history = (agent.context as unknown as { history: unknown[] }).history;
expect(history).toHaveLength(1);
});
it('skips a sessionStart whose skill is not registered and warns', async () => {
const { agent, warnings } = sessionStartAgent({
sessionStarts: [
{ pluginId: 'demo', skillName: 'missing' },
{ pluginId: 'superpowers', skillName: 'using-superpowers' },
],
skills: [skill('using-superpowers', 'body')],
});
const injector = new PluginSessionStartInjector(agent);
await injector.inject();
const text = lastReminder(agent);
expect(text).not.toContain('plugin="demo"');
expect(text).toContain('plugin="superpowers"');
expect(warnings).toContainEqual(
expect.objectContaining({
message: 'plugin sessionStart skill not found',
payload: expect.objectContaining({ pluginId: 'demo', skillName: 'missing' }),
}),
);
});
it('emits nothing when no sessionStart declarations are present', async () => {
const { agent } = sessionStartAgent({ sessionStarts: [], skills: [] });
const injector = new PluginSessionStartInjector(agent);
await injector.inject();
const history = (agent.context as unknown as { history: unknown[] }).history;
expect(history).toEqual([]);
});
});

View file

@ -0,0 +1,195 @@
import { describe, expect, it } from 'vitest';
import { mkdtemp, readFile } from 'node:fs/promises';
import path from 'node:path';
import { tmpdir } from 'node:os';
import yazl from 'yazl';
import { crc32 } from 'node:zlib';
import { downloadZip, extractZip } from '../../src/plugin/archive';
async function createZipBuffer(entries: Array<{ name: string; data: string | Buffer }>): Promise<Buffer> {
return new Promise((resolve, reject) => {
const zipfile = new yazl.ZipFile();
const chunks: Buffer[] = [];
zipfile.outputStream.on('data', (chunk) => chunks.push(chunk));
zipfile.outputStream.on('end', () => {
resolve(Buffer.concat(chunks));
});
zipfile.outputStream.on('error', reject);
for (const entry of entries) {
zipfile.addBuffer(Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data), entry.name);
}
zipfile.end();
});
}
function createMinimalZip(entryName: string, content: string): Buffer {
const nameBuf = Buffer.from(entryName, 'utf8');
const dataBuf = Buffer.from(content, 'utf8');
const crc = crc32(dataBuf);
const localHeader = Buffer.alloc(30);
localHeader.writeUInt32LE(0x04034b50, 0);
localHeader.writeUInt16LE(20, 4);
localHeader.writeUInt16LE(0, 6);
localHeader.writeUInt16LE(0, 8);
localHeader.writeUInt16LE(0, 10);
localHeader.writeUInt16LE(0, 12);
localHeader.writeUInt32LE(crc, 14);
localHeader.writeUInt32LE(dataBuf.length, 18);
localHeader.writeUInt32LE(dataBuf.length, 22);
localHeader.writeUInt16LE(nameBuf.length, 26);
localHeader.writeUInt16LE(0, 28);
const cdHeader = Buffer.alloc(46);
cdHeader.writeUInt32LE(0x02014b50, 0);
cdHeader.writeUInt16LE(20, 4);
cdHeader.writeUInt16LE(20, 6);
cdHeader.writeUInt16LE(0, 8);
cdHeader.writeUInt16LE(0, 10);
cdHeader.writeUInt16LE(0, 12);
cdHeader.writeUInt16LE(0, 14);
cdHeader.writeUInt32LE(crc, 16);
cdHeader.writeUInt32LE(dataBuf.length, 20);
cdHeader.writeUInt32LE(dataBuf.length, 24);
cdHeader.writeUInt16LE(nameBuf.length, 28);
cdHeader.writeUInt16LE(0, 30);
cdHeader.writeUInt16LE(0, 32);
cdHeader.writeUInt16LE(0, 34);
cdHeader.writeUInt16LE(0, 36);
cdHeader.writeUInt32LE(0, 38);
cdHeader.writeUInt32LE(0, 42);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(1, 8);
eocd.writeUInt16LE(1, 10);
eocd.writeUInt32LE(46 + nameBuf.length, 12);
eocd.writeUInt32LE(30 + nameBuf.length + dataBuf.length, 16);
eocd.writeUInt16LE(0, 20);
return Buffer.concat([localHeader, nameBuf, dataBuf, cdHeader, nameBuf, eocd]);
}
async function serveOnce(buffer: Buffer): Promise<string> {
const { createServer } = await import('node:http');
return new Promise((resolve) => {
const server = createServer((_, res) => {
res.writeHead(200, { 'Content-Type': 'application/zip' });
res.end(buffer);
server.close();
});
server.listen(0, '127.0.0.1', () => {
const addr = server.address()!;
resolve(`http://127.0.0.1:${(addr as any).port}`);
});
});
}
describe('downloadZip', () => {
it('downloads a zip from a URL', async () => {
const zipBuffer = await createZipBuffer([{ name: 'test.txt', data: 'hello' }]);
const url = await serveOnce(zipBuffer);
const result = await downloadZip(url);
expect(result).toBeInstanceOf(Buffer);
expect(result.length).toBeGreaterThan(0);
});
it('throws on HTTP error', async () => {
const { createServer } = await import('node:http');
const url = await new Promise<string>((resolve) => {
const server = createServer((_, res) => {
res.writeHead(404);
res.end('Not found');
server.close();
});
server.listen(0, '127.0.0.1', () => {
const addr = server.address()!;
resolve(`http://127.0.0.1:${(addr as any).port}`);
});
});
await expect(downloadZip(url)).rejects.toThrow(/Failed to download zip: HTTP 404/i);
});
});
describe('extractZip', () => {
it('extracts flat files and returns root when no manifest found', async () => {
const destDir = await mkdtemp(path.join(tmpdir(), 'archive-test-'));
const zipBuffer = await createZipBuffer([
{ name: 'readme.txt', data: 'hello' },
{ name: 'data/info.json', data: '{}' },
]);
const root = await extractZip(zipBuffer, destDir);
expect(root).toBe(destDir);
const readme = await readFile(path.join(destDir, 'readme.txt'), 'utf8');
expect(readme).toBe('hello');
});
it('detects plugin root with plugin.json', async () => {
const destDir = await mkdtemp(path.join(tmpdir(), 'archive-test-'));
const zipBuffer = await createZipBuffer([
{ name: 'my-plugin/plugin.json', data: '{"name":"test"}' },
{ name: 'my-plugin/readme.md', data: '# Test' },
]);
const root = await extractZip(zipBuffer, destDir);
expect(root).toBe(path.join(destDir, 'my-plugin'));
const manifest = await readFile(path.join(root, 'plugin.json'), 'utf8');
expect(manifest).toBe('{"name":"test"}');
});
it('detects plugin root with .kimi-plugin/plugin.json', async () => {
const destDir = await mkdtemp(path.join(tmpdir(), 'archive-test-'));
const zipBuffer = await createZipBuffer([
{ name: 'my-plugin/.kimi-plugin/plugin.json', data: '{"name":"test"}' },
{ name: 'my-plugin/skills/demo/SKILL.md', data: '---\nname: demo\n---\nbody' },
]);
const root = await extractZip(zipBuffer, destDir);
expect(root).toBe(path.join(destDir, 'my-plugin'));
const manifest = await readFile(path.join(root, '.kimi-plugin', 'plugin.json'), 'utf8');
expect(manifest).toBe('{"name":"test"}');
});
it('detects nested plugin root depth-first', async () => {
const destDir = await mkdtemp(path.join(tmpdir(), 'archive-test-'));
const zipBuffer = await createZipBuffer([
{ name: 'outer/plugin.json', data: '{"name":"outer"}' },
{ name: 'outer/inner/plugin.json', data: '{"name":"inner"}' },
]);
const root = await extractZip(zipBuffer, destDir);
expect(root).toBe(path.join(destDir, 'outer'));
});
it('rejects entries with path traversal', async () => {
const destDir = await mkdtemp(path.join(tmpdir(), 'archive-test-'));
const zipBuffer = createMinimalZip('../escape.txt', 'bad');
await expect(extractZip(zipBuffer, destDir)).rejects.toThrow(/invalid relative path/i);
});
it('rejects entries with .. in path', async () => {
const destDir = await mkdtemp(path.join(tmpdir(), 'archive-test-'));
const zipBuffer = createMinimalZip('a/../../escape.txt', 'bad');
await expect(extractZip(zipBuffer, destDir)).rejects.toThrow(/invalid relative path/i);
});
it('accepts file names containing dots that are not path components', async () => {
const destDir = await mkdtemp(path.join(tmpdir(), 'archive-test-'));
const zipBuffer = await createZipBuffer([
{ name: 'foo..bar.txt', data: 'ok' },
{ name: 'dir/..hidden.md', data: 'ok' },
]);
await extractZip(zipBuffer, destDir);
expect(await readFile(path.join(destDir, 'foo..bar.txt'), 'utf8')).toBe('ok');
expect(await readFile(path.join(destDir, 'dir', '..hidden.md'), 'utf8')).toBe('ok');
});
});

View file

@ -0,0 +1,34 @@
import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { PluginManager } from '../../src/plugin/manager';
describe('PluginManager → SkillRegistry integration', () => {
it('enabled plugin contributes to pluginSkillRoots()', async () => {
const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-'));
const pluginRoot = await realpath(await mkdtemp(path.join(tmpdir(), 'plugin-')));
await writeFile(
path.join(pluginRoot, 'plugin.json'),
JSON.stringify({ name: 'demo', skills: './skills/' }),
'utf8',
);
await mkdir(path.join(pluginRoot, 'skills', 'demo-skill'), { recursive: true });
await writeFile(
path.join(pluginRoot, 'skills', 'demo-skill', 'SKILL.md'),
'---\nname: demo-skill\ndescription: demo\n---\nbody',
'utf8',
);
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(pluginRoot);
expect(manager.pluginSkillRoots()).toContainEqual({
path: path.join(pluginRoot, 'skills'),
source: 'extra',
plugin: { id: 'demo', instructions: undefined },
});
});
});

View file

@ -0,0 +1,507 @@
import { mkdir, mkdtemp, realpath, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import yazl from 'yazl';
import { PluginManager } from '../../src/plugin/manager';
async function makeKimiHome(): Promise<string> {
return mkdtemp(path.join(tmpdir(), 'kimi-home-'));
}
async function makePlugin(
name: string,
options: {
skills?: boolean;
sessionStartSkill?: string;
mcpServers?: Record<string, unknown>;
} = {},
): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), `plugin-${name}-`));
const manifest: Record<string, unknown> = { name };
if (options.skills === true) {
manifest['skills'] = './skills/';
await mkdir(path.join(root, 'skills'), { recursive: true });
await mkdir(path.join(root, 'skills', 'demo-skill'), { recursive: true });
await writeFile(
path.join(root, 'skills', 'demo-skill', 'SKILL.md'),
'---\nname: demo-skill\ndescription: A demo\n---\nbody',
'utf8',
);
}
if (options.sessionStartSkill !== undefined) {
manifest['sessionStart'] = { skill: options.sessionStartSkill };
}
if (options.mcpServers !== undefined) {
manifest['mcpServers'] = options.mcpServers;
}
await writeFile(
path.join(root, 'plugin.json'),
JSON.stringify(manifest),
'utf8',
);
return realpath(root);
}
describe('PluginManager', () => {
it('install() adds a plugin and load() rehydrates it from disk', async () => {
const home = await makeKimiHome();
const pluginRoot = await makePlugin('demo', { skills: true });
let manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
expect(manager.list()).toEqual([]);
const record = await manager.install(pluginRoot);
expect(record.id).toBe('demo');
expect(record.enabled).toBe(true);
expect(manager.list()).toHaveLength(1);
manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
expect(manager.list()).toHaveLength(1);
expect(manager.get('demo')?.root).toBe(pluginRoot);
});
it('install() accepts a .kimi-plugin manifest', async () => {
const home = await makeKimiHome();
const root = await mkdtemp(path.join(tmpdir(), 'kimi-plugin-'));
await mkdir(path.join(root, '.kimi-plugin'), { recursive: true });
await mkdir(path.join(root, 'skills'), { recursive: true });
await writeFile(
path.join(root, '.kimi-plugin', 'plugin.json'),
JSON.stringify({
name: 'superpowers',
skills: './skills/',
skillInstructions: 'Use Kimi tools.',
}),
'utf8',
);
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
const record = await manager.install(root);
const rootReal = await realpath(root);
expect(record.id).toBe('superpowers');
expect(record.manifestKind).toBe('kimi-plugin');
expect(record.manifest?.skills).toEqual([path.join(rootReal, 'skills')]);
expect(manager.pluginSkillRoots()).toContainEqual({
path: path.join(rootReal, 'skills'),
source: 'extra',
plugin: { id: 'superpowers', instructions: 'Use Kimi tools.' },
});
});
it('install() rejects a relative plugin root', async () => {
const home = await makeKimiHome();
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await expect(manager.install('relative/plugin')).rejects.toThrow(/absolute path/i);
});
it('install() persists the real plugin root when installing through a symlink', async () => {
const home = await makeKimiHome();
const pluginRoot = await makePlugin('demo');
const link = path.join(await mkdtemp(path.join(tmpdir(), 'plugin-link-')), 'demo-link');
await symlink(pluginRoot, link);
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
const record = await manager.install(link);
expect(record.root).toBe(pluginRoot);
const reloaded = new PluginManager({ kimiHomeDir: home });
await reloaded.load();
expect(reloaded.get('demo')?.root).toBe(pluginRoot);
});
it('setEnabled() persists the new state', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo', { skills: true });
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
await manager.setEnabled('demo', false);
expect(manager.get('demo')?.enabled).toBe(false);
const reloaded = new PluginManager({ kimiHomeDir: home });
await reloaded.load();
expect(reloaded.get('demo')?.enabled).toBe(false);
});
it('remove() clears the entry but does not delete the source directory', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo', { skills: true });
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
await manager.remove('demo');
expect(manager.get('demo')).toBeUndefined();
// Source directory survives.
const { stat } = await import('node:fs/promises');
expect((await stat(root)).isDirectory()).toBe(true);
});
it('pluginSkillRoots() returns only enabled plugins skills paths', async () => {
const home = await makeKimiHome();
const a = await makePlugin('a', { skills: true });
const b = await makePlugin('b', { skills: true });
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(a);
await manager.install(b);
await manager.setEnabled('b', false);
expect(manager.pluginSkillRoots()).toContainEqual({
path: path.join(a, 'skills'),
source: 'extra',
plugin: { id: 'a', instructions: undefined },
});
expect(manager.pluginSkillRoots()).not.toContainEqual({
path: path.join(b, 'skills'),
source: 'extra',
plugin: { id: 'b', instructions: undefined },
});
});
it('reload() picks up an in-place manifest edit', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo');
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
await writeFile(
path.join(root, 'plugin.json'),
JSON.stringify({ name: 'demo', version: '2.0.0' }),
'utf8',
);
const summary = await manager.reload();
expect(summary.errors).toEqual([]);
expect(manager.get('demo')?.manifest?.version).toBe('2.0.0');
});
it('install() refuses to add a directory without a manifest', async () => {
const home = await makeKimiHome();
const root = await mkdtemp(path.join(tmpdir(), 'no-manifest-'));
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await expect(manager.install(root)).rejects.toThrow(/manifest/i);
});
it('install() refuses to add the same plugin twice', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo');
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
await expect(manager.install(root)).rejects.toThrow(/already installed/i);
});
it('keeps a plugin in error state instead of losing it on a broken manifest', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo');
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
await writeFile(
path.join(root, 'plugin.json'),
'{ not json',
'utf8',
);
await manager.reload();
const record = manager.get('demo');
expect(record?.state).toBe('error');
expect(record?.diagnostics).toContainEqual(
expect.objectContaining({ code: 'manifest.invalid_json' }),
);
expect(manager.pluginSkillRoots()).toEqual([]);
});
it('enabledSessionStarts() returns only enabled plugin sessionStart declarations', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo', {
skills: true,
sessionStartSkill: 'demo-skill',
});
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
expect(manager.enabledSessionStarts()).toEqual([
{ pluginId: 'demo', skillName: 'demo-skill' },
]);
await manager.setEnabled('demo', false);
expect(manager.enabledSessionStarts()).toEqual([]);
});
it('maps manifest skillInstructions to record skillInstructions', async () => {
const home = await makeKimiHome();
const root = await mkdtemp(path.join(tmpdir(), 'plugin-instructions-'));
await writeFile(
path.join(root, 'plugin.json'),
JSON.stringify({
name: 'demo',
skillInstructions: 'Always be helpful.',
}),
'utf8',
);
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
const record = await manager.install(root);
expect(record.skillInstructions).toBe('Always be helpful.');
});
it('setMcpServerEnabled() persists explicit MCP server state', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo', {
mcpServers: {
finance: { command: 'finance-mcp' },
docs: { url: 'https://example.com/mcp' },
},
});
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
expect(manager.info('demo')?.mcpServers).toContainEqual(
expect.objectContaining({
name: 'finance',
runtimeName: 'plugin-demo-finance',
enabled: false,
command: 'finance-mcp',
}),
);
expect(manager.summaries()[0]).toEqual(
expect.objectContaining({
mcpServerCount: 2,
enabledMcpServerCount: 0,
}),
);
await manager.setMcpServerEnabled('demo', 'finance', true);
expect(manager.enabledMcpServers()).toEqual({
'plugin-demo-finance': expect.objectContaining({ command: 'finance-mcp' }),
});
expect(manager.summaries()[0]).toEqual(
expect.objectContaining({
mcpServerCount: 2,
enabledMcpServerCount: 1,
}),
);
const reloaded = new PluginManager({ kimiHomeDir: home });
await reloaded.load();
expect(reloaded.info('demo')?.mcpServers).toContainEqual(
expect.objectContaining({ name: 'finance', enabled: true }),
);
});
it('enabledMcpServers() excludes disabled plugins', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo', {
mcpServers: { finance: { command: 'finance-mcp' } },
});
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
await manager.setMcpServerEnabled('demo', 'finance', true);
await manager.setEnabled('demo', false);
expect(manager.enabledMcpServers()).toEqual({});
});
it('setMcpServerEnabled() rejects unknown MCP servers', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo');
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
await expect(manager.setMcpServerEnabled('demo', 'missing', true)).rejects.toThrow(
/does not declare MCP server/i,
);
});
it('install() sets originalSource and updatedAt', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo');
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
const before = Date.now();
const record = await manager.install(root);
const after = Date.now();
expect(record.originalSource).toBe(root);
expect(record.updatedAt).toBeDefined();
const updatedAt = new Date(record.updatedAt!).getTime();
expect(updatedAt).toBeGreaterThanOrEqual(before);
expect(updatedAt).toBeLessThanOrEqual(after);
expect(record.installedAt).toBe(record.updatedAt);
});
it('persist() and load() round-trip originalSource and updatedAt', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo');
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
const reloaded = new PluginManager({ kimiHomeDir: home });
await reloaded.load();
const record = reloaded.get('demo');
expect(record?.originalSource).toBe(root);
expect(record?.updatedAt).toBeDefined();
});
it('setEnabled() updates updatedAt', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo');
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
const record = await manager.install(root);
const firstUpdatedAt = record.updatedAt;
// Give enough time for the timestamp to change.
await new Promise((r) => setTimeout(r, 10));
await manager.setEnabled('demo', false);
const after = manager.get('demo');
expect(after?.updatedAt).toBeDefined();
expect(after?.updatedAt).not.toBe(firstUpdatedAt);
const reloaded = new PluginManager({ kimiHomeDir: home });
await reloaded.load();
expect(reloaded.get('demo')?.updatedAt).toBe(after?.updatedAt);
});
it('info() includes originalSource', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo');
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
const info = manager.info('demo');
expect(info?.originalSource).toBe(root);
});
it('install() supports zip URL', async () => {
const home = await makeKimiHome();
const zipBuffer = await createZipBuffer([
{
name: 'plugin/plugin.json',
data: JSON.stringify({ name: 'zip-demo', skills: './skills/' }),
},
{
name: 'plugin/skills/demo-skill/SKILL.md',
data: '---\nname: demo-skill\ndescription: A demo\n---\nbody',
},
]);
const url = await serveOnce(zipBuffer);
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
const record = await manager.install(url);
const managedRoot = await realpath(path.join(home, 'plugins', 'managed', 'zip-demo'));
expect(record.id).toBe('zip-demo');
expect(record.source).toBe('zip-url');
expect(record.originalSource).toBe(url);
expect(record.root).toBe(managedRoot);
expect(record.manifest?.skills).toEqual([path.join(managedRoot, 'skills')]);
const reloaded = new PluginManager({ kimiHomeDir: home });
await reloaded.load();
expect(reloaded.get('zip-demo')?.source).toBe('zip-url');
expect(reloaded.get('zip-demo')?.root).toBe(managedRoot);
});
it('install() from zip-url overwrites existing zip-url plugin', async () => {
const home = await makeKimiHome();
const zipBuffer1 = await createZipBuffer([
{ name: 'plugin/plugin.json', data: JSON.stringify({ name: 'zip-demo', version: '1.0.0' }) },
]);
const url1 = await serveOnce(zipBuffer1);
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(url1);
const zipBuffer2 = await createZipBuffer([
{ name: 'plugin/plugin.json', data: JSON.stringify({ name: 'zip-demo', version: '2.0.0' }) },
]);
const url2 = await serveOnce(zipBuffer2);
const record = await manager.install(url2);
expect(record.manifest?.version).toBe('2.0.0');
expect(manager.list()).toHaveLength(1);
expect(record.originalSource).toBe(url2);
});
it('install() from zip-url refuses to overwrite local-path plugin', async () => {
const home = await makeKimiHome();
const root = await makePlugin('zip-demo');
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
const zipBuffer = await createZipBuffer([
{ name: 'plugin/plugin.json', data: JSON.stringify({ name: 'zip-demo' }) },
]);
const url = await serveOnce(zipBuffer);
await expect(manager.install(url)).rejects.toThrow(/already installed from a local directory/i);
});
it('install() rejects zip URL without manifest', async () => {
const home = await makeKimiHome();
const zipBuffer = await createZipBuffer([
{ name: 'readme.txt', data: 'no manifest here' },
]);
const url = await serveOnce(zipBuffer);
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await expect(manager.install(url)).rejects.toThrow(/manifest/i);
});
});
async function createZipBuffer(entries: Array<{ name: string; data: string | Buffer }>): Promise<Buffer> {
return new Promise((resolve, reject) => {
const zipfile = new yazl.ZipFile();
const chunks: Buffer[] = [];
zipfile.outputStream.on('data', (chunk) => chunks.push(chunk));
zipfile.outputStream.on('end', () => {
resolve(Buffer.concat(chunks));
});
zipfile.outputStream.on('error', reject);
for (const entry of entries) {
zipfile.addBuffer(Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data), entry.name);
}
zipfile.end();
});
}
async function serveOnce(buffer: Buffer): Promise<string> {
const { createServer } = await import('node:http');
return new Promise((resolve) => {
const server = createServer((_, res) => {
res.writeHead(200, { 'Content-Type': 'application/zip' });
res.end(buffer);
server.close();
});
server.listen(0, '127.0.0.1', () => {
const addr = server.address()!;
resolve(`http://127.0.0.1:${(addr as any).port}`);
});
});
}

View file

@ -0,0 +1,343 @@
import { mkdtemp, mkdir, writeFile, symlink, realpath } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { parseManifest } from '../../src/plugin/manifest';
async function makePlugin(
files: Record<string, string>,
options: { dirs?: readonly string[] } = {},
): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), 'kimi-plugin-test-'));
for (const dir of options.dirs ?? []) {
await mkdir(path.join(root, dir), { recursive: true });
}
for (const [rel, body] of Object.entries(files)) {
await mkdir(path.dirname(path.join(root, rel)), { recursive: true });
await writeFile(path.join(root, rel), body, 'utf8');
}
return realpath(root);
}
describe('parseManifest', () => {
it('reads a minimal plugin.json', async () => {
const root = await makePlugin({
'plugin.json': JSON.stringify({ name: 'demo', version: '1.0.0' }),
});
const result = await parseManifest(root);
expect(result.manifest?.name).toBe('demo');
expect(result.manifest?.version).toBe('1.0.0');
expect(result.manifestKind).toBe('plugin-json');
expect(result.diagnostics).toEqual([]);
});
it('prefers plugin.json when .kimi-plugin/plugin.json also exists', async () => {
const root = await makePlugin({
'plugin.json': JSON.stringify({ name: 'plugin-json-version', version: '1.0.0' }),
'.kimi-plugin/plugin.json': JSON.stringify({ name: 'kimi-plugin-version' }),
});
const result = await parseManifest(root);
expect(result.manifestKind).toBe('plugin-json');
expect(result.manifest?.name).toBe('plugin-json-version');
expect(result.shadowedManifestPath).toBe(path.join(root, '.kimi-plugin/plugin.json'));
});
it('reads .kimi-plugin/plugin.json when plugin.json is absent', async () => {
const root = await makePlugin(
{
'.kimi-plugin/plugin.json': JSON.stringify({
name: 'demo',
version: '1.0.0',
keywords: ['workflow'],
skills: './skills/',
interface: { displayName: 'Demo' },
sessionStart: { skill: 'using-demo' },
skillInstructions: 'Use Kimi tools.',
}),
},
{ dirs: ['skills'] },
);
const result = await parseManifest(root);
expect(result.manifestKind).toBe('kimi-plugin');
expect(result.manifestPath).toBe(path.join(root, '.kimi-plugin/plugin.json'));
expect(result.manifest?.name).toBe('demo');
expect(result.manifest?.version).toBe('1.0.0');
expect(result.manifest?.keywords).toEqual(['workflow']);
expect(result.manifest?.skills).toEqual([path.join(root, 'skills')]);
expect(result.manifest?.interface?.displayName).toBe('Demo');
expect(result.manifest?.sessionStart).toEqual({ skill: 'using-demo' });
expect(result.manifest?.skillInstructions).toBe('Use Kimi tools.');
});
it('does NOT fall back to .kimi-plugin/plugin.json when plugin.json is invalid JSON', async () => {
const root = await makePlugin({
'plugin.json': '{ not json',
'.kimi-plugin/plugin.json': JSON.stringify({ name: 'kimi-plugin-version' }),
});
const result = await parseManifest(root);
expect(result.manifest).toBeUndefined();
expect(result.manifestKind).toBe('plugin-json');
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: 'manifest.invalid_json' }),
);
expect(result.shadowedManifestPath).toBe(path.join(root, '.kimi-plugin/plugin.json'));
});
it('rejects names that violate the regex', async () => {
const root = await makePlugin({
'plugin.json': JSON.stringify({ name: 'Bad Name!' }),
});
const result = await parseManifest(root);
expect(result.manifest).toBeUndefined();
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: 'manifest.invalid_name' }),
);
});
it('returns manifest.missing when neither file exists', async () => {
const root = await makePlugin({});
const result = await parseManifest(root);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: 'manifest.missing' }),
);
});
it('resolves a single skills path', async () => {
const root = await makePlugin(
{ 'plugin.json': JSON.stringify({ name: 'demo', skills: './skills/' }) },
{ dirs: ['skills'] },
);
const result = await parseManifest(root);
expect(result.manifest?.skills).toEqual([path.join(root, 'skills')]);
});
it('resolves an array of skills paths', async () => {
const root = await makePlugin(
{
'plugin.json': JSON.stringify({
name: 'demo',
skills: ['./a/', './b/'],
}),
},
{ dirs: ['a', 'b'] },
);
const result = await parseManifest(root);
expect(result.manifest?.skills).toEqual([path.join(root, 'a'), path.join(root, 'b')]);
});
it('rejects a skills path not prefixed with ./', async () => {
const root = await makePlugin({
'plugin.json': JSON.stringify({ name: 'demo', skills: 'skills/' }),
});
const result = await parseManifest(root);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: 'manifest.skills.path_required_dot_slash' }),
);
expect(result.manifest?.skills).toEqual([]);
});
it('rejects a skills path that escapes plugin_root', async () => {
const root = await makePlugin({
'plugin.json': JSON.stringify({ name: 'demo', skills: './../escape' }),
});
const result = await parseManifest(root);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: 'manifest.skills.path_escape' }),
);
});
it('rejects a skills path that escapes via a symlink', async () => {
const root = await makePlugin({
'plugin.json': JSON.stringify({ name: 'demo', skills: './sym' }),
});
const outside = await mkdtemp(path.join(tmpdir(), 'kimi-plugin-outside-'));
await symlink(outside, path.join(root, 'sym'));
const result = await parseManifest(root);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: 'manifest.skills.path_escape' }),
);
});
it('warns when skills resolves to a non-directory', async () => {
const root = await makePlugin({
'plugin.json': JSON.stringify({ name: 'demo', skills: './notes.md' }),
'notes.md': 'hi',
});
const result = await parseManifest(root);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({
code: 'manifest.skills.not_a_directory',
severity: 'warn',
}),
);
});
it('falls back to root SKILL.md when skills field is absent', async () => {
const root = await makePlugin({
'plugin.json': JSON.stringify({ name: 'demo' }),
'SKILL.md': '---\nname: root-skill\n---\nbody',
});
const result = await parseManifest(root);
expect(result.manifest?.skills).toEqual([root]);
});
it('does not fall back to root SKILL.md when skills field is present', async () => {
const root = await makePlugin(
{
'plugin.json': JSON.stringify({ name: 'demo', skills: './skills/' }),
'SKILL.md': '---\nname: root-skill\n---\nbody',
},
{ dirs: ['skills'] },
);
const result = await parseManifest(root);
expect(result.manifest?.skills).toEqual([path.join(root, 'skills')]);
});
it('reports unsupported runtime fields in plugin.json without parsing them as capabilities', async () => {
const root = await makePlugin({
'plugin.json': JSON.stringify({
name: 'demo',
tools: [
{ name: 'tool-a', description: 'Desc A', command: ['echo', 'a'] },
{ name: 'tool-b', description: 'Desc B', command: ['echo', 'b'] },
],
configFile: 'cfg.json',
config_file: 'legacy-cfg.json',
inject: { foo: 'bar' },
bootstrap: { skill: 'using-demo' },
hooks: { sessionStart: { skill: 'using-demo' } },
apps: './apps',
}),
});
const result = await parseManifest(root);
expect(result.manifest).toEqual(
expect.objectContaining({
name: 'demo',
}),
);
for (const field of [
'tools',
'configFile',
'config_file',
'inject',
'bootstrap',
'hooks',
'apps',
]) {
expect(result.diagnostics).toContainEqual(
expect.objectContaining({
code: `manifest.unsupported_field.${field}`,
severity: 'info',
}),
);
}
});
it('parses skillInstructions', async () => {
const root = await makePlugin({
'plugin.json': JSON.stringify({ name: 'demo', skillInstructions: 'Do this.' }),
});
const result = await parseManifest(root);
expect(result.manifest?.skillInstructions).toBe('Do this.');
});
it('parses keywords metadata', async () => {
const root = await makePlugin({
'plugin.json': JSON.stringify({ name: 'demo', keywords: ['finance', 'workflow'] }),
});
const result = await parseManifest(root);
expect(result.manifest?.keywords).toEqual(['finance', 'workflow']);
});
it('reads sessionStart in plugin.json', async () => {
const root = await makePlugin({
'plugin.json': JSON.stringify({
name: 'demo',
sessionStart: { skill: 'using-demo' },
}),
});
const result = await parseManifest(root);
expect(result.manifest?.sessionStart).toEqual({ skill: 'using-demo' });
});
it('does not read .codex-plugin/plugin.json as a manifest', async () => {
const root = await makePlugin({
'.codex-plugin/plugin.json': JSON.stringify({ name: 'demo', skills: './skills/' }),
});
const result = await parseManifest(root);
expect(result.manifest).toBeUndefined();
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: 'manifest.missing', severity: 'error' }),
);
});
it('parses plugin mcpServers without enabling them', async () => {
const root = await makePlugin(
{
'plugin.json': JSON.stringify({
name: 'demo',
mcpServers: {
finance: {
command: './bin/finance-mcp',
args: ['--stdio'],
cwd: './bin',
env: { FINANCE_API_KEY: 'x' },
},
docs: {
url: 'https://example.com/mcp',
headers: { 'X-Test': '1' },
},
},
}),
},
{ dirs: ['bin'] },
);
await writeFile(path.join(root, 'bin', 'finance-mcp'), '#!/bin/sh\n', 'utf8');
const result = await parseManifest(root);
expect(result.manifest?.mcpServers?.['finance']).toEqual({
transport: 'stdio',
command: path.join(root, 'bin', 'finance-mcp'),
args: ['--stdio'],
cwd: path.join(root, 'bin'),
env: { FINANCE_API_KEY: 'x' },
});
expect(result.manifest?.mcpServers?.['docs']).toEqual({
transport: 'http',
url: 'https://example.com/mcp',
headers: { 'X-Test': '1' },
});
});
it('warns and skips invalid plugin mcpServers entries', async () => {
const root = await makePlugin({
'plugin.json': JSON.stringify({
name: 'demo',
mcpServers: {
bad: { command: '/tmp/unsafe' },
},
}),
});
const result = await parseManifest(root);
expect(result.manifest?.mcpServers).toBeUndefined();
expect(result.diagnostics).toContainEqual(
expect.objectContaining({
code: 'manifest.mcpServers.bad.command.path_required_dot_slash',
severity: 'warn',
}),
);
});
it('captures interface.displayName and shortDescription', async () => {
const root = await makePlugin({
'plugin.json': JSON.stringify({
name: 'demo',
interface: { displayName: 'Demo', shortDescription: 'A demo.' },
}),
});
const result = await parseManifest(root);
expect(result.manifest?.interface?.displayName).toBe('Demo');
expect(result.manifest?.interface?.shortDescription).toBe('A demo.');
});
});

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { resolveInstallSource } from '../../src/plugin/source';
describe('resolveInstallSource', () => {
it('recognizes https:// as zip-url', () => {
const result = resolveInstallSource('https://example.com/plugin.zip');
expect(result).toEqual({ kind: 'zip-url', path: 'https://example.com/plugin.zip' });
});
it('recognizes http:// as zip-url', () => {
const result = resolveInstallSource('http://example.com/plugin.zip');
expect(result).toEqual({ kind: 'zip-url', path: 'http://example.com/plugin.zip' });
});
it('recognizes absolute path as local-path', () => {
const result = resolveInstallSource('/home/user/plugin');
expect(result).toEqual({ kind: 'local-path', path: '/home/user/plugin' });
});
it('trims whitespace from local paths', () => {
const result = resolveInstallSource(' /home/user/plugin ');
expect(result).toEqual({ kind: 'local-path', path: '/home/user/plugin' });
});
it('throws for relative local paths', () => {
expect(() => resolveInstallSource('relative/path')).toThrow(/absolute path/i);
});
it('throws for empty string', () => {
expect(() => resolveInstallSource('')).toThrow(/absolute path/i);
});
});

View file

@ -0,0 +1,64 @@
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import {
type InstalledFile,
readInstalled,
writeInstalled,
} from '../../src/plugin/store';
async function makeKimiHome(): Promise<string> {
return mkdtemp(path.join(tmpdir(), 'kimi-home-'));
}
describe('plugin store', () => {
it('returns an empty list when the file does not exist', async () => {
const home = await makeKimiHome();
const result = await readInstalled(home);
expect(result.plugins).toEqual([]);
expect(result.version).toBe(1);
});
it('writes and reads installed.json round-trip', async () => {
const home = await makeKimiHome();
const data: InstalledFile = {
version: 1,
plugins: [
{
id: 'demo',
root: '/tmp/demo',
source: 'local-path',
enabled: true,
installedAt: '2026-05-25T09:00:00Z',
updatedAt: '2026-05-25T10:00:00Z',
originalSource: '/tmp/demo',
capabilities: {
mcpServers: {
finance: { enabled: true },
},
},
},
],
};
await writeInstalled(home, data);
const result = await readInstalled(home);
expect(result).toEqual(data);
});
it('writes atomically (no .tmp left after success)', async () => {
const home = await makeKimiHome();
await writeInstalled(home, { version: 1, plugins: [] });
const after = await readFile(path.join(home, 'plugins', 'installed.json'), 'utf8');
expect(after).toContain('"version": 1');
});
it('throws on a corrupt installed.json instead of silently dropping it', async () => {
const home = await makeKimiHome();
await writeInstalled(home, { version: 1, plugins: [] });
await writeFile(path.join(home, 'plugins', 'installed.json'), '{ not json', 'utf8');
await expect(readInstalled(home)).rejects.toThrow(/parse/i);
});
});

View file

@ -0,0 +1,131 @@
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { KimiCore } from '../../src/rpc/core-impl';
describe('KimiCore plugin RPCs', () => {
it('install → list → setEnabled → remove round trip', async () => {
const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-'));
const pluginRoot = await mkdtemp(path.join(tmpdir(), 'plugin-'));
await writeFile(
path.join(pluginRoot, 'plugin.json'),
JSON.stringify({ name: 'demo', version: '1.0.0' }),
'utf8',
);
const core = new KimiCore(async () => ({}) as never, { homeDir: home });
await new Promise((r) => setImmediate(r));
const installed = await core.installPlugin({ source: pluginRoot });
expect(installed.id).toBe('demo');
expect(installed.version).toBe('1.0.0');
const list = await core.listPlugins({});
expect(list).toHaveLength(1);
await core.setPluginEnabled({ id: 'demo', enabled: false });
const after = await core.listPlugins({});
expect(after[0]?.enabled).toBe(false);
await core.removePlugin({ id: 'demo' });
await expect(core.listPlugins({})).resolves.toEqual([]);
});
it('setPluginMcpServerEnabled toggles plugin MCP state', async () => {
const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-'));
const pluginRoot = await mkdtemp(path.join(tmpdir(), 'plugin-'));
await writeFile(
path.join(pluginRoot, 'plugin.json'),
JSON.stringify({
name: 'demo',
mcpServers: {
finance: { command: 'finance-mcp' },
},
}),
'utf8',
);
const core = new KimiCore(async () => ({}) as never, { homeDir: home });
await new Promise((r) => setImmediate(r));
await core.installPlugin({ source: pluginRoot });
await core.setPluginMcpServerEnabled({ id: 'demo', server: 'finance', enabled: true });
await expect(core.getPluginInfo({ id: 'demo' })).resolves.toEqual(
expect.objectContaining({
mcpServers: expect.arrayContaining([
expect.objectContaining({ name: 'finance', enabled: true }),
]),
}),
);
});
it('throws PLUGIN_LOAD_FAILED on every RPC when installed.json is corrupt', async () => {
const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-'));
await mkdir(path.join(home, 'plugins'), { recursive: true });
await writeFile(path.join(home, 'plugins', 'installed.json'), '{ not json', 'utf8');
const core = new KimiCore(async () => ({}) as never, { homeDir: home });
// Driving an awaiting RPC first ensures the load promise has settled
// and captured pluginsLoadError before the read RPCs run.
await expect(core.installPlugin({ source: '/tmp/nonexistent' })).rejects.toThrow(/load/i);
await expect(core.listPlugins({})).rejects.toThrow(/load/i);
await expect(core.getPluginInfo({ id: 'demo' })).rejects.toThrow(/load/i);
await expect(core.setPluginEnabled({ id: 'demo', enabled: false })).rejects.toThrow(/load/i);
await expect(
core.setPluginMcpServerEnabled({ id: 'demo', server: 'finance', enabled: true }),
).rejects.toThrow(/load/i);
await expect(core.removePlugin({ id: 'demo' })).rejects.toThrow(/load/i);
// installed.json must NOT have been overwritten by the failed install.
const { readFile } = await import('node:fs/promises');
const onDisk = await readFile(path.join(home, 'plugins', 'installed.json'), 'utf8');
expect(onDisk).toBe('{ not json');
// Fixing the file and calling reload clears the error state.
await writeFile(
path.join(home, 'plugins', 'installed.json'),
JSON.stringify({ version: 1, plugins: [] }),
'utf8',
);
await core.reloadPlugins({});
await expect(core.listPlugins({})).resolves.toEqual([]);
});
it('listPlugins waits for initial plugin load', async () => {
const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-'));
const pluginRoot = await mkdtemp(path.join(tmpdir(), 'plugin-'));
await writeFile(
path.join(pluginRoot, 'plugin.json'),
JSON.stringify({ name: 'demo' }),
'utf8',
);
await mkdir(path.join(home, 'plugins'), { recursive: true });
await writeFile(
path.join(home, 'plugins', 'installed.json'),
JSON.stringify({
version: 1,
plugins: [
{
id: 'demo',
root: pluginRoot,
source: 'local-path',
enabled: true,
installedAt: '2026-05-25T09:00:00Z',
},
],
}),
'utf8',
);
const core = new KimiCore(async () => ({}) as never, { homeDir: home });
await expect(core.listPlugins({})).resolves.toContainEqual(
expect.objectContaining({ id: 'demo' }),
);
});
});

View file

@ -31,6 +31,29 @@ describe('skill parser', () => {
expect(skills[0]?.description).toBe('Something');
});
it('preserves plugin metadata from the skill root', async () => {
const root = await makeSkillsRoot();
await writeFlat(root, 'brainstorming.md', ['---', 'description: Brainstorm', '---', 'Body']);
const skills = await discoverSkills({
roots: [
{
path: root,
source: 'extra',
plugin: {
id: 'superpowers',
instructions: 'Use AskUserQuestion.',
},
},
],
});
expect(skills[0]?.plugin).toEqual({
id: 'superpowers',
instructions: 'Use AskUserQuestion.',
});
});
it('falls back to the first non-empty body line as description when frontmatter is absent', async () => {
const root = await makeSkillsRoot();
await writeFlat(root, 'plain.md', ['', '', 'This is the headline description.', '', 'More body text here.']);
@ -225,6 +248,25 @@ describe('SkillRegistry.renderSkillPrompt', () => {
expect(rendered).toBe('Zero: first\nOne: second');
});
it('prepends plugin instructions when a skill came from a plugin root', () => {
const rendered = new SkillRegistry().renderSkillPrompt(
testSkill({
content: 'Brainstorm body.',
plugin: {
id: 'superpowers',
instructions: 'Use AskUserQuestion for clarifying questions.',
},
}),
'',
);
expect(rendered).toBe(
'<kimi-plugin-instructions plugin="superpowers">\n' +
'Use AskUserQuestion for clarifying questions.\n' +
'</kimi-plugin-instructions>\n\nBrainstorm body.',
);
});
});
async function makeSkillsRoot(): Promise<string> {
@ -257,6 +299,7 @@ async function writeFlatOrSubdirSkill(
function testSkill(input: {
readonly content: string;
readonly metadata?: SkillDefinition['metadata'];
readonly plugin?: SkillDefinition['plugin'];
}): SkillDefinition {
return {
name: 'review',
@ -266,5 +309,6 @@ function testSkill(input: {
content: input.content,
metadata: input.metadata ?? {},
source: 'user',
plugin: input.plugin,
};
}

View file

@ -665,6 +665,35 @@ describe('resolveSkillRoots extra dirs', () => {
expect(matches).toHaveLength(1);
});
it('preserves plugin metadata when a plugin skill root duplicates an extra dir', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const real = path.join(repoDir, 'real');
await mkdir(real, { recursive: true });
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
extraDirs: [real],
pluginSkillRoots: [
{
path: real,
source: 'extra',
plugin: {
id: 'superpowers',
instructions: 'Use AskUserQuestion.',
},
},
],
});
const realResolved = await realpath(real);
const matches = roots.filter((r) => r.path === realResolved);
expect(matches).toHaveLength(1);
expect(matches[0]?.plugin).toEqual({
id: 'superpowers',
instructions: 'Use AskUserQuestion.',
});
});
it('stamps skills discovered via extra dirs with source=extra', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const extra = path.join(repoDir, 'my-extra');

View file

@ -143,6 +143,33 @@ describe('SkillTool execution', () => {
);
});
it('keeps plugin instructions adjacent to model-invoked skill content', async () => {
const methods = skillToolMethods();
const tool = skillTool(
registry([
{
...skill('brainstorming', {}, 'brainstorm body'),
source: 'extra',
plugin: {
id: 'superpowers',
instructions: 'Use AskUserQuestion for clarifying questions.',
},
},
]),
methods,
);
await execute(tool, { skill: 'brainstorming' });
expect(methods.recordSystemReminder.mock.calls[0]?.[0]).toContain(
'<kimi-skill-loaded name="brainstorming" args="">\n' +
'<kimi-plugin-instructions plugin="superpowers">\n' +
'Use AskUserQuestion for clarifying questions.\n' +
'</kimi-plugin-instructions>\n\nbrainstorm body\n' +
'</kimi-skill-loaded>',
);
});
it('expands skill body placeholders for model-invoked inline skills', async () => {
const methods = skillToolMethods();
const tool = skillTool(

View file

@ -34,6 +34,9 @@ import type {
McpServerInfo,
McpStartupMetrics,
PermissionMode,
PluginInfo,
PluginSummary,
ReloadSummary,
CompactOptions,
SessionPlan,
SessionStatus,
@ -432,6 +435,45 @@ export class SDKRpcClient {
return rpc.reconnectMcpServer({ sessionId: input.sessionId, name: input.name });
}
async listPlugins(): Promise<readonly PluginSummary[]> {
const rpc = await this.getRpc();
return rpc.listPlugins({});
}
async installPlugin(source: string): Promise<PluginSummary> {
const rpc = await this.getRpc();
return rpc.installPlugin({ source });
}
async setPluginEnabled(id: string, enabled: boolean): Promise<void> {
const rpc = await this.getRpc();
return rpc.setPluginEnabled({ id, enabled });
}
async setPluginMcpServerEnabled(
id: string,
server: string,
enabled: boolean,
): Promise<void> {
const rpc = await this.getRpc();
return rpc.setPluginMcpServerEnabled({ id, server, enabled });
}
async removePlugin(id: string): Promise<void> {
const rpc = await this.getRpc();
return rpc.removePlugin({ id });
}
async reloadPlugins(): Promise<ReloadSummary> {
const rpc = await this.getRpc();
return rpc.reloadPlugins({});
}
async getPluginInfo(id: string): Promise<PluginInfo> {
const rpc = await this.getRpc();
return rpc.getPluginInfo({ id });
}
async activateSkill(input: ActivateSkillRpcInput): Promise<void> {
const rpc = await this.getRpc();
return rpc.activateSkill({

View file

@ -7,7 +7,10 @@ import type {
McpServerInfo,
McpStartupMetrics,
PermissionMode,
PluginInfo,
PluginSummary,
PromptInput,
ReloadSummary,
ResumedSessionState,
SessionPlan,
SessionStatus,
@ -280,6 +283,45 @@ export class Session {
await this.rpc.reconnectMcpServer({ sessionId: this.id, name });
}
async listPlugins(): Promise<readonly PluginSummary[]> {
this.ensureOpen();
return this.rpc.listPlugins();
}
async installPlugin(source: string): Promise<PluginSummary> {
this.ensureOpen();
return this.rpc.installPlugin(source);
}
async setPluginEnabled(id: string, enabled: boolean): Promise<void> {
this.ensureOpen();
await this.rpc.setPluginEnabled(id, enabled);
}
async setPluginMcpServerEnabled(
id: string,
server: string,
enabled: boolean,
): Promise<void> {
this.ensureOpen();
await this.rpc.setPluginMcpServerEnabled(id, server, enabled);
}
async removePlugin(id: string): Promise<void> {
this.ensureOpen();
await this.rpc.removePlugin(id);
}
async reloadPlugins(): Promise<ReloadSummary> {
this.ensureOpen();
return this.rpc.reloadPlugins();
}
async getPluginInfo(id: string): Promise<PluginInfo> {
this.ensureOpen();
return this.rpc.getPluginInfo(id);
}
async activateSkill(name: string, args?: string | undefined): Promise<void> {
this.ensureOpen();
const skillName = normalizeRequiredString(

View file

@ -31,9 +31,12 @@ export type {
ModelAlias,
MoonshotServiceConfig,
OAuthRef,
PluginInfo,
PluginSummary,
PromptOrigin,
ProviderConfig,
ProviderType,
ReloadSummary,
ResumedAgentState,
ServicesConfig,
ShellEnvironment,

View file

@ -0,0 +1,241 @@
# Kimi Code 插件协议当前实现
> 已被 `reports/2026-05-27-superpowers-kimi-plugin-handoff.md` 接手文档更新。
> 最新方向是支持 `plugin.json` / `.kimi-plugin/plugin.json`,并移除
> `.codex-plugin/plugin.json` fallback。
## 结论
当前 `/plugins` 应收束为 Kimi Code 自己的插件协议,而不是旧 Kimi CLI
`tools[].command` runtime 的延续。
当前原生协议入口是:
```text
<plugin_root>/plugin.json
```
如果没有根目录 `plugin.json`,可以把 Codex 插件 manifest 作为 skills-only
fallback 导入:
```text
<plugin_root>/.codex-plugin/plugin.json
```
这个 fallback 只读取元数据、`skills``interface` 展示字段,不导入 Kimi
运行时语义。
不支持:
```text
<plugin_root>/.kimi-plugin/plugin.json
```
最终模型:
```text
Kimi Code plugin =
root plugin.json
+ optional .codex-plugin skills-only fallback
+ skills
+ sessionStart skill injection
+ opt-in mcpServers
+ skillInstructions
+ display metadata
```
## Manifest 形状
```json
{
"name": "kimi-finance",
"version": "1.0.0",
"description": "Finance data and analysis workflows for Kimi Code",
"keywords": ["finance", "mcp"],
"skills": "./skills/",
"sessionStart": {
"skill": "using-finance"
},
"skillInstructions": "Prefer finance MCP tools for live market data. Do not invent live prices.",
"mcpServers": {
"finance": {
"command": "uvx",
"args": ["kimi-finance-mcp"]
}
},
"interface": {
"displayName": "Kimi Finance",
"shortDescription": "Market data and financial analysis workflows"
}
}
```
## 当前支持字段
| 字段 | 行为 |
|---|---|
| `name` | 必填,作为 plugin id 的来源 |
| `version` / `description` / `keywords` | 展示和搜索元数据 |
| `skills` | 声明 skill root支持 `skills/<name>/SKILL.md` |
| root `SKILL.md` | 没有 `skills` 字段时自动作为单 skill root |
| `sessionStart.skill` | 新 session 首轮注入指定 skill |
| `skillInstructions` | 附加到该插件所有 skill 正文前 |
| `mcpServers` | 解析并展示;必须用户显式 enable 后,下个 session 才启动 |
| `interface` | `/plugins info` 的展示信息 |
## `.codex-plugin` fallback
`.codex-plugin/plugin.json` 只用于兼容已经存在的 Codex/Superpowers
skills-only 插件目录。它不是 Kimi 原生协议。
fallback 导入字段:
```text
name
version
description
keywords
homepage
license
author
skills
interface
```
fallback 明确不导入:
```text
hooks
sessionStart
mcpServers
apps
skillInstructions
```
如果同时存在根目录 `plugin.json``.codex-plugin/plugin.json`,根目录
`plugin.json` 胜出,`.codex-plugin/plugin.json` 只在 `/plugins info` 里作为
shadowed manifest 展示。根目录 `plugin.json` 语法错误时也不会 fallback
避免一个坏的原生 manifest 被悄悄绕过。
## 当前明确不支持字段
这些字段只生成 unsupported diagnostic不产生执行能力
```text
tools
configFile
config_file
inject
bootstrap
hooks
apps
```
其中 `bootstrap` 和旧的 `hooks.sessionStart.skill``sessionStart.skill` 取代。
## MCP 策略
`mcpServers` 是真实 Kimi Code plugin 字段,但不是安装即运行。
安装后:
```text
/plugins install /path/to/plugin
/plugins info kimi-finance
```
用户会看到:
```text
MCP servers:
finance disabled (plugin-kimi-finance-finance)
command: uvx kimi-finance-mcp
```
显式启用:
```text
/plugins mcp enable kimi-finance finance
```
禁用:
```text
/plugins mcp disable kimi-finance finance
```
启用状态保存在:
```text
$KIMI_HOME/plugins/installed.json
```
新 session 创建时enabled plugin MCP server 会合并进现有 session MCP config
然后走 Kimi 现有 MCP 生命周期、状态事件、工具注册和权限展示。
## 为什么不支持 tools[].command
`tools[].command` 的实际含义是:
```text
插件安装后,可以把任意本地命令注册成模型可调用工具。
```
这会引入一条和 MCP、Bash、权限系统并行的执行通道。当前协议不创建新的执行通道。
插件需要执行能力时,应该选择:
1. 用 skill 指导模型调用 Kimi 现有工具,例如 `Bash`
2. 声明 MCP server并让用户显式 enable。
3. 后续使用一方维护的 managed runtime。
## 旧财经插件迁移形态
旧财经插件不做 legacy adapter。推荐重写为
```text
kimi-finance-plugin/
plugin.json
skills/
using-finance/SKILL.md
stock-analysis/SKILL.md
earnings-analysis/SKILL.md
mcp/
finance-server/
```
短期可以用 skills 指导模型走现有工具;长期应该用 `mcpServers.finance`
提供结构化数据工具,例如 quote、financials、news。stdio MCP 会继承当前进程环境变量;
如果必须在 manifest 里写 `env`,那就是字面量覆盖,不是 `${VAR}` 插值。
## Superpowers 迁移形态
现有 Superpowers checkout 可以通过 `.codex-plugin/plugin.json` fallback 安装,
并贡献 `skills/` 里的 Skills。但这种模式不会自动启用
`using-superpowers`,因为 Codex manifest 没有 Kimi 的 session-start 语义。
Superpowers 要成为完整的 Kimi 原生插件,需要根目录 `plugin.json`
```json
{
"name": "superpowers",
"version": "5.1.0",
"skills": "./skills/",
"sessionStart": {
"skill": "using-superpowers"
},
"skillInstructions": "Kimi-specific tool mapping and behavior notes.",
"interface": {
"displayName": "Superpowers"
}
}
```
Kimi 代码不再 hardcode `superpowers``.codex-plugin/plugin.json` 只提供
skills-only 导入,不会推导 `sessionStart``skillInstructions`
`mcpServers`

View file

@ -0,0 +1,222 @@
# Superpowers / Kimi Code 插件接手文档
## 当前结论
Kimi Code 插件路线收束为:
```text
Kimi Code plugin =
plugin.json 或 .kimi-plugin/plugin.json
+ skills
+ sessionStart.skill 声明式注入
+ skillInstructions
+ 可选 mcpServers
+ 展示元数据
```
插件 loader 不执行第三方 Python、Node.js、Shell、hook 脚本,也不兼容旧
Kimi CLI 的 `tools[].command` runtime。
`.codex-plugin/plugin.json` 不再作为 fallback。后续 Superpowers 测试都应该使用
`.kimi-plugin/plugin.json` 的本地包或 CDN zip。
## Manifest 查找顺序
当前目标查找顺序:
```text
<plugin_root>/plugin.json
<plugin_root>/.kimi-plugin/plugin.json
```
根目录 `plugin.json` 优先。如果两者同时存在,根目录 `plugin.json` 胜出,
`.kimi-plugin/plugin.json` 只作为 shadowed manifest 在 `/plugins info` 中展示。
Zip 安装也必须按同样规则检测插件根目录。也就是说CDN beta zip 只要包含
`.kimi-plugin/plugin.json`,就应该能被识别为 Kimi 插件包。
## Superpowers 本地适配形状
`~/code/superpowers` 中先加 Kimi 专属薄适配,不提交:
```text
~/code/superpowers/
.kimi-plugin/
plugin.json
skills/
using-superpowers/
SKILL.md
brainstorming/
SKILL.md
...
```
建议 `.kimi-plugin/plugin.json`
```json
{
"name": "superpowers",
"version": "5.1.0",
"description": "An agentic skills framework & software development methodology.",
"skills": "./skills/",
"sessionStart": {
"skill": "using-superpowers"
},
"skillInstructions": "Kimi Code tool mapping: TodoWrite -> TodoList; Task -> Agent; Skill -> Skill. Use AskUserQuestion when asking the user to choose between concrete options.",
"interface": {
"displayName": "Superpowers",
"shortDescription": "Planning, TDD, debugging, and delivery workflows for coding agents"
}
}
```
这份适配不 fork、复制或改写 Superpowers skills只告诉 Kimi
- 从 `./skills/` 扫描 Skills。
- 新 session 开始时声明式注入 `using-superpowers`
- 给每个 Superpowers skill 前面附加 Kimi 工具映射说明。
## Kimi 侧核心代码路径
入口和状态:
- `apps/kimi-code/src/tui/kimi-tui.ts`
- `/plugins` 子命令分发。
- `install` 后提示用户 `/new`,因为当前 session 不热更新 Skills/MCP。
- `packages/node-sdk/src/session.ts`
- 暴露 TUI 调用的 plugin SDK 方法。
- `packages/agent-core/src/rpc/core-impl.ts`
- RPC 实现,持有 `PluginManager`
插件管理:
- `packages/agent-core/src/plugin/manifest.ts`
- 解析 `plugin.json` / `.kimi-plugin/plugin.json`
- 校验 `skills``sessionStart``mcpServers``skillInstructions`
- `packages/agent-core/src/plugin/manager.ts`
- 安装、启用、禁用、删除、重载。
- `pluginSkillRoots()` 把 enabled plugin 的 skill roots 接到 session。
- `enabledSessionStarts()` 输出需要注入的 session-start skill。
- `packages/agent-core/src/plugin/archive.ts`
- zip 下载和解压。
- 解压后检测 plugin root。
- `packages/agent-core/src/plugin/store.ts`
- 读写 `$KIMI_CODE_HOME/plugins/installed.json`
Skill 注入:
- `packages/agent-core/src/skill/scanner.ts`
- 扫描插件贡献的 skill roots。
- `packages/agent-core/src/skill/registry.ts`
- `renderSkillPrompt()``skillInstructions` 加到插件 skill 正文前。
- `packages/agent-core/src/agent/injection/plugin-session-start.ts`
- 把 `sessionStart.skill` 渲染成一次性 session-start 注入。
- 不执行插件 hook 代码。
- resume/replay 时避免重复注入。
## Superpowers 验收方式
本地安装:
```sh
/plugins install /Users/moonshot/code/superpowers
/new
```
检查:
```sh
/plugins
/plugins info superpowers
```
必须看到:
- `skills` 数量大于 0。
- `Session start: using-superpowers`
- `Skill instructions: present`
行为验收:
```text
Let's make a react todo list
```
期望:
- 首轮先注入 `using-superpowers`
- 模型随后触发 `brainstorming`,而不是直接写代码。
- 如果需要用户在具体选项中选择,应调用 Kimi 的 `AskUserQuestion`,让 TUI 出现结构化选择 UI。
如果没有出现结构化 UI优先排查
1. `.kimi-plugin/plugin.json` 是否真的被读取,而不是只装到了旧 `.codex-plugin` 包。
2. `/plugins info superpowers` 是否显示 `Session start``Skill instructions`
3. `skillInstructions` 是否明确写了 `AskUserQuestion` 映射。
4. 当前模型是否实际遵守了工具映射;`skillInstructions` 是 prompt guidance不是代码层硬替换。
## CDN beta 策略
Superpowers upstream 合入前,可以把本地适配包打成 zip 发到 CDN
```text
superpowers-kimi-5.1.0-kimi.1.zip
```
包内容应该来自官方 `obra/superpowers` 对应 tag 或 commit再加
```text
.kimi-plugin/plugin.json
```
不要改:
```text
skills/**/*.md
```
除非是在准备给 upstream 提交且有行为评估证据。不要把 plugin id 改成
`superpowers-kimi`,保持:
```json
{ "name": "superpowers" }
```
这样 upstream 合入后可以把安装源从 CDN 切到官方包,用户侧仍是同一个 plugin id。
## Upstream PR 策略
Superpowers 接受的模式是“新增宿主薄适配”,不是 fork skills。
建议 PR 只包含:
```text
.kimi-plugin/plugin.json
docs/README.kimi.md
README.md 中 Kimi Code 安装入口
```
PR 前需要准备真实 transcript。Superpowers 贡献指南要求新 harness 支持必须证明
clean session 中发送:
```text
Let's make a react todo list
```
会自动触发 `brainstorming`。如果 transcript 里没有自动触发,先修 Kimi 侧或
manifest不要提 PR。
PR 文案要明确:
- 这是 Kimi Code harness adapter。
- 不复制、不改写 Superpowers skills。
- Kimi 的 session start 是声明式 skill 注入,不执行第三方脚本。
- 需要 Kimi Code 版本包含 `.kimi-plugin/plugin.json``/plugins` 支持。
## 不做的事
- 不提交或自动打开 upstream PR。
- 不长期维护 Superpowers fork。
- 不通过 `.codex-plugin/plugin.json` 安装 Superpowers。
- 不在 Kimi core 里硬编码 `superpowers`
- 不执行 Superpowers 的 hook 脚本。

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff