From cdd90acaffd5868fcd8eed4405503e7811254430 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 27 May 2026 01:28:23 +0800 Subject: [PATCH] feat: add plugin support --- .changeset/plugins-v1.md | 7 + apps/kimi-code/src/tui/commands/registry.ts | 7 + .../messages/plugins-status-panel.ts | 135 ++ apps/kimi-code/src/tui/kimi-tui.ts | 141 +- docs/.vitepress/config.ts | 2 + docs/AGENTS.md | 2 +- docs/en/configuration/data-locations.md | 6 + docs/en/customization/mcp.md | 2 + docs/en/customization/plugins.md | 152 ++ docs/en/reference/slash-commands.md | 1 + docs/zh/configuration/data-locations.md | 6 + docs/zh/customization/mcp.md | 2 + docs/zh/customization/plugins.md | 152 ++ docs/zh/reference/slash-commands.md | 1 + packages/agent-core/src/agent/index.ts | 5 + .../agent-core/src/agent/injection/manager.ts | 7 +- .../agent/injection/plugin-session-start.ts | 57 + packages/agent-core/src/errors/codes.ts | 16 + packages/agent-core/src/index.ts | 1 + packages/agent-core/src/plugin.ts | 1 + packages/agent-core/src/plugin/archive.ts | 143 ++ packages/agent-core/src/plugin/index.ts | 10 + packages/agent-core/src/plugin/manager.ts | 400 +++++ packages/agent-core/src/plugin/manifest.ts | 438 +++++ packages/agent-core/src/plugin/source.ts | 21 + packages/agent-core/src/plugin/store.ts | 61 + packages/agent-core/src/plugin/types.ts | 127 ++ packages/agent-core/src/rpc/core-api.ts | 34 + packages/agent-core/src/rpc/core-impl.ts | 125 +- packages/agent-core/src/session/index.ts | 6 + packages/agent-core/src/skill/registry.ts | 17 +- packages/agent-core/src/skill/scanner.ts | 69 +- packages/agent-core/src/skill/types.ts | 7 + .../injection/plugin-session-start.test.ts | 189 ++ .../agent-core/test/plugin/archive.test.ts | 195 +++ .../test/plugin/integration.test.ts | 34 + .../agent-core/test/plugin/manager.test.ts | 507 ++++++ .../agent-core/test/plugin/manifest.test.ts | 343 ++++ .../agent-core/test/plugin/source.test.ts | 33 + packages/agent-core/test/plugin/store.test.ts | 64 + .../agent-core/test/rpc/plugins-rpc.test.ts | 131 ++ packages/agent-core/test/skill/parser.test.ts | 44 + .../agent-core/test/skill/scanner.test.ts | 29 + .../agent-core/test/tools/skill-tool.test.ts | 27 + packages/node-sdk/src/rpc.ts | 42 + packages/node-sdk/src/session.ts | 42 + packages/node-sdk/src/types.ts | 3 + .../2026-05-26-plugin-protocol-realignment.md | 241 +++ ...6-05-27-superpowers-kimi-plugin-handoff.md | 222 +++ reports/plugins-implementation-plan.html | 1531 +++++++++++++++++ reports/plugins-research.html | 1519 ++++++++++++++++ 51 files changed, 7342 insertions(+), 15 deletions(-) create mode 100644 .changeset/plugins-v1.md create mode 100644 apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts create mode 100644 docs/en/customization/plugins.md create mode 100644 docs/zh/customization/plugins.md create mode 100644 packages/agent-core/src/agent/injection/plugin-session-start.ts create mode 100644 packages/agent-core/src/plugin.ts create mode 100644 packages/agent-core/src/plugin/archive.ts create mode 100644 packages/agent-core/src/plugin/index.ts create mode 100644 packages/agent-core/src/plugin/manager.ts create mode 100644 packages/agent-core/src/plugin/manifest.ts create mode 100644 packages/agent-core/src/plugin/source.ts create mode 100644 packages/agent-core/src/plugin/store.ts create mode 100644 packages/agent-core/src/plugin/types.ts create mode 100644 packages/agent-core/test/agent/injection/plugin-session-start.test.ts create mode 100644 packages/agent-core/test/plugin/archive.test.ts create mode 100644 packages/agent-core/test/plugin/integration.test.ts create mode 100644 packages/agent-core/test/plugin/manager.test.ts create mode 100644 packages/agent-core/test/plugin/manifest.test.ts create mode 100644 packages/agent-core/test/plugin/source.test.ts create mode 100644 packages/agent-core/test/plugin/store.test.ts create mode 100644 packages/agent-core/test/rpc/plugins-rpc.test.ts create mode 100644 reports/2026-05-26-plugin-protocol-realignment.md create mode 100644 reports/2026-05-27-superpowers-kimi-plugin-handoff.md create mode 100644 reports/plugins-implementation-plan.html create mode 100644 reports/plugins-research.html diff --git a/.changeset/plugins-v1.md b/.changeset/plugins-v1.md new file mode 100644 index 000000000..a9d702274 --- /dev/null +++ b/.changeset/plugins-v1.md @@ -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. diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 6313b2f0d..b18bccb1c 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -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: [], diff --git a/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts b/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts new file mode 100644 index 000000000..4dbca68a5 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts @@ -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 '), + ]; + } + 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} `)); + } + 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); +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 03df4fbaa..0e4e6f76e 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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 { + // 临时阅读注释:/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 '); + 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} .` + : ''; + 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 '); + 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 '); + return; + } + const id = rest[1]; + const server = rest[2]; + if (id === undefined || server === undefined) { + this.showError('Usage: /plugins mcp enable|disable '); + 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} `); + 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 '); + 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 { 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; +} diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index ab968c7d0..b04ae5ac1 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -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' }, ], diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 14d574ea6..49fc9bf3f 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -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 diff --git a/docs/en/configuration/data-locations.md b/docs/en/configuration/data-locations.md index 2d081d6ac..161a9a4fe 100644 --- a/docs/en/configuration/data-locations.md +++ b/docs/en/configuration/data-locations.md @@ -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) │ ├── .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//`. 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/.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/.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 | diff --git a/docs/en/customization/mcp.md b/docs/en/customization/mcp.md index 28aec71ac..0f5f0c23b 100644 --- a/docs/en/customization/mcp.md +++ b/docs/en/customization/mcp.md @@ -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 ` and start a new session. See [Plugins](./plugins.md) for details. + The top-level shape of `mcp.json` is: ```json diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md new file mode 100644 index 000000000..7cde5c095 --- /dev/null +++ b/docs/en/customization/plugins.md @@ -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 +/plugins enable +/plugins disable +/plugins remove +/plugins reload +/plugins mcp enable +/plugins mcp disable +``` + +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 ` 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.json +``` + +If `plugin.json` is absent, Kimi Code CLI reads the Kimi-scoped manifest: + +```text +/.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:`, 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 ` and do not crash unrelated sessions. diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 5210640c1..0bbd597fc 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -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 `. | Yes | | `/version` | — | Show the Kimi Code CLI version number. | Yes | | `/feedback` | — | Submit feedback to help improve Kimi Code CLI. | Yes | diff --git a/docs/zh/configuration/data-locations.md b/docs/zh/configuration/data-locations.md index 519ad5029..5f493065d 100644 --- a/docs/zh/configuration/data-locations.md +++ b/docs/zh/configuration/data-locations.md @@ -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) │ ├── .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//`。详见 [Plugins](../customization/plugins.md)。 + OAuth 凭据以文件形式存放在数据根下的 `credentials/` 子目录,目录权限 `0o700`、文件权限 `0o600`,仅当前用户可读写。其中: - **托管 Kimi / Open Platform 等供应商的 OAuth 凭据**位于 `credentials/.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/.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/` 目录 | diff --git a/docs/zh/customization/mcp.md b/docs/zh/customization/mcp.md index 8f7130253..81dcda77c 100644 --- a/docs/zh/customization/mcp.md +++ b/docs/zh/customization/mcp.md @@ -17,6 +17,8 @@ MCP server 配置写在 `mcp.json` 中,分为两层: 最方便的入口是在 TUI 中运行 `/mcp-config`,它会引导你新增、编辑或删除 server。要查看当前连接状态,可运行 `/mcp`。 +Plugins 也可以在 `plugin.json` 中声明 MCP servers。Plugin 声明的 servers 不会在安装时启动;需要通过 `/plugins mcp enable ` 显式启用,并开启新会话。详见 [Plugins](./plugins.md)。 + `mcp.json` 的顶层结构如下: ```json diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md new file mode 100644 index 000000000..92ca0c137 --- /dev/null +++ b/docs/zh/customization/plugins.md @@ -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 +/plugins enable +/plugins disable +/plugins remove +/plugins reload +/plugins mcp enable +/plugins mcp disable +``` + +本地目录只会登记到 `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 ` 展示最新安装状态和 diagnostics。它不会热更新当前会话里的 Skills 或 MCP 连接。 + +## Manifest 格式 + +Kimi Code CLI 把根目录 `plugin.json` 作为优先 plugin manifest: + +```text +/plugin.json +``` + +如果没有 `plugin.json`,Kimi Code CLI 会读取 Kimi 专属 manifest: + +```text +/.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:`,还是模型自动调用加载,`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 ` 中的 diagnostics,不会让无关会话崩溃。 diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 9f3c3f2cb..2c70cccb5 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -55,6 +55,7 @@ | `/usage` | — | 显示 token 用量、上下文占用以及配额信息。 | 是 | | `/status` | — | 显示当前会话运行时状态,包括版本、模型、工作目录和权限模式等。 | 是 | | `/mcp` | — | 列出当前会话中的 MCP server 及其连接状态。 | 是 | +| `/plugins` | — | 列出、安装、查看、启用、禁用、移除和重载 plugins;也可通过 `/plugins mcp enable\|disable ` 启用或禁用 plugin 声明的 MCP servers。 | 是 | | `/version` | — | 显示 Kimi Code CLI 版本号。 | 是 | | `/feedback` | — | 提交反馈以改进 Kimi Code CLI。 | 是 | diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index cdb1def9a..88f95d909 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -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 diff --git a/packages/agent-core/src/agent/injection/manager.ts b/packages/agent-core/src/agent/injection/manager.ts index ffc1fcf01..edda42c40 100644 --- a/packages/agent-core/src/agent/injection/manager.ts +++ b/packages/agent-core/src/agent/injection/manager.ts @@ -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 { diff --git a/packages/agent-core/src/agent/injection/plugin-session-start.ts b/packages/agent-core/src/agent/injection/plugin-session-start.ts new file mode 100644 index 000000000..4d39e4abb --- /dev/null +++ b/packages/agent-core/src/agent/injection/plugin-session-start.ts @@ -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 { + // 临时阅读注释: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 ( + `\n${skillContent}\n` + ); +} + +function escapeAttr(value: string): string { + return value.replaceAll('"', '"'); +} diff --git a/packages/agent-core/src/errors/codes.ts b/packages/agent-core/src/errors/codes.ts index 2583a8faa..2014dba2c 100644 --- a/packages/agent-core/src/errors/codes.ts +++ b/packages/agent-core/src/errors/codes.ts @@ -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, diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 36a44a7a4..2045c9872 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -5,6 +5,7 @@ export * from './config'; export * from './session/export'; export * from './telemetry'; export * from './errors'; +export * from './plugin'; export { flushDiagnosticLogs, getRootLogger, diff --git a/packages/agent-core/src/plugin.ts b/packages/agent-core/src/plugin.ts new file mode 100644 index 000000000..01e7ac1fb --- /dev/null +++ b/packages/agent-core/src/plugin.ts @@ -0,0 +1 @@ +export * from './plugin/index'; diff --git a/packages/agent-core/src/plugin/archive.ts b/packages/agent-core/src/plugin/archive.ts new file mode 100644 index 000000000..da3f29476 --- /dev/null +++ b/packages/agent-core/src/plugin/archive.ts @@ -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 { + 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 { + 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((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 { + async function search(current: string): Promise { + 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 { + 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 { + try { + return (await stat(p)).isFile(); + } catch { + return false; + } +} diff --git a/packages/agent-core/src/plugin/index.ts b/packages/agent-core/src/plugin/index.ts new file mode 100644 index 000000000..5722be5bd --- /dev/null +++ b/packages/agent-core/src/plugin/index.ts @@ -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'; diff --git a/packages/agent-core/src/plugin/manager.ts b/packages/agent-core/src/plugin/manager.ts new file mode 100644 index 000000000..54022f762 --- /dev/null +++ b/packages/agent-core/src/plugin/manager.ts @@ -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(); + + constructor(options: PluginManagerOptions) { + this.kimiHomeDir = options.kimiHomeDir; + } + + async load(): Promise { + // 临时阅读注释:启动时从 installed.json 恢复插件列表,并重新读取每个插件当前的 manifest。 + const file = await readInstalled(this.kimiHomeDir); + const next = new Map(); + 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 { + 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 { + // 临时阅读注释: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 { + 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 { + const key = normalizePluginId(id); + if (!this.records.delete(key)) { + throw new Error(`Plugin "${id}" is not installed`); + } + await this.persist(); + } + + async reload(): Promise { + // 临时阅读注释:reload 用于用户手动改 manifest 或 installed.json 后刷新内存快照。 + const prevIds = new Set(this.records.keys()); + const file = await readInstalled(this.kimiHomeDir); + const next = new Map(); + 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 { + const out: Record = {}; + 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 { + 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 { + 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 { + // 临时阅读注释:安装路径必须是绝对路径并 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}`; +} diff --git a/packages/agent-core/src/plugin/manifest.ts b/packages/agent-core/src/plugin/manifest.ts new file mode 100644 index 000000000..8a68f0a83 --- /dev/null +++ b/packages/agent-core/src/plugin/manifest.ts @@ -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 { + 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, + 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 { + 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 { + 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 { + 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 = {}; + 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 { + 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, 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, 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 { + 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 { + try { + return (await stat(p)).isFile(); + } catch { + return false; + } +} + +async function isDir(p: string): Promise { + try { + return (await stat(p)).isDirectory(); + } catch { + return false; + } +} diff --git a/packages/agent-core/src/plugin/source.ts b/packages/agent-core/src/plugin/source.ts new file mode 100644 index 000000000..d964a16b5 --- /dev/null +++ b/packages/agent-core/src/plugin/source.ts @@ -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 }; +} diff --git a/packages/agent-core/src/plugin/store.ts b/packages/agent-core/src/plugin/store.ts new file mode 100644 index 000000000..981319177 --- /dev/null +++ b/packages/agent-core/src/plugin/store.ts @@ -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 { + // 临时阅读注释: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 { + // 临时阅读注释:先写临时文件再 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); +} diff --git a/packages/agent-core/src/plugin/types.ts b/packages/agent-core/src/plugin/types.ts new file mode 100644 index 000000000..776955e6b --- /dev/null +++ b/packages/agent-core/src/plugin/types.ts @@ -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>; + readonly interface?: PluginInterface; + readonly skillInstructions?: string; +} + +export interface PluginMcpServerState { + readonly enabled: boolean; +} + +export interface PluginCapabilityState { + readonly mcpServers?: Readonly>; +} + +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(); +} diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index ebc1b2e36..042cb4aa5 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -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; } diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index e79d11a4e..013712cc1 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -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 { private readonly skillDirs: readonly string[]; private readonly providerManager: ProviderManager; private readonly sessionStore: SessionStore; + readonly plugins: PluginManager; + private pluginsReady: Promise; + private pluginsLoadError: Error | undefined; constructor( protected readonly rpcClient: CoreRPCClient, @@ -132,6 +145,15 @@ export class KimiCore implements PromisableMethods { 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 { 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 { 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 { 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 { } 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 { 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 { return this.sessionApi(sessionId).generateAgentsMd(payload); } + async installPlugin(payload: InstallPluginPayload): Promise { + // 临时阅读注释: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 { + await this.pluginsReady; + this.assertPluginsLoaded(); + return this.plugins.summaries(); + } + + async setPluginEnabled({ id, enabled }: SetPluginEnabledPayload): Promise { + await this.pluginsReady; + this.assertPluginsLoaded(); + await this.plugins.setEnabled(id, enabled); + } + + async setPluginMcpServerEnabled({ + id, + server, + enabled, + }: SetPluginMcpServerEnabledPayload): Promise { + await this.pluginsReady; + this.assertPluginsLoaded(); + await this.plugins.setMcpServerEnabled(id, server, enabled); + } + + async removePlugin({ id }: RemovePluginPayload): Promise { + await this.pluginsReady; + this.assertPluginsLoaded(); + await this.plugins.remove(id); + } + + async reloadPlugins(_: EmptyPayload): Promise { + 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 { + 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 { if (this.runtime !== undefined) return this.runtime; const runtime = await createRuntimeConfig({ @@ -553,10 +659,23 @@ export class KimiCore implements PromisableMethods { 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) { diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index b5bbe9a61..ad621208a 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -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, }); } diff --git a/packages/agent-core/src/skill/registry.ts b/packages/agent-core/src/skill/registry.ts index 551b5bfc7..6ebeb657a 100644 --- a/packages/agent-core/src/skill/registry.ts +++ b/packages/agent-core/src/skill/registry.ts @@ -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 ( + `\n` + + `${instructions}\n` + + `\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('&', '&').replaceAll('"', '"'); +} diff --git a/packages/agent-core/src/skill/scanner.ts b/packages/agent-core/src/skill/scanner.ts index 5f6823937..014a7fb47 100644 --- a/packages/agent-core/src/skill/scanner.ts +++ b/packages/agent-core/src/skill/scanner.ts @@ -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; readonly isDir?: (p: string) => Promise; @@ -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 { @@ -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, + realpath: (p: string) => Promise, +): Promise { + // 临时阅读注释: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; readonly byName: Map; 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 { @@ -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({ diff --git a/packages/agent-core/src/skill/types.ts b/packages/agent-core/src/skill/types.ts index 8ea89b1dd..21a44db60 100644 --- a/packages/agent-core/src/skill/types.ts +++ b/packages/agent-core/src/skill/types.ts @@ -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 { diff --git a/packages/agent-core/test/agent/injection/plugin-session-start.test.ts b/packages/agent-core/test/agent/injection/plugin-session-start.test.ts new file mode 100644 index 000000000..74bf5547a --- /dev/null +++ b/packages/agent-core/test/agent/injection/plugin-session-start.test.ts @@ -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 `\n${instructions}\n\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 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(''); + expect(text).toContain(''); + expect(text).toContain('AskUserQuestion'); + expect(text).toContain('TodoList'); + expect(text).toContain('body of skill'); + expect(text).toContain(''); + }); + + 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(''); + expect(text).toContain('body'); + expect(text).not.toContain(''); + 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: 'old' }], + 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([]); + }); +}); diff --git a/packages/agent-core/test/plugin/archive.test.ts b/packages/agent-core/test/plugin/archive.test.ts new file mode 100644 index 000000000..67137fe61 --- /dev/null +++ b/packages/agent-core/test/plugin/archive.test.ts @@ -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 { + 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 { + 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((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'); + }); +}); diff --git a/packages/agent-core/test/plugin/integration.test.ts b/packages/agent-core/test/plugin/integration.test.ts new file mode 100644 index 000000000..6382e02c8 --- /dev/null +++ b/packages/agent-core/test/plugin/integration.test.ts @@ -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 }, + }); + }); +}); diff --git a/packages/agent-core/test/plugin/manager.test.ts b/packages/agent-core/test/plugin/manager.test.ts new file mode 100644 index 000000000..5fd1b3090 --- /dev/null +++ b/packages/agent-core/test/plugin/manager.test.ts @@ -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 { + return mkdtemp(path.join(tmpdir(), 'kimi-home-')); +} + +async function makePlugin( + name: string, + options: { + skills?: boolean; + sessionStartSkill?: string; + mcpServers?: Record; + } = {}, +): Promise { + const root = await mkdtemp(path.join(tmpdir(), `plugin-${name}-`)); + const manifest: Record = { 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 { + 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 { + 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}`); + }); + }); +} diff --git a/packages/agent-core/test/plugin/manifest.test.ts b/packages/agent-core/test/plugin/manifest.test.ts new file mode 100644 index 000000000..ffc69fced --- /dev/null +++ b/packages/agent-core/test/plugin/manifest.test.ts @@ -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, + options: { dirs?: readonly string[] } = {}, +): Promise { + 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.'); + }); +}); diff --git a/packages/agent-core/test/plugin/source.test.ts b/packages/agent-core/test/plugin/source.test.ts new file mode 100644 index 000000000..d379151af --- /dev/null +++ b/packages/agent-core/test/plugin/source.test.ts @@ -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); + }); +}); diff --git a/packages/agent-core/test/plugin/store.test.ts b/packages/agent-core/test/plugin/store.test.ts new file mode 100644 index 000000000..1b73a4537 --- /dev/null +++ b/packages/agent-core/test/plugin/store.test.ts @@ -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 { + 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); + }); +}); diff --git a/packages/agent-core/test/rpc/plugins-rpc.test.ts b/packages/agent-core/test/rpc/plugins-rpc.test.ts new file mode 100644 index 000000000..12e1f53bd --- /dev/null +++ b/packages/agent-core/test/rpc/plugins-rpc.test.ts @@ -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' }), + ); + }); +}); diff --git a/packages/agent-core/test/skill/parser.test.ts b/packages/agent-core/test/skill/parser.test.ts index 15d105fb1..27a8c6b0c 100644 --- a/packages/agent-core/test/skill/parser.test.ts +++ b/packages/agent-core/test/skill/parser.test.ts @@ -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( + '\n' + + 'Use AskUserQuestion for clarifying questions.\n' + + '\n\nBrainstorm body.', + ); + }); }); async function makeSkillsRoot(): Promise { @@ -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, }; } diff --git a/packages/agent-core/test/skill/scanner.test.ts b/packages/agent-core/test/skill/scanner.test.ts index 12a5bde63..4f2f05f6a 100644 --- a/packages/agent-core/test/skill/scanner.test.ts +++ b/packages/agent-core/test/skill/scanner.test.ts @@ -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'); diff --git a/packages/agent-core/test/tools/skill-tool.test.ts b/packages/agent-core/test/tools/skill-tool.test.ts index 49a343eae..aa8a03385 100644 --- a/packages/agent-core/test/tools/skill-tool.test.ts +++ b/packages/agent-core/test/tools/skill-tool.test.ts @@ -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( + '\n' + + '\n' + + 'Use AskUserQuestion for clarifying questions.\n' + + '\n\nbrainstorm body\n' + + '', + ); + }); + it('expands skill body placeholders for model-invoked inline skills', async () => { const methods = skillToolMethods(); const tool = skillTool( diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 97fb11616..a2041af5c 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -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 { + const rpc = await this.getRpc(); + return rpc.listPlugins({}); + } + + async installPlugin(source: string): Promise { + const rpc = await this.getRpc(); + return rpc.installPlugin({ source }); + } + + async setPluginEnabled(id: string, enabled: boolean): Promise { + const rpc = await this.getRpc(); + return rpc.setPluginEnabled({ id, enabled }); + } + + async setPluginMcpServerEnabled( + id: string, + server: string, + enabled: boolean, + ): Promise { + const rpc = await this.getRpc(); + return rpc.setPluginMcpServerEnabled({ id, server, enabled }); + } + + async removePlugin(id: string): Promise { + const rpc = await this.getRpc(); + return rpc.removePlugin({ id }); + } + + async reloadPlugins(): Promise { + const rpc = await this.getRpc(); + return rpc.reloadPlugins({}); + } + + async getPluginInfo(id: string): Promise { + const rpc = await this.getRpc(); + return rpc.getPluginInfo({ id }); + } + async activateSkill(input: ActivateSkillRpcInput): Promise { const rpc = await this.getRpc(); return rpc.activateSkill({ diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 55f2989f2..6dc395ef7 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -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 { + this.ensureOpen(); + return this.rpc.listPlugins(); + } + + async installPlugin(source: string): Promise { + this.ensureOpen(); + return this.rpc.installPlugin(source); + } + + async setPluginEnabled(id: string, enabled: boolean): Promise { + this.ensureOpen(); + await this.rpc.setPluginEnabled(id, enabled); + } + + async setPluginMcpServerEnabled( + id: string, + server: string, + enabled: boolean, + ): Promise { + this.ensureOpen(); + await this.rpc.setPluginMcpServerEnabled(id, server, enabled); + } + + async removePlugin(id: string): Promise { + this.ensureOpen(); + await this.rpc.removePlugin(id); + } + + async reloadPlugins(): Promise { + this.ensureOpen(); + return this.rpc.reloadPlugins(); + } + + async getPluginInfo(id: string): Promise { + this.ensureOpen(); + return this.rpc.getPluginInfo(id); + } + async activateSkill(name: string, args?: string | undefined): Promise { this.ensureOpen(); const skillName = normalizeRequiredString( diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index f5a2a90b8..ffbae71c5 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -31,9 +31,12 @@ export type { ModelAlias, MoonshotServiceConfig, OAuthRef, + PluginInfo, + PluginSummary, PromptOrigin, ProviderConfig, ProviderType, + ReloadSummary, ResumedAgentState, ServicesConfig, ShellEnvironment, diff --git a/reports/2026-05-26-plugin-protocol-realignment.md b/reports/2026-05-26-plugin-protocol-realignment.md new file mode 100644 index 000000000..7cf389294 --- /dev/null +++ b/reports/2026-05-26-plugin-protocol-realignment.md @@ -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.json +``` + +如果没有根目录 `plugin.json`,可以把 Codex 插件 manifest 作为 skills-only +fallback 导入: + +```text +/.codex-plugin/plugin.json +``` + +这个 fallback 只读取元数据、`skills` 和 `interface` 展示字段,不导入 Kimi +运行时语义。 + +不支持: + +```text +/.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//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`。 diff --git a/reports/2026-05-27-superpowers-kimi-plugin-handoff.md b/reports/2026-05-27-superpowers-kimi-plugin-handoff.md new file mode 100644 index 000000000..074cced83 --- /dev/null +++ b/reports/2026-05-27-superpowers-kimi-plugin-handoff.md @@ -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.json +/.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 脚本。 diff --git a/reports/plugins-implementation-plan.html b/reports/plugins-implementation-plan.html new file mode 100644 index 000000000..411adeb3c --- /dev/null +++ b/reports/plugins-implementation-plan.html @@ -0,0 +1,1531 @@ + + + + + + Kimi Code /plugins 实现计划 + + + +
+ Kimi Code /plugins 实现计划 + +
+ +
+
+
本地源码调研版 · 2026-05-25
+

给 Kimi Code 增加 `/plugins`:先兼容 Superpowers,再逐步扩展到 Claude / Codex / kimi-cli / OpenCode / Kilo

+

+ 这份文档把已经下载到 /Users/moonshot/code 的源码和文档串起来,给出可以落地的实现顺序。 + 核心判断很简单:第一版不要直接运行 OpenCode/Kilo 的任意 JS 插件;先把插件当成“可安装的能力包”读取, + 把 skills、工具声明、界面元数据接进 Kimi Code。 +

+
+ 第一目标:Superpowers 可用 + 推荐入口:Codex manifest + OpenCode/Kilo 代码插件延后 + 默认不执行第三方 JS +
+
+ +
+

一页结论

+

+ Kimi Code 现有的 skills 和工具系统已经能承接大部分插件价值。真正需要新增的是插件发现、安装、启停、兼容适配和 `/plugins` 管理界面。 +

+ +
+
+ M1-M2 +

Superpowers 先做成可用闭环

+

+ 读取 /Users/moonshot/code/superpowers/.codex-plugin/plugin.json,把 + skills: "./skills/" 加到 Kimi 的额外 skill roots,并为 + using-superpowers 做一次启动提示注入。 +

+
+ +
+ M3 +

kimi-cli 老插件作为工具兼容

+

+ 老 kimi-cliplugin.json 本质是“命令行工具声明”:工具参数走 + stdin,结果走 stdout。这和 Kimi 的 tool manager 最容易对接。 +

+
+ +
+ M6+ +

OpenCode / Kilo JS 插件不要第一版执行

+

+ 它们的插件能拦截消息、工具、权限、配置和 shell env。Kimi 还没有等价的 hook 边界,直接运行会把安全和调试成本拉高。 +

+
+
+ +
+ 推荐路线: + 第一版实现“插件包读取 + 本地安装 + skills 注册 + kimi-cli 工具适配 + `/plugins` UI”。OpenCode/Kilo 的代码插件只做识别和提示, + 不作为默认可执行能力。这样可以马上兼容 Superpowers,同时不把 Kimi 绑到另一个 agent 的运行时。 +
+ +
+
+ 兼容目标怎么定义 + + 分成四档:能读取元数据、能注册 skills、能执行声明式工具、能运行代码 hook。前两档可以马上做;第四档必须等 Kimi 自己的 hook API + 定清楚以后再做。 + +
+
+ Superpowers 用哪条路径 + + 优先用 .codex-plugin/plugin.json。Superpowers 的 OpenCode 插件实际做两件事:注册 skills 路径和注入 bootstrap; + 这两件事 Kimi 可以原生完成,不需要运行它的 JS。 + +
+
+ Claude Code 兼容度 + + Claude 插件 schema 很完整,覆盖 commands、agents、skills、hooks、MCP、LSP、userConfig、channels、dependencies。 + Kimi 第一版只应读取 metadata/skills/hooks 声明并展示诊断;完整行为兼容要拆阶段。 + +
+
+ Codex 兼容度 + + Codex manifest 比 Claude 小,字段和 Superpowers 正好匹配。Kimi 第一版可兼容 skills 和 + interfacehooksappsmcpServers 先解析展示,暂不执行。 + +
+
+
+ +
+

当前 Kimi Code 可复用的基础

+

+ 这部分决定了实现要贴哪里。结论是:不要先新造一整套插件运行时,先把插件能力接到已有的 skills、tool、RPC 和 TUI 命令。 +

+ +
+
+ 插件目录 + ~/.kimi-code/plugins 和项目级目录,保存 manifest、状态和安装来源。 +
+
+ manifest 读取 + 识别 Codex、Claude、kimi-cli、Superpowers、OpenCode/Kilo 包。 +
+
+ 能力归一 + 转换成 Kimi 内部的 skills、tools、bootstrap、diagnostics。 +
+
+ Session 接入 + 插件 skill dirs 进入 resolveSkillRoots.extraDirs,工具注册进 ToolManager。 +
+
+ /plugins UI + 安装、启停、查看详情、reload、展示不兼容原因。 +
+
+ +
+
+

已有 skills 路径可以直接承接插件 skills

+

+ packages/agent-core/src/skill/scanner.ts 已支持 + explicitDirsextraDirs。插件第一版可以把 skill 目录作为 + extra source 注入,避免一开始就改动 skill 类型和展示规则。 +

+
+
+

工具层已有命名和权限经验

+

+ packages/agent-core/src/agent/tool/index.ts 已管理 builtin、user、MCP 工具。 + 插件工具建议使用 plugin__<plugin>__<tool> 的名字,和 MCP 的 + mcp__server__tool 思路保持一致。 +

+
+
+

现有 TUI slash 命令没有 /plugins

+

+ apps/kimi-code/src/tui/commands/registry.ts 目前已有 + /settings/mcp/tasks/sessions 等命令。 + /plugins 应该作为一级命令加入,并在 detail view 里显示兼容状态。 +

+
+
+

SDK/RPC 要先补管理 API

+

+ packages/node-sdk/src/session.ts 已有 listSkills 和 + activateSkill。插件管理更适合放在 harness/core RPC 层:list/install/enable/disable/remove/reload。 +

+
+
+
+ +
+

兼容矩阵

+

+ 这里把“兼容”拆成可落地动作。可以导入并不等于可以执行;第一版要把边界说清楚。 +

+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
格式 / 项目本地证据能直接读取什么第一版动作不能第一版承诺的部分
+ Superpowers + 必须第一时间兼容 + + /Users/moonshot/code/superpowers/.codex-plugin/plugin.json
+ /Users/moonshot/code/superpowers/skills/
+ /Users/moonshot/code/superpowers/.opencode/plugins/superpowers.js +
+ 元数据、interfaceskillsusing-superpowers。 + OpenCode JS 的实际行为也能用源码读出来:注册 skills 路径,并向第一条用户消息注入 bootstrap。 + + 用 Codex manifest 安装;注册 skills;把 bootstrap 作为 Kimi 原生 session 注入;显示为 + format=codex+superpowers。 + + 不执行 .opencode/plugins/superpowers.js。它依赖 OpenCode 的 + configexperimental.chat.messages.transform hook。 +
+ Codex plugin + 第一版可兼容子集 + + /Users/moonshot/code/codex/codex-rs/core-plugins/src/manifest.rs
+ /Users/moonshot/code/codex/codex-rs/utils/plugins/src/plugin_namespace.rs +
+ .codex-plugin/plugin.json;也能识别 .claude-plugin/plugin.json。 + 支持 skillsmcpServersappshooksinterface。 + + 先支持 skillsinterface。路径必须是 ./ 开头,并且不能逃出 plugin root。 + + hooksappsmcpServers 先解析展示,不执行。 +
+ kimi-cli Python plugin + 工具适配最直接 + + /Users/moonshot/code/kimi-cli/docs/en/customization/plugins.md
+ /Users/moonshot/code/kimi-cli/src/kimi_cli/plugin/ +
+ plugin.json,字段包括 toolscommandparameters、 + config_fileinject。 + + 转成 Kimi plugin tools:stdin 写 JSON 参数,stdout 返回文本;工具默认需要权限确认;超时、输出大小和 cwd 固定。 + + 旧版凭据写入 config_file 可以先不做,第一步用环境变量更安全。 +
+ Claude Code plugin + 先导入,后兼容行为 + + /Users/moonshot/code/claude-code/src/utils/plugins/schemas.ts
+ /Users/moonshot/code/claude-code/src/services/plugins/ +
+ 元数据、commands、agents、skills、hooks、outputStyles、MCP、LSP、userConfig、channels、dependencies、marketplace。 + + 先解析 metadata 和 skills;如果 manifest 没写 skills,但 root 有 skills/,对 Superpowers 这类包给出可用 fallback。 + + Claude 的 hooks、commands、agents、userConfig 不能假装等价。需要逐项映射到 Kimi 的功能以后再启用。 +
+ OpenCode plugin + 代码运行延后 + + /Users/moonshot/code/opencode/packages/web/src/content/docs/plugins.mdx
+ /Users/moonshot/code/opencode/packages/plugin/src/index.ts
+ /Users/moonshot/code/opencode/packages/opencode/src/plugin/ +
+ 本地 .opencode/plugins/*.js、npm package spec、hooks、custom tools、TUI plugins、themes。 + + 第一版只识别并显示“不执行代码插件”。对 Superpowers 走 Codex manifest 专用路径。 + + tool.execute.beforepermission.ask、 + experimental.chat.messages.transform 等 hook 没有 Kimi 等价边界。 +
+ Kilo plugin + OpenCode 分支同类 + + /Users/moonshot/code/kilocode/packages/plugin/src/index.ts
+ /Users/moonshot/code/kilocode/packages/opencode/src/plugin/index.ts +
+ 和 OpenCode 基本同形,只是 SDK/包名换成 @kilocode/*,并增加 Kilo 自己的 internal plugins。 + + 和 OpenCode 一样先识别、展示、提示。可借鉴它的 pure mode:默认跳过外部代码插件。 + + 不承诺直接运行 Kilo server/TUI plugin。否则会把 Kilo 的 runtime 假设带进 Kimi。 +
+
+
+ +
+

Superpowers 兼容方案

+

+ Superpowers 是最适合作为第一版验收目标的插件,因为它已经同时提供 Codex、Claude、Cursor、OpenCode 入口,并且核心能力主要是 skills。 +

+ +
+
+

本地仓库结构

+
superpowers/
+├── .codex-plugin/plugin.json      # Kimi 第一版优先读取
+├── .claude-plugin/plugin.json     # Claude metadata,字段较少
+├── .cursor-plugin/plugin.json     # Cursor 有 skills/agents/commands/hooks
+├── .opencode/plugins/superpowers.js
+├── hooks/session-start            # Claude/Cursor/Copilot 入口
+└── skills/*/SKILL.md              # 真正的主体能力
+
+ +
+

Kimi 应该复刻的行为

+

+ OpenCode 版 Superpowers 做了两件事:把 skills/ 加入 OpenCode 配置;读取 + skills/using-superpowers/SKILL.md,去掉 frontmatter 后注入第一条 user message。 + Kimi 需要实现同样效果,但用自己的 session 和 skill registry 完成。 +

+
+ 注意: + docs/README.opencode.md 说使用 experimental.chat.system.transform, + 但源码当前是 experimental.chat.messages.transform。实现时以源码为准。 +
+
+
+ +
+
Superpowers 到 Kimi 的映射
+
+
+ skills/ + 作为插件 skill root 加入 extraDirs +
+
+ using-superpowers + 作为 session bootstrap 注入一次,并防重复。 +
+
+ Skill tool + 映射到 Kimi 的 SkillTool 和 slash skill 命令。 +
+
+ Task + 映射到 Kimi 的 AgentTool;没有 subagent 时显示降级提示。 +
+
+
+ +
+ 建议的 Kimi bootstrap 文案形状 +
+

+ 不建议把完整文案硬编码在代码里。应从 skills/using-superpowers/SKILL.md 读取正文, + 在运行时追加 Kimi tool mapping。注入内容只出现一次,并打上内部标记,避免 compact、fork 或重放时重复。 +

+
<EXTREMELY_IMPORTANT>
+You have superpowers.
+
+The using-superpowers skill content is already loaded. Do not load it again.
+
+{content from skills/using-superpowers/SKILL.md without frontmatter}
+
+Tool Mapping for Kimi Code:
+- TodoWrite -> TodoList
+- Task with subagents -> Agent
+- Skill tool -> Skill
+- Read, Write, Edit, Bash -> native Kimi tools
+</EXTREMELY_IMPORTANT>
+
+
+ +
+ 为什么不用 OpenCode JS 直接跑 Superpowers +
+

+ .opencode/plugins/superpowers.js 的输入是 OpenCode 的 PluginInput, + 返回 OpenCode 的 hooks。Kimi 没有相同的 config hook 和 message transform hook。 + 为了两件简单行为去模拟整套 OpenCode runtime,不划算,也会让插件能修改 Kimi 的消息和工具执行流程。 +

+
+
+
+ +
+

实现设计

+

+ 第一版的关键词是“读得准、装得上、可关闭、可诊断”。代码运行能力只接声明式工具,不接任意 hook。 +

+ +
+
+

新增模块建议

+
packages/agent-core/src/plugin/
+├── types.ts              # 内部 PluginCandidate / PluginCapability
+├── paths.ts              # ~/.kimi-code/plugins 和项目插件目录
+├── detect.ts             # 识别 manifest 格式
+├── manifest-codex.ts     # .codex-plugin/plugin.json
+├── manifest-claude.ts    # .claude-plugin/plugin.json 子集
+├── manifest-kimi-cli.ts  # plugin.json tools
+├── manifest-opencode.ts  # 只读识别
+├── manager.ts            # list/install/remove/enable/reload
+├── skill-roots.ts        # 计算启用插件的 skills 目录
+├── tool-adapter.ts       # kimi-cli tool -> Kimi Tool
+└── bootstrap.ts          # Superpowers / manifest bootstrap
+
+ +
+

内部数据形状

+
type PluginFormat =
+  | 'kimi'
+  | 'codex'
+  | 'claude'
+  | 'kimi-cli'
+  | 'opencode'
+  | 'kilo'
+  | 'superpowers'
+  | 'unknown';
+
+interface PluginCandidate {
+  id: string;
+  name: string;
+  version?: string;
+  description?: string;
+  root: string;
+  source: 'installed' | 'local-path' | 'git' | 'zip' | 'npm';
+  formats: readonly PluginFormat[];
+  capabilities: PluginCapabilities;
+  diagnostics: readonly PluginDiagnostic[];
+}
+
+
+ +
+
+

配置建议

+

+ 放在 core config,而不是 TUI config。TUI config 只管界面偏好;插件会影响 agent 能力,应该由 + ~/.kimi-code/config.toml 和 core SDK 统一管理。 +

+
[plugins]
+dirs = ["~/.kimi-code/plugins", ".kimi-code/plugins"]
+experimental_code_plugins = false
+
+[plugins.enabled]
+superpowers = true
+
+ +
+

安装状态文件

+

+ 建议在 ~/.kimi-code/plugins/installed.json 存来源、版本、启停状态和校验信息。 + 插件正文可以 copy 到本地缓存,也可以保留 local path 引用;第一版本地路径安装可以先引用,远程安装再 copy。 +

+
{
+  "plugins": [
+    {
+      "id": "superpowers",
+      "root": "/Users/moonshot/code/superpowers",
+      "source": "local-path",
+      "enabled": true,
+      "formats": ["codex", "superpowers"]
+    }
+  ]
+}
+
+
+ +

关键接入点

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
代码区域要做什么为什么放这里
packages/agent-core/src/config/schema.ts增加 plugins config schema 和 patch schema。插件是运行能力,不是纯 UI 偏好。
packages/agent-core/src/rpc/core-impl.ts新增 plugin manager,并在创建 session 时把启用插件的 skill dirs 传给 extraSkillDirsSession 现在已经从 core RPC 汇总 skills 配置。
packages/agent-core/src/session/index.ts接收插件 bootstrap,并在新 session 的首轮上下文注入一次。Superpowers 的“已加载 using-superpowers”需要 session 级语义。
packages/agent-core/src/agent/tool/index.ts增加 plugin tool source,注册 plugin__name__toolkimi-cli 老插件工具需要进入模型可调用工具列表。
packages/node-sdk/src/rpc.ts暴露 listPluginsinstallPluginsetPluginEnabledremovePluginreloadPluginsTUI 和未来非 TUI 调用都走同一套 API。
apps/kimi-code/src/tui/commands/registry.ts增加 /plugins slash command。/mcp/settings 同级,符合用户预期。
+
+ +
+ 安全边界: + 默认只执行 Kimi 自己适配的声明式工具。OpenCode/Kilo/Claude hooks 不能默认执行;即使后续支持,也必须在 + experimental_code_plugins = true 之类的显式配置下打开。 +
+
+ +
+

/plugins 用户体验

+

+ `/plugins` 不是单纯列表。它要回答三个问题:装了什么、现在能用什么、为什么某些能力没启用。 +

+ +
+
+

列表页

+

+ 展示 name、version、来源、enabled、format、skills/tools 数量、诊断 badge。对 Superpowers 显示 + ReadyCodex manifest。 +

+
+
+

详情页

+

+ 分区显示 metadata、skills、tools、unsupported hooks、安装路径、来源、最近一次 reload 错误。 + 对不支持的 hooks 给明确原因,不只报“unsupported”。 +

+
+
+

安装入口

+

+ 第一版先支持 local path:/plugins install /Users/moonshot/code/superpowers。 + 后续再做 git/zip/npm/marketplace。 +

+
+
+ +
+ 建议命令集 +
+
/plugins
+/plugins install /Users/moonshot/code/superpowers
+/plugins info superpowers
+/plugins enable superpowers
+/plugins disable superpowers
+/plugins remove superpowers
+/plugins reload
+

+ 如果当前 TUI 不适合一次做完整交互界面,可以先用 slash command + dialog 组合实现;核心 API 稳定后再美化列表交互。 +

+
+
+ +
+ 界面诊断文案示例 +
+
superpowers 5.1.0
+Status: enabled
+Formats: codex, superpowers
+Skills: 12 loaded from /Users/moonshot/code/superpowers/skills
+Bootstrap: using-superpowers injected once per new session
+
+Unsupported:
+- .opencode/plugins/superpowers.js was detected but not executed.
+  Reason: OpenCode message/config hooks are not part of Kimi plugin runtime.
+
+
+
+ +
+

分阶段实现计划

+

+ 计划按“能被用户看到的行为”拆,不按内部模块堆任务。每个阶段都应该能独立 review。 +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
阶段目标主要改动验收方式
M1
插件模型和检测
能识别本地插件目录,输出统一的 plugin candidate。 + 新增 packages/agent-core/src/plugin;支持 Codex manifest、Claude manifest 子集、kimi-cli + plugin.json、OpenCode/Kilo 只读识别。 + + fixture 覆盖 6 类格式;路径逃逸、空 name、坏 JSON 都有诊断;不会执行任何插件代码。 +
M2
Superpowers 可用
从本地路径安装 Superpowers 后,新 session 能看到并使用它的 skills。 + 安装 local path;注册 skills/;读取 using-superpowers 注入一次;增加 tool mapping。 + + /plugins install /Users/moonshot/code/superpowers 后, + listSkills 出现 Superpowers skills;问 “Tell me about your superpowers” 有正确上下文。 +
M3
kimi-cli 工具
旧版 plugin.json 的工具能被 Kimi 调用。 + 转 JSON Schema;注册 plugin tools;stdin/stdout 适配;超时、cwd、env、输出大小限制;权限默认 ask。 + + 一个 Python 示例插件能接收 JSON 参数并返回结果;禁用插件后工具从新 session 消失。 +
M4
完整管理闭环
用户能安装、启停、删除、reload、查看诊断。 + SDK/RPC 补齐管理 API;TUI 详情页;状态文件;reload 后刷新 skills/tools。 + + `/plugins` 列表和详情能解释每个插件为什么 ready、partial 或 unsupported。 +
M5
Claude/Codex 扩展
更完整地导入 Claude/Codex 插件声明。 + Claude commands 到 Kimi slash commands 的映射评估;MCP 配置导入;hooks 配置只读展示升级为可确认导入。 + + 对 2-3 个真实 Claude 插件给出可用能力和不可用能力列表,不误报可执行。 +
M6
OpenCode/Kilo 实验
只在显式开启时运行非常小的安全子集。 + 先考虑只支持 config 追加 skill paths 和只读 message bootstrap;其他 hooks 保持禁用。 + + 默认关闭;打开后有清晰风险提示;失败不会影响普通 Kimi session。 +
M7
远程安装和市场
支持 git/zip/npm/marketplace。 + 下载、校验、缓存、更新、锁版本;借鉴 Claude marketplace 和 Codex remote plugin 的设计。 + + 可安装固定 tag;更新前能展示 diff/版本;失败可回滚。 +
+
+
+ +
+

验收标准和测试

+

+ 这类功能最怕“看起来装上了,但 session 里没生效”。测试要覆盖真实路径,不只测 parser。 +

+ +
+
+

第一版必须通过的验收

+
    +
  • /plugins install /Users/moonshot/code/superpowers 后列表显示 enabled、version、skills 数量。
  • +
  • Superpowers skills 进入 session.listSkills(),并能被 SkillTool 使用。
  • +
  • 新 session 只注入一次 using-superpowers bootstrap,重复 reload 不重复。
  • +
  • 检测到 OpenCode JS,但显示为未执行,并解释原因。
  • +
  • 禁用插件后,新 session 不再加载它的 skills 和 bootstrap。
  • +
+
+ +
+

测试文件建议

+
    +
  • packages/agent-core 现有测试附近增加 plugin parser/manager 测试,不要散落太多新文件。
  • +
  • Superpowers fixture 只保留最小结构:.codex-plugin/plugin.json + 两个 SKILL.md
  • +
  • kimi-cli fixture 覆盖 stdout 成功、非 0 退出、超时、坏 JSON 参数。
  • +
  • TUI command 先测 command resolution 和 RPC 调用,不强行测复杂渲染内部。
  • +
+
+
+ +
+ 建议最小手测脚本: + 安装 Superpowers 本地路径,启动新 session,运行 /plugins 查看状态,再问 + Tell me about your superpowers,最后 disable 并开启新 session 确认 skills 消失。 +
+
+ +
+

主要风险和处理方式

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
风险影响处理方式
第三方代码插件改写消息或工具参数用户很难知道模型到底收到了什么,也难以 debug。第一版不执行 OpenCode/Kilo/Claude hooks;后续必须显式开启并记录诊断。
插件工具和内置工具重名可能误调用高权限工具。插件工具统一加 plugin__ 前缀;详情页展示原始工具名。
manifest path 逃逸插件可能读取根目录外的文件作为技能或资源。沿用 Codex 的约束:相对路径必须 ./ 开头,resolve 后必须在 plugin root 内。
Superpowers bootstrap 重复注入token 膨胀,模型重复收到同一段强提示。注入时加内部标记;session/fork/compact 流程里检查是否已存在。
Claude manifest 字段太多容易把“能解析”误说成“能执行”。详情页按 capability 分组:loaded、recognized、unsupported;默认保守。
远程安装供应链风险自动拉取和执行远程内容会扩大攻击面。远程安装放 M7;第一版 local path;远程需要 pin 版本、缓存、校验和回滚。
+
+
+ +
+

待确认问题

+

+ 这些问题不阻塞 M1/M2,但会影响后续兼容面和用户配置形态。 +

+
+
+

安装策略

+

+ 本地路径安装是“引用原目录”还是“copy 到 ~/.kimi-code/plugins”?开发期引用更方便,发布版 copy 更稳定。 + 建议第一版支持引用,并在远程安装阶段引入 copy/cache。 +

+
+
+

Superpowers bootstrap 是否泛化

+

+ 可以先做成 Superpowers adapter。等出现第二个需要同类行为的插件,再把它提升为 manifest 字段,例如 + bootstrapSkill。 +

+
+
+

插件启用范围

+

+ 全局启用、项目启用、单 session 启用的优先级要明确。建议先全局 + 项目两级,项目覆盖全局。 +

+
+
+

Claude hooks 怎么处理

+

+ 第一版建议“识别但不执行”,详情页列出 hooks。未来如果支持,要先定义 Kimi 的 hook 输入输出,而不是照搬 Claude JSON。 +

+
+
+
+ +
+

调研来源

+

+ 以下是本计划直接引用的本地源码和文档位置。路径都来自 /Users/moonshot/code 下已下载仓库。 +

+
+
+ Kimi Code 当前实现
+ /Users/moonshot/code/kimi-code/packages/agent-core/src/skill/scanner.ts
+ /Users/moonshot/code/kimi-code/packages/agent-core/src/agent/tool/index.ts
+ /Users/moonshot/code/kimi-code/apps/kimi-code/src/tui/commands/registry.ts +
+
+ Superpowers
+ /Users/moonshot/code/superpowers/.codex-plugin/plugin.json
+ /Users/moonshot/code/superpowers/.opencode/plugins/superpowers.js
+ /Users/moonshot/code/superpowers/hooks/session-start
+ /Users/moonshot/code/superpowers/docs/README.opencode.md +
+
+ Codex plugins
+ /Users/moonshot/code/codex/codex-rs/core-plugins/src/manifest.rs
+ /Users/moonshot/code/codex/codex-rs/utils/plugins/src/plugin_namespace.rs +
+
+ Claude Code plugins
+ /Users/moonshot/code/claude-code/src/utils/plugins/schemas.ts
+ /Users/moonshot/code/claude-code/src/services/plugins/ +
+
+ kimi-cli Python plugins
+ /Users/moonshot/code/kimi-cli/docs/en/customization/plugins.md
+ /Users/moonshot/code/kimi-cli/src/kimi_cli/plugin/ +
+
+ OpenCode / Kilo
+ /Users/moonshot/code/opencode/packages/web/src/content/docs/plugins.mdx
+ /Users/moonshot/code/opencode/packages/plugin/src/index.ts
+ /Users/moonshot/code/opencode/packages/opencode/src/plugin/
+ /Users/moonshot/code/kilocode/packages/plugin/src/index.ts +
+
+
+ + + + +
+ + + + diff --git a/reports/plugins-research.html b/reports/plugins-research.html new file mode 100644 index 000000000..e1edb8ff9 --- /dev/null +++ b/reports/plugins-research.html @@ -0,0 +1,1519 @@ + + + + + + Kimi Code /plugins 功能调研报告 + + + +
+
+
+

Kimi Code · /plugins 功能调研

+

把插件做成“安装、管理、接入现有能力”的入口,而不是第一期就重写一个扩展平台

+

+ 结论:当前仓库已经有 slash 命令、Skills、MCP、hooks、SDK RPC 和旧 kimi-cli 插件迁移探测。推荐第一期让 + /plugins 管理本地插件包,并把插件包里的 SKILL.md、可执行工具、MCP 配置接到现有能力上; + marketplace、自动更新、分享和复杂 UI 扩展放到后续阶段。 +

+
+ 调研日期:2026-05-25 + 当前仓库:/Users/moonshot/code/kimi-code + 参考源码:kimi-cli / claude-code / codex / pi + 交付物:自包含 HTML +
+
+
+ +
+ + +
+
+

1. 先给结论

+
+ 推荐方向: + /plugins 第一版做成 TUI 里的插件管理页:看已安装插件、看能力清单、看错误、启用/禁用、卸载、从本地路径安装。 + 插件本身先是一个目录包,里面可以带 plugin.jsonSKILL.md、工具脚本和可选 MCP 配置。 +
+ +
+
+
先做本地插件
+

沿用 old kimi-cli 的 plugin.json 思路,并和当前 Kimi Code 的 Skills / MCP / tool 执行体系对接。

+
+
+
不要急marketplace
+

Claude 和 Codex 的 marketplace、分享、自动升级很完整,但会明显扩大实现和安全面,适合第二阶段以后。

+
+
+
最关键安全边界
+

插件工具不能绕开权限确认,远程安装必须显式触发,manifest 路径必须限制在插件目录内。

+
+
+ +

一句话拆解

+

+ 现在的 Kimi Code 已经有“能力被发现、展示、执行”的基础。缺的是一个稳定的插件包格式、安装位置、管理界面,以及把插件声明的能力接入 + Session 的 RPC。做 /plugins 时,不应该让 TUI 直接读 agent-core,而是走 + packages/node-sdk 暴露出的会话 API。 +

+ +

第一期成功标准

+
    +
  • 用户输入 /plugins 后,可以看到插件列表、插件来源、启用状态、能力摘要和加载错误。
  • +
  • 用户可以从本地目录安装一个插件,也可以禁用、启用、卸载已安装插件。
  • +
  • 插件里的 skill 可以出现在现有的 skill 列表和 slash 补全里。
  • +
  • 插件声明的工具如果被模型调用,走现有权限/确认机制,不能静默执行。
  • +
  • 旧 kimi-cli 的 ~/.kimi/plugins 至少有清楚的迁移提示,最好能做验证后迁移。
  • +
+
+ +
+

2. 当前仓库是什么

+

+ /Users/moonshot/code/kimi-code 是一个 TypeScript monorepo。核心产品是终端里的 Kimi Code CLI/TUI, + 底层由 SDK 和 agent-core 提供会话、skills、tools、MCP、权限、后台任务等能力。 +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
路径作用和 /plugins 的关系
apps/kimi-codeCLI / TUI 应用,用户直接交互的地方。新增 /plugins slash 命令、插件管理 UI、安装/卸载操作入口。
packages/node-sdk公开 TypeScript SDK 和 TUI 使用的 harness/session 包装。TUI 不直接依赖 agent-core,插件列表和操作应该通过这里暴露。
packages/agent-coreAgent、Session、skills、tools、MCP、权限、records 等核心能力。插件扫描、manifest 解析、工具注册、skill roots 扩展应主要落在这里。
packages/migration-legacy旧 kimi-cli 迁移探测和迁移材料。已经检测旧 ~/.kimi/plugins,当前显示为“not yet supported”。
docs/en/customizationSkills、MCP 等用户文档。插件上线后需要新增或更新用户文档,说明插件和 skill/MCP 的区别。
+
+ +
+ 边界提醒: + apps/kimi-code 的仓库级规则要求它通过 @moonshot-ai/kimi-code-sdk 消费核心能力,不直接依赖 + @moonshot-ai/agent-core。所以 /plugins 的 UI 可以在 app 里,但真正的插件扫描和执行不能只写在 TUI 里。 +
+
+ +
+

3. 当前已有可复用能力

+
+
+

Slash 命令

+

+ apps/kimi-code/src/tui/commands/registry.ts 存内置命令列表; + resolve.ts 先解析内置命令,再解析 skill 命令;未知 slash 会被当普通消息发送。 +

+

对 /plugins:新增 plugins 内置命令,再在 KimiTUI 的命令分发里打开管理界面。

+
+
+

Skills

+

+ packages/agent-core/src/skill/scanner.ts 负责扫描 .kimi-code/skills、 + .agents/skills、显式目录和 built-in 目录。 +

+

对 /plugins:插件目录可以作为额外 skill root,让插件内的 SKILL.md 自动进入 slash 补全和模型可见列表。

+
+
+

MCP

+

+ 当前已有 /mcp 状态面板和 /mcp-config 内置 skill,用户文档说明 MCP 配置来自用户和项目目录。 +

+

对 /plugins:插件可以后续声明 MCP server,但第一期可以只展示,不急着自动合并执行。

+
+
+

SDK RPC

+

+ packages/node-sdk/src/session.ts 已经有 listSkills()activateSkill() 这类会话 API。 +

+

对 /plugins:新增 listPlugins()installPlugin()setPluginEnabled()removePlugin() 比较自然。

+
+
+

旧迁移探测

+

+ packages/migration-legacy/src/detect.tspaths.ts 已经会找旧 + ~/.kimi/plugins。 +

+

对 /plugins:这里是兼容 old kimi-cli 插件的入口,不需要从零设计迁移发现。

+
+
+

现有 TUI 面板

+

+ tasks-browser.tshelp-panel.tsmcp-status-panel.ts 已经展示了搜索、选择、状态面板的写法。 +

+

对 /plugins:可以照着任务浏览器做一个插件浏览器,不必重做整套交互控件。

+
+
+ +
+ 当前发现的一个小坑:TUI 可能没有传递 --skills-dir +

+ apps/kimi-code/src/cli/commands.ts 定义了可重复的 --skills-dir <dir>; + run-prompt.ts 会把它传给 KimiHarness。但交互式 + run-shell.ts 当前构造 KimiHarness 时没有明显传入 skillDirs。 + 如果 /plugins 复用“额外目录”接入 skills,需要顺手覆盖这个路径,避免 TUI 和非交互模式行为不一致。 +

+
+
+ +
+

4. 参考实现对比

+

+ 下面四套参考实现的关注点不一样:old kimi-cli 最像第一期本地插件;Claude Code 是完整扩展平台; + Codex 是 marketplace 和 /plugins UI 的强参考;Pi 展示了 TypeScript 扩展系统的另一种边界。 +

+ +
+ + + + + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
项目用户入口插件包形态能扩展什么对 Kimi 的启发
kimi-cli Pythonkimi plugin install/list/remove/info目录或 zip/git,根目录 plugin.json,可带 SKILL.md可执行工具,启动时还把插件目录并入 skill roots。最适合第一期兼容;实现简单,工具用 stdin JSON、stdout 文本。
Claude Code/plugin、CLI plugin 命令、--plugin-dir/reload-pluginsmanifest + commands/agents/skills/hooks/outputStyles/MCP/LSP/userConfig/marketplace。几乎所有用户可见能力:slash、agents、skills、hooks、MCP、LSP、样式和配置。适合作为长期目标,但第一期照搬会过重;可借鉴“纯操作层 + TUI/CLI 包装”。
Codex/plugins TUI、app-server plugin RPC、配置里的 enable/disable。.codex-plugin/plugin.json,路径字段必须以 ./ 开头。skills、MCP servers、apps、hooks、marketplace、share/install/uninstall。最像目标 UI;可借鉴 feature gate、cache 清理、插件 ID 和路径校验。
Piskills 命令、--extension、自动发现、/reloadskills 是标准目录;extensions 是 TS 模块或带 pi 字段的 package。工具、命令、事件拦截、UI、prompt、provider、资源发现。提醒我们区分“插件包管理”和“运行时扩展 API”;热刷新和来源标记值得借鉴。
+
+
+ +
+

old kimi-cli Python:最贴近第一期

+
+ plugin.json + 本地安装 + 工具执行 + 会写入注入配置 +
+

+ 文档在 /Users/moonshot/code/kimi-cli/docs/en/customization/plugins.md。 + 源码集中在 src/kimi_cli/plugin/src/kimi_cli/cli/plugin.py。 +

+
    +
  • 插件是目录包,根目录必须有 plugin.json
  • +
  • plugin.json 主要字段是 nameversiondescriptionconfig_fileinjecttools
  • +
  • 工具命令运行在插件目录,stdin 是 JSON 参数,stdout 作为工具结果返回,非零退出和 stderr 会转成错误。
  • +
  • 安装支持本地目录、zip、远程 zip、git URL、repo 子目录和分支 URL。
  • +
  • 安装位置是旧的 ~/.kimi/plugins;Kimi Code 应该使用新的 ~/.kimi-code/plugins
  • +
  • 启动时会刷新插件配置,把 host 的 api_keybase_url 注入配置文件或运行时 env。
  • +
  • 插件目录也会被纳入 skill roots,因此一个插件可以同时带工具和 SKILL.md
  • +
+
+ 不建议照搬的点: + old kimi-cli 会把凭证写入插件配置文件。Kimi Code 第一版建议只做运行时环境变量注入,避免把密钥落盘到插件目录。 +
+
+ +
+

Claude Code:完整插件平台参考

+
+ /plugin UI + marketplace + skills/commands/hooks/MCP + 范围很大 +
+

+ 主要源码在 /Users/moonshot/code/claude-code/src/utils/plugins/、 + src/services/plugins/src/commands/plugin/。 +

+
    +
  • PluginManifestSchema 支持 commands、agents、skills、hooks、outputStyles、MCP、LSP、userConfig、channels、dependencies。
  • +
  • /plugin 打开交互设置页,包含发现、已安装、marketplaces、错误等视图;还有 /reload-plugins
  • +
  • CLI 与 TUI 复用 pluginOperations.ts,CLI 只负责命令行输出和退出码。
  • +
  • --plugin-dir 可以加载 session-only 插件,适合调试。
  • +
  • 插件缓存目录默认在 ~/.claude/plugins,带 marketplace 和版本分层。
  • +
  • userConfig 支持敏感值和模板替换,MCP/LSP/hooks/skills/agents 都可能引用配置。
  • +
+
+ 可借鉴: + 把安装、卸载、启用、读取 manifest 等逻辑放在纯操作层,让 CLI 和 TUI 共享;这样后续加非交互命令不会重复实现。 +
+
+ +
+

Codex:/plugins UI 和 marketplace 参考

+
+ /plugins + app-server RPC + enable/disable config + 功能开关 +
+

+ 主要源码在 /Users/moonshot/code/codex/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs、 + app-server/src/request_processors/plugins.rs、 + tui/src/chatwidget/plugins.rscore-plugins/src/manifest.rs。 +

+
    +
  • /plugins 是 TUI 弹窗,支持 All Plugins、Installed、marketplace tab、Add Marketplace、搜索、详情、安装、卸载。
  • +
  • 插件 API 走 app-server protocol:list/read/install/uninstall/share/marketplace add/remove/upgrade。
  • +
  • 安装或卸载后会清 plugin/skill cache,并触发已有 thread 的 MCP refresh。
  • +
  • 配置用 [plugins."plugin@marketplace"] enabled = true/false 记录启用状态。
  • +
  • 插件 ID 形如 <plugin>@<marketplace>,两个段只允许 ASCII 字母、数字、_-
  • +
  • manifest 路径字段必须以 ./ 开头,不能包含 ..,保证不会越过插件根目录。
  • +
+
+ 可借鉴: + 功能开关、错误态、搜索态、缓存刷新和路径校验。Kimi Code 不一定需要 app-server,但需要同样清晰的 SDK/core API 边界。 +
+
+ +
+

Pi:扩展 API 和热刷新参考

+
+ skills 标准 + TS extensions + /reload + 更像运行时扩展,不是插件商店 +
+

+ 主要源码在 /Users/moonshot/code/pi/packages/coding-agent/src/core/skills.ts、 + src/core/extensions/loader.tssrc/core/extensions/runner.ts 和 + docs/extensions.md。 +

+
    +
  • skills 兼容 Agent Skills 标准,扫描 ~/.pi/agent/skills.pi/skills.agents/skills、package 和 settings 指定目录。
  • +
  • extension 是 TypeScript 模块,可以注册工具、命令、快捷键、provider、事件处理、UI、消息渲染。
  • +
  • extension 自动发现目录是 ~/.pi/agent/extensions.pi/extensions,也可通过 --extension 临时加载。
  • +
  • /reload 会重新加载 keybindings、extensions、skills、prompts、themes。
  • +
  • 多个 extension 注册同名 slash command 时,Pi 会保留多个并加数字后缀;工具则 first registration wins。
  • +
+
+ 可借鉴: + 资源来源 sourceInfo 很重要;插件列表里要告诉用户“这个能力来自哪个插件、哪个文件”,不要只显示名字。 +
+
+
+ +
+

5. /plugins 应该解决什么

+

+ 对用户来说,/plugins 不应该只是列一个目录。它要回答四个问题:我装了什么、它能做什么、它有没有问题、我能怎么处理。 +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
需求第一期建议后续再做
查看插件显示 installed / disabled / error;展示 name、version、description、source path、能力数量。按 marketplace、作者、分类、更新时间过滤。
查看详情详情页显示 skills、tools、MCP、hooks、manifest 错误、安装路径。展示 screenshots、README、版本历史、更新日志。
安装从本地目录安装;可选支持本地 zip。远程 zip、git、GitHub marketplace、npm 包。
启用/禁用记录到 ~/.kimi-code/config.toml 或插件状态文件;当前 session 尽量刷新,否则明确提示重启或 /new按项目启用、workspace policy、管理员策略。
卸载删除安装目录前确认;禁用状态和错误记录同步清理。保留插件 data dir、版本回滚。
错误处理manifest 解析错误、路径越界、工具名冲突、缺少入口文件都展示清楚。自动修复建议、诊断导出。
迁移识别 old kimi-cli ~/.kimi/plugins,提供一键迁移或清楚提示。迁移后自动转换 manifest 格式和配置注入方式。
+
+ +

用户流程建议

+
+
+ 输入 /plugins + 打开插件浏览器。没有插件时展示安装入口和旧插件迁移提示。 +
+
+ 选择插件 + 看描述、路径、状态、skills、tools、MCP、错误。 +
+
+ 安装/启用 + 用户显式选择本地目录;解析 manifest;预览能力和风险。 +
+
+ 刷新能力 + 更新 skills、plugin tools、MCP cache;无法热刷新时提示重启。 +
+
+ 使用能力 + skill 进入 slash 补全;工具被模型调用时按权限流程执行。 +
+
+
+ +
+

6. 插件包形态

+

+ 最小可用的插件包应该容易手写,也能兼容 old kimi-cli 的目录插件。建议第一期固定根目录 + plugin.json,允许同时带 SKILL.md 和工具脚本。 +

+ +
+
+

推荐目录结构

+
+ +
my-plugin/
+├── plugin.json
+├── SKILL.md
+├── tools/
+│   └── search.js
+└── mcp.json       # 可选,建议第二期再自动接入
+
+
+
+

第一期 manifest 建议

+

字段尽量少,先保证安装、展示和工具执行能闭环。

+
+ name + version + description + tools + skills + mcpServers +
+
+
+ +

推荐 plugin.json 示例

+
+ +
{
+  "name": "repo-search",
+  "version": "0.1.0",
+  "description": "Search repository files and summarize matches.",
+  "skills": "./skills",
+  "tools": [
+    {
+      "name": "search",
+      "description": "Search files with ripgrep and return compact matches.",
+      "command": ["node", "./tools/search.js"],
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "query": {
+            "type": "string",
+            "description": "The ripgrep query."
+          }
+        },
+        "required": ["query"]
+      }
+    }
+  ]
+}
+
+ +

字段建议

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段是否第一期支持说明
name必须插件名。建议 lowercase + 数字 + -,避免路径和展示混乱。
version必须用于展示、未来升级和迁移判断。
description必须展示给用户,也可作为模型理解插件能力的简短说明。
skills可选推荐路径字段必须以 ./ 开头;也可以第一期默认扫描插件根目录里的 SKILL.md
tools可选但核心每个工具有 name、description、command、parameters。执行时 stdin JSON,stdout 文本。
mcpServers建议延后可以先解析和展示,不自动执行。后续再合并到 MCP 配置。
inject / config_file不建议第一期写配置old kimi-cli 有这个能力,但 Kimi Code 第一版不应默认把密钥写入插件目录。
+
+
+ +
+

7. 推荐实现方案

+

+ 实现上建议分三层:core 负责发现和执行,SDK 负责暴露会话 API,TUI 负责展示和用户操作。 +

+ +
+
+ TUI + /plugins 打开浏览器,调用 SDK,不直接读 core。 +
+
+ node-sdk + 新增 session 方法和 RPC 类型,转发 list/install/enable/remove。 +
+
+ agent-core + 解析 manifest、管理安装目录、注册 skills 和 tools。 +
+
+ runtime + 工具执行走权限确认、超时、stdout 限制和错误记录。 +
+
+ storage + ~/.kimi-code/plugins 保存安装包,config 记录启用状态。 +
+
+ +

建议新增/修改路径

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
路径改动原因
packages/agent-core/src/plugin/manifest.ts新增 manifest schema、路径校验、错误诊断。插件格式要有唯一入口,路径必须限制在插件根目录内。
packages/agent-core/src/plugin/manager.ts新增 list/install/remove/enable/disable,管理 ~/.kimi-code/plugins不要把文件系统逻辑散到 TUI。
packages/agent-core/src/plugin/tool.ts把 manifest tools 转成 agent-core tool,处理 stdin/stdout/timeout/权限。old kimi-cli 的可执行工具模型可以复用,但要接入当前权限体系。
packages/agent-core/src/skill/scanner.ts把已启用插件的 skill roots 加入扫描结果。插件里的 skill 才能进入 /skill:name 和短 slash。
packages/agent-core/src/session/rpc.ts新增 plugin RPC handler。让 SDK/TUI 可以管理插件。
packages/node-sdk/src/session.ts新增 listPlugins()installPlugin()setPluginEnabled()removePlugin()保持 app 只通过 SDK 使用 core 能力。
apps/kimi-code/src/tui/commands/registry.ts新增 plugins 内置 slash 命令。进入 /plugins 补全和帮助。
apps/kimi-code/src/tui/kimi-tui.ts新增 case "plugins"showPluginsBrowser()接入现有命令分发。
apps/kimi-code/src/tui/components/dialogs/plugins-browser.ts新增插件管理界面。展示列表、详情、安装、禁用、卸载、错误。
packages/migration-legacyapps/kimi-code/src/migration把旧 plugins 从“未支持”改成可迁移或可引导。用户从 old kimi-cli 升级时不会丢失插件。
+
+ +

工具命名建议

+
+ 推荐: + 插件工具在内部命名为 plugin__<plugin>__<tool>,展示时仍可以显示 <plugin> / <tool>。 + 这样不会和内置工具、MCP 工具、其他插件工具撞名。 +
+

+ old kimi-cli 遇到工具名冲突时直接跳过插件工具。这个做法简单,但用户很难发现为什么工具不可用。 + 新实现更应该在 /plugins 里明确显示“工具名冲突”或采用命名空间避免冲突。 +

+ +

安装目录和配置建议

+
    +
  • 安装目录:~/.kimi-code/plugins/<plugin-name>
  • +
  • 临时安装 staging:~/.kimi-code/plugins/.tmp-<random>,验证成功后原子替换。
  • +
  • 启用状态:优先写入现有 config,例如 [plugins."repo-search"] enabled = true
  • +
  • 插件私有数据:后续可以加 ~/.kimi-code/plugin-data/<plugin-name>,卸载时询问是否保留。
  • +
  • 远程 marketplace:第一期不建缓存层,第二期再考虑 plugins/cache/<source>/<name>/<version>
  • +
+
+ +
+

8. 分阶段落地

+
+ Phase 0:确认范围 + 先决定第一期是否只支持本地目录,以及是否要立刻兼容 old kimi-cli 工具执行。 +
+
+ Phase 1:只读 /plugins 浏览器 + 实现 manifest 解析、安装目录扫描、SDK listPlugins()、TUI 列表/详情/错误态。不先执行任何插件工具。 +
+
+ Phase 2:skill 接入 + 把已启用插件里的 SKILL.mdskills/ 加到 skill roots,保证 slash 补全和模型提示正常。 +
+
+ Phase 3:工具执行 + 支持 tools 字段,工具执行走权限确认、超时、stdout 限制和错误展示。 +
+
+ Phase 4:安装、禁用、卸载和迁移 + 支持本地目录安装、禁用/启用、卸载;把旧 ~/.kimi/plugins 纳入迁移。 +
+
+ Phase 5:远程来源和 marketplace + 在本地模型稳定后再做 git/zip/npm/marketplace、更新、分享、签名或 allowlist。 +
+ +

最小第一 PR 建议

+
    +
  1. 新增 packages/agent-core/src/plugin/manifest.ts,只支持本地已安装目录扫描和错误收集。
  2. +
  3. 新增 SDK/RPC listPlugins(),返回结构化列表,不做安装/执行。
  4. +
  5. 新增 /plugins 命令和只读 TUI 浏览器。
  6. +
  7. 补测试:manifest 成功/失败、路径越界、TUI slash 注册、空列表 UI。
  8. +
+

+ 这个 PR 小,能先把用户界面和数据模型定下来;后续再接 skill、tools、安装操作,不会一口气改太多核心路径。 +

+
+ +
+

9. 风险和安全边界

+
+ 核心原则: + 插件是用户安装的代码和提示词包。它既能影响模型行为,也可能执行本地命令,所以默认必须按“不可信输入”处理。 +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
风险具体症状建议防线
路径越界manifest 里写 ../secret、zip 解压覆盖外部文件。所有路径必须归一化并确认在插件根目录内;zip 解压逐项检查。
静默执行命令模型调用插件工具后直接跑本地脚本。插件工具走和 shell/MCP 类似的权限确认;显示插件名、工具名、参数摘要。
密钥落盘安装时把 API key 写进插件目录配置。第一期不要写密钥文件;只允许运行时 env 注入,且 UI 明确展示。
工具名冲突插件工具覆盖内置工具或 MCP 工具。内部命名空间化;冲突在 /plugins 里作为诊断展示。
远程安装投毒用户粘贴远程 URL,内容变化或来源伪装。第一期只本地路径;远程来源需要显式确认、校验、缓存和来源展示。
输出过大插件工具 stdout 很大,撑爆上下文或 UI。限制 stdout 字节数和行数;超出时截断并提示。
长时间卡住插件工具不退出。默认 timeout,例如 120 秒;支持 abort signal。
+
+ +

第一期明确不要做

+
    +
  • 不要支持自动安装远程 marketplace 插件。
  • +
  • 不要让插件声明任意 hook 并在启动时直接执行。
  • +
  • 不要默认把用户密钥写入插件配置文件。
  • +
  • 不要允许插件工具裸名覆盖内置工具。
  • +
  • 不要让 TUI 绕过 SDK 直接操作 core 内部对象。
  • +
+
+ +
+

10. 测试清单

+
+ +
+
+
+ manifest 解析 +

合法 manifest、缺少必填字段、未知字段、路径不是 ./、路径包含 ..

+
+
+ 安装流程 +

本地目录安装、重复安装、staging 失败回滚、卸载后状态清理。

+
+
+ skill 接入 +

已启用插件的 SKILL.md 被扫描;禁用后不出现;短 slash 不覆盖内置命令。

+
+
+ 工具执行 +

stdin JSON、stdout 返回、非零退出、stderr、timeout、env 注入、权限拒绝。

+
+
+ 命名冲突 +

插件工具与内置工具、MCP 工具、其他插件工具同名时可诊断或命名空间化。

+
+
+ SDK/RPC +

Session.listPlugins() 等 API 返回稳定结构;错误不会让会话崩掉。

+
+
+ TUI 浏览器 +

空列表、loading、error、search、详情、启用/禁用/卸载确认。

+
+
+ 旧插件迁移 +

检测 ~/.kimi/plugins;提示或迁移到 ~/.kimi-code/plugins;无效插件可跳过并报告。

+
+
+ 配置写入 +

启用状态写入后可重新读取;保留未知 config 字段;禁用插件不加载工具和 skills。

+
+
+ 文档 +

说明插件、skills、MCP 的区别;说明安全风险和安装来源;更新 slash command 文档。

+
+
+
+ +
+

11. 需要先拍板的问题

+
    +
  1. 第一期是否只支持本地插件?我建议是。远程 git/zip/marketplace 放第二期。
  2. +
  3. 是否兼容 old kimi-cli 的 plugin.json我建议兼容核心字段,尤其是 tools 和顶层 SKILL.md
  4. +
  5. 插件工具是否允许裸工具名?我建议内部强制命名空间,UI 里再展示友好名。
  6. +
  7. 安装后是否必须热刷新当前 session?最好支持,但可以第一期提示“新会话生效”;如果做工具执行,热刷新体验更重要。
  8. +
  9. MCP 是否第一期自动接入?我建议第一期只解析和展示,第二期再合并执行。
  10. +
  11. 启用状态是全局还是项目级?第一期全局更简单;项目级适合有 workspace policy 后再做。
  12. +
+
+ +
+

12. 调研来源

+

本报告基于本机源码和文档检索,不依赖远程网络资料。下面是主要证据路径。

+ +

Kimi Code 当前仓库

+
    +
  • /Users/moonshot/code/kimi-code/apps/kimi-code/AGENTS.md
  • +
  • /Users/moonshot/code/kimi-code/apps/kimi-code/src/tui/commands/registry.ts
  • +
  • /Users/moonshot/code/kimi-code/apps/kimi-code/src/tui/commands/resolve.ts
  • +
  • /Users/moonshot/code/kimi-code/apps/kimi-code/src/tui/commands/skills.ts
  • +
  • /Users/moonshot/code/kimi-code/apps/kimi-code/src/tui/kimi-tui.ts
  • +
  • /Users/moonshot/code/kimi-code/packages/node-sdk/src/session.ts
  • +
  • /Users/moonshot/code/kimi-code/packages/node-sdk/src/rpc.ts
  • +
  • /Users/moonshot/code/kimi-code/packages/agent-core/src/session/rpc.ts
  • +
  • /Users/moonshot/code/kimi-code/packages/agent-core/src/session/index.ts
  • +
  • /Users/moonshot/code/kimi-code/packages/agent-core/src/skill/scanner.ts
  • +
  • /Users/moonshot/code/kimi-code/packages/agent-core/src/skill/parser.ts
  • +
  • /Users/moonshot/code/kimi-code/packages/migration-legacy/src/detect.ts
  • +
  • /Users/moonshot/code/kimi-code/apps/kimi-code/src/migration/migration-screen.ts
  • +
  • /Users/moonshot/code/kimi-code/docs/en/customization/skills.md
  • +
  • /Users/moonshot/code/kimi-code/docs/en/customization/mcp.md
  • +
  • /Users/moonshot/code/kimi-code/docs/en/reference/slash-commands.md
  • +
+ +

参考实现

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
项目路径看点
kimi-cli Python/Users/moonshot/code/kimi-cli/docs/en/customization/plugins.md
/Users/moonshot/code/kimi-cli/src/kimi_cli/plugin/
/Users/moonshot/code/kimi-cli/src/kimi_cli/cli/plugin.py
旧插件格式、安装、工具执行、配置注入、skill roots。
Claude Code/Users/moonshot/code/claude-code/src/utils/plugins/
/Users/moonshot/code/claude-code/src/services/plugins/
/Users/moonshot/code/claude-code/src/commands/plugin/
完整插件平台、marketplace、UI、纯操作层、reload。
Codex/Users/moonshot/code/codex/codex-rs/tui/src/chatwidget/plugins.rs
/Users/moonshot/code/codex/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs
/Users/moonshot/code/codex/codex-rs/core-plugins/src/manifest.rs
/plugins UI、plugin RPC、manifest 路径安全、配置启用状态。
Pi/Users/moonshot/code/pi/packages/coding-agent/docs/skills.md
/Users/moonshot/code/pi/packages/coding-agent/docs/extensions.md
/Users/moonshot/code/pi/packages/coding-agent/src/core/extensions/loader.ts
Agent Skills 标准、TS extension、热刷新、动态工具和命令。
+
+ +

+ 注:本文里的“第一期/第二期”是实现建议,不代表已有产品承诺。真正开工前建议先用上面的“需要拍板的问题”确认范围。 +

+
+
+
+
+ + + +