feat: add plugin manager and official plugins (#119)

* feat: add plugin manager and official plugins

* fix(agent-core): honor plugin capability overrides

* fix: restrict plugin zip root detection

* Update apps/kimi-code/src/constant/app.ts

Co-authored-by: liruifengv <liruifeng1024@gmail.com>
Signed-off-by: qer <wbxl2000@outlook.com>

---------

Signed-off-by: qer <wbxl2000@outlook.com>
Co-authored-by: liruifengv <liruifeng1024@gmail.com>
This commit is contained in:
qer 2026-05-27 22:47:33 +08:00 committed by GitHub
parent 2c7a8cc010
commit ebf6e8181e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 7327 additions and 23 deletions

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

@ -0,0 +1,7 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code-sdk": minor
"@moonshot-ai/kimi-code": minor
---
Add user-global plugin installation, interactive plugin management, plugin-provided skills, and plugin-owned MCP servers.

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---
Restrict plugin zip installs to manifests at the archive root or a single wrapper directory.

3
.gitignore vendored
View file

@ -10,4 +10,5 @@ coverage/
.claude
.conductor
.kimi-stash-dir
superpowers/
plugins/cdn/
superpowers

View file

@ -150,6 +150,7 @@
"node_modules/",
"apps/*/scripts/",
"docs/smoke-archive/",
"plugins/curated/superpowers/",
"*.generated.ts"
]
}

View file

@ -53,7 +53,10 @@
"produce:native:manifest": "node scripts/native/produce-manifest.mjs",
"release:native:resolve": "node scripts/native/resolve-release.mjs",
"test:native:smoke": "node scripts/native/smoke.mjs",
"dev": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts",
"dev": "node scripts/dev.mjs",
"dev:cli-only": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts",
"dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs",
"build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs",
"dev:prod": "node dist/main.mjs",
"clean": "rm -rf dist",
"typecheck": "tsc -p tsconfig.json --noEmit",

View file

@ -0,0 +1,246 @@
#!/usr/bin/env node
import { createWriteStream } from 'node:fs';
import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import yazl from 'yazl';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(SCRIPT_DIR, '../../..');
const DEFAULT_PLUGINS_ROOT = resolve(REPO_ROOT, 'plugins');
const DEFAULT_OUT_DIR = resolve(DEFAULT_PLUGINS_ROOT, 'cdn');
const SENTINEL = '.kimi-plugin-marketplace-build.json';
const SKIP_DIRS = new Set(['.git', 'node_modules']);
const SKIP_FILES = new Set(['.DS_Store']);
const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) {
try {
const options = parseArgs(process.argv.slice(2));
const pluginsRoot = resolve(options.pluginsRoot ?? DEFAULT_PLUGINS_ROOT);
const outDir = resolve(options.outDir ?? DEFAULT_OUT_DIR);
await buildPluginMarketplaceCdn({ pluginsRoot, outDir });
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
}
}
export async function buildPluginMarketplaceCdn({ pluginsRoot, outDir }) {
assertSafeOutputDir(pluginsRoot, outDir);
await prepareOutputDir(outDir);
const marketplacePath = resolveInsideRoot(pluginsRoot, 'marketplace.json');
const raw = await readFile(marketplacePath, 'utf8');
const parsed = JSON.parse(raw);
if (!isRecord(parsed) || !Array.isArray(parsed.plugins)) {
throw new Error('plugins/marketplace.json must contain a "plugins" array.');
}
const archives = [];
const plugins = [];
for (const entry of parsed.plugins) {
if (!isRecord(entry) || typeof entry.source !== 'string') {
plugins.push(entry);
continue;
}
const result = await materializeEntrySource(entry.source, pluginsRoot, outDir);
plugins.push({ ...entry, source: result.source });
if (result.archive !== undefined) archives.push(result.archive);
}
const outputMarketplace = {
...parsed,
plugins,
};
await writeFile(
resolveInsideRoot(outDir, 'marketplace.json'),
JSON.stringify(outputMarketplace, null, 2) + '\n',
'utf8',
);
await writeFile(
resolveInsideRoot(outDir, SENTINEL),
JSON.stringify(
{
generatedBy: 'build-plugin-marketplace-cdn',
generatedAt: new Date().toISOString(),
pluginsRoot,
},
null,
2,
) + '\n',
'utf8',
);
process.stdout.write(`Plugin marketplace CDN artifacts written to ${outDir}\n`);
process.stdout.write(` marketplace.json\n`);
for (const archive of archives) {
process.stdout.write(` ${archive}\n`);
}
}
async function materializeEntrySource(source, pluginsRoot, outDir) {
if (!isLocalRelativeSource(source)) return { source };
const sourcePath = resolveInsideRoot(pluginsRoot, source);
const info = await stat(sourcePath).catch(() => undefined);
if (info === undefined) {
throw new Error(`Marketplace source does not exist: ${source}`);
}
if (info.isDirectory()) {
const zipSource = withZipExtension(source);
const zipRel = stripRelativePrefix(zipSource);
await zipDirectory(sourcePath, resolveInsideRoot(outDir, zipRel));
return { source: zipSource, archive: zipRel };
}
if (info.isFile() && extname(sourcePath) === '.zip') {
const zipRel = stripRelativePrefix(source);
await mkdir(dirname(resolveInsideRoot(outDir, zipRel)), { recursive: true });
await cp(sourcePath, resolveInsideRoot(outDir, zipRel));
return { source, archive: zipRel };
}
throw new Error(`Marketplace source must be a directory or .zip file: ${source}`);
}
async function zipDirectory(sourceRoot, outputFile) {
await mkdir(dirname(outputFile), { recursive: true });
const zipfile = new yazl.ZipFile();
const output = createWriteStream(outputFile);
const done = new Promise((resolveDone, rejectDone) => {
output.on('close', resolveDone);
output.on('error', rejectDone);
zipfile.outputStream.on('error', rejectDone);
});
zipfile.outputStream.pipe(output);
await addDirectoryToZip(zipfile, sourceRoot, basename(sourceRoot));
zipfile.end();
await done;
}
async function addDirectoryToZip(zipfile, root, zipRoot) {
const entries = await readdir(root, { withFileTypes: true });
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue;
if (entry.isFile() && SKIP_FILES.has(entry.name)) continue;
const absolutePath = resolve(root, entry.name);
const zipPath = `${zipRoot}/${relative(root, absolutePath).replaceAll(sep, '/')}`;
if (entry.isDirectory()) {
await addDirectoryToZip(zipfile, absolutePath, zipPath);
} else if (entry.isFile()) {
zipfile.addFile(absolutePath, zipPath);
}
}
}
async function prepareOutputDir(outDir) {
const existing = await stat(outDir).catch(() => undefined);
if (existing === undefined) {
await mkdir(outDir, { recursive: true });
return;
}
if (!existing.isDirectory()) {
throw new Error(`Output path exists and is not a directory: ${outDir}`);
}
const entries = await readdir(outDir);
if (entries.length > 0 && !entries.includes(SENTINEL)) {
throw new Error(
`Refusing to overwrite non-generated output directory: ${outDir}\n` +
`Choose an empty --out-dir or remove it manually.`,
);
}
await rm(outDir, { recursive: true, force: true });
await mkdir(outDir, { recursive: true });
}
function assertSafeOutputDir(pluginsRoot, outDir) {
if (outDir === pluginsRoot) {
throw new Error('Output directory must not be the plugins root.');
}
if (isWithin(pluginsRoot, outDir)) {
throw new Error('Output directory must not contain the plugins root.');
}
}
function resolveInsideRoot(root, input) {
const resolved = resolve(root, input);
if (!isWithin(resolved, root)) {
throw new Error(`Path escapes root: ${input}`);
}
return resolved;
}
function isWithin(candidate, root) {
const relativePath = relative(root, candidate);
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath));
}
function isLocalRelativeSource(source) {
const trimmed = source.trim();
return (
trimmed.length > 0 &&
!trimmed.startsWith('http://') &&
!trimmed.startsWith('https://') &&
!trimmed.startsWith('file://') &&
!trimmed.startsWith('/') &&
!trimmed.startsWith('~/') &&
trimmed !== '~'
);
}
function withZipExtension(source) {
const trimmed = source.trim().replace(/\/+$/, '');
return extname(trimmed) === '.zip' ? trimmed : `${trimmed}.zip`;
}
function stripRelativePrefix(source) {
return source.trim().replace(/^\.\//, '');
}
function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parseArgs(args) {
const out = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--plugins-root') {
out.pluginsRoot = requiredValue(args, ++i, arg);
continue;
}
if (arg === '--out-dir') {
out.outDir = requiredValue(args, ++i, arg);
continue;
}
if (arg === '-h' || arg === '--help') {
printHelp();
process.exit(0);
}
throw new Error(`Unknown argument: ${arg}`);
}
return out;
}
function requiredValue(args, index, flag) {
const value = args[index];
if (value === undefined || value.startsWith('--')) {
throw new Error(`${flag} requires a value.`);
}
return value;
}
function printHelp() {
process.stdout.write(`Usage: pnpm run build:plugin-marketplace [-- --out-dir <dir>]\n`);
process.stdout.write(`\n`);
process.stdout.write(`Build CDN-ready plugin marketplace artifacts.\n`);
process.stdout.write(`\n`);
process.stdout.write(`Options:\n`);
process.stdout.write(` --plugins-root <dir> Source plugins root. Default: ${DEFAULT_PLUGINS_ROOT}\n`);
process.stdout.write(` --out-dir <dir> Output directory. Default: ${DEFAULT_OUT_DIR}\n`);
}

View file

@ -0,0 +1,234 @@
#!/usr/bin/env node
import { createReadStream } from 'node:fs';
import { readdir, readFile, stat } from 'node:fs/promises';
import { createServer } from 'node:http';
import { basename, dirname, extname, relative, resolve, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import yazl from 'yazl';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(SCRIPT_DIR, '../../..');
const DEFAULT_PLUGINS_ROOT = resolve(REPO_ROOT, 'plugins');
export async function startPluginMarketplaceServer(options = {}) {
const pluginsRoot = resolve(
options.pluginsRoot ?? process.env.KIMI_CODE_PLUGIN_MARKETPLACE_DEV_ROOT ?? DEFAULT_PLUGINS_ROOT,
);
const host = options.host ?? process.env.KIMI_CODE_PLUGIN_MARKETPLACE_DEV_HOST ?? '127.0.0.1';
const port = Number(options.port ?? process.env.KIMI_CODE_PLUGIN_MARKETPLACE_DEV_PORT ?? 0);
const server = createServer((req, res) => {
void handleRequest(req, res, pluginsRoot);
});
await new Promise((resolveStarted, rejectStarted) => {
const onError = (error) => {
rejectStarted(error);
};
server.once('error', onError);
server.listen(port, host, () => {
server.off('error', onError);
resolveStarted();
});
});
const address = server.address();
if (address === null || typeof address === 'string') {
throw new Error('Plugin marketplace dev server did not bind to a TCP port.');
}
const marketplaceUrl = `http://${host}:${address.port}/marketplace.json`;
return {
server,
pluginsRoot,
marketplaceUrl,
close: () =>
new Promise((resolveClosed, rejectClosed) => {
server.close((error) => {
if (error !== undefined) rejectClosed(error);
else resolveClosed();
});
}),
};
}
async function handleRequest(req, res, pluginsRoot) {
const method = req.method ?? 'GET';
if (method !== 'GET' && method !== 'HEAD') {
res.writeHead(405, { Allow: 'GET, HEAD' });
res.end();
return;
}
let pathname;
try {
pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://localhost').pathname);
} catch {
res.writeHead(400);
res.end('Bad request');
return;
}
try {
if (pathname === '/' || pathname === '/marketplace.json') {
await serveMarketplaceJson(res, pluginsRoot, method === 'HEAD');
return;
}
if (pathname.endsWith('.zip')) {
await servePluginZip(res, pluginsRoot, pathname, method === 'HEAD');
return;
}
await serveStaticFile(res, pluginsRoot, pathname, method === 'HEAD');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(message);
}
}
async function serveMarketplaceJson(res, pluginsRoot, headOnly) {
const file = resolveInsideRoot(pluginsRoot, 'marketplace.json');
const raw = await readFile(file, 'utf8');
const body = Buffer.from(
JSON.stringify(await rewriteMarketplaceJson(raw, pluginsRoot), null, 2) + '\n',
);
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': String(body.byteLength),
});
res.end(headOnly ? undefined : body);
}
async function rewriteMarketplaceJson(raw, pluginsRoot) {
const parsed = JSON.parse(raw);
if (!isRecord(parsed) || !Array.isArray(parsed.plugins)) return parsed;
const plugins = await Promise.all(
parsed.plugins.map(async (entry) => {
if (!isRecord(entry) || typeof entry.source !== 'string') return entry;
if (!isLocalRelativeSource(entry.source)) return entry;
const sourcePath = resolveInsideRoot(pluginsRoot, entry.source);
if (!(await isDirectory(sourcePath))) return entry;
return { ...entry, source: withZipExtension(entry.source) };
}),
);
return { ...parsed, plugins };
}
async function servePluginZip(res, pluginsRoot, pathname, headOnly) {
const zipRel = pathname.replace(/^\/+/, '');
const sourceRel = zipRel.slice(0, -'.zip'.length);
const sourceRoot = resolveInsideRoot(pluginsRoot, sourceRel);
if (!(await isDirectory(sourceRoot))) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not found');
return;
}
res.writeHead(200, { 'Content-Type': 'application/zip' });
if (headOnly) {
res.end();
return;
}
const zipfile = new yazl.ZipFile();
zipfile.outputStream.on('error', (error) => {
res.destroy(error);
});
zipfile.outputStream.pipe(res);
await addDirectoryToZip(zipfile, sourceRoot, basename(sourceRoot));
zipfile.end();
}
async function serveStaticFile(res, pluginsRoot, pathname, headOnly) {
const file = resolveInsideRoot(pluginsRoot, pathname.replace(/^\/+/, ''));
const info = await stat(file).catch(() => undefined);
if (info === undefined || !info.isFile()) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not found');
return;
}
res.writeHead(200, {
'Content-Type': contentType(file),
'Content-Length': String(info.size),
});
if (headOnly) {
res.end();
return;
}
createReadStream(file).on('error', (error) => res.destroy(error)).pipe(res);
}
async function addDirectoryToZip(zipfile, root, zipRoot) {
const entries = await readdir(root, { withFileTypes: true });
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
const absolutePath = resolve(root, entry.name);
const zipPath = `${zipRoot}/${relative(root, absolutePath).replaceAll(sep, '/')}`;
if (entry.isDirectory()) {
await addDirectoryToZip(zipfile, absolutePath, zipPath);
} else if (entry.isFile()) {
zipfile.addFile(absolutePath, zipPath);
}
}
}
function resolveInsideRoot(root, input) {
const resolved = resolve(root, input);
const rootWithSep = root.endsWith(sep) ? root : root + sep;
if (resolved !== root && !resolved.startsWith(rootWithSep)) {
throw new Error(`Path escapes plugin marketplace root: ${input}`);
}
return resolved;
}
function isLocalRelativeSource(source) {
const trimmed = source.trim();
return (
trimmed.length > 0 &&
!trimmed.startsWith('http://') &&
!trimmed.startsWith('https://') &&
!trimmed.startsWith('file://') &&
!trimmed.startsWith('/') &&
!trimmed.startsWith('~/') &&
trimmed !== '~'
);
}
function withZipExtension(source) {
const trimmed = source.trim().replace(/\/+$/, '');
return extname(trimmed) === '.zip' ? trimmed : `${trimmed}.zip`;
}
async function isDirectory(path) {
return (await stat(path).catch(() => undefined))?.isDirectory() === true;
}
function contentType(path) {
switch (extname(path)) {
case '.json':
return 'application/json; charset=utf-8';
case '.mjs':
case '.js':
return 'text/javascript; charset=utf-8';
case '.md':
return 'text/markdown; charset=utf-8';
case '.txt':
return 'text/plain; charset=utf-8';
default:
return 'application/octet-stream';
}
}
function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) {
const started = await startPluginMarketplaceServer();
console.error(`Plugin marketplace dev server: ${started.marketplaceUrl}`);
console.error(`Serving: ${started.pluginsRoot}`);
}

View file

@ -0,0 +1,46 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { startPluginMarketplaceServer } from './dev-plugin-marketplace-server.mjs';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const APP_ROOT = resolve(SCRIPT_DIR, '..');
const MARKETPLACE_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL';
let marketplaceServer;
const env = { ...process.env };
if (env[MARKETPLACE_ENV] === undefined || env[MARKETPLACE_ENV]?.trim().length === 0) {
marketplaceServer = await startPluginMarketplaceServer();
env[MARKETPLACE_ENV] = marketplaceServer.marketplaceUrl;
console.error(`Plugin marketplace dev server: ${marketplaceServer.marketplaceUrl}`);
}
const tsxBin = process.platform === 'win32' ? 'tsx.cmd' : 'tsx';
const cliArgs = process.argv.slice(2);
if (cliArgs[0] === '--') cliArgs.shift();
const child = spawn(
tsxBin,
['--import', '../../build/register-raw-text-loader.mjs', './src/main.ts', ...cliArgs],
{
cwd: APP_ROOT,
env,
stdio: 'inherit',
},
);
child.on('error', async (error) => {
console.error(`Failed to start Kimi Code dev CLI: ${error.message}`);
await marketplaceServer?.close();
process.exit(1);
});
child.on('exit', async (code, signal) => {
await marketplaceServer?.close();
if (signal !== null) {
process.exit(1);
}
process.exit(code ?? 0);
});

View file

@ -9,11 +9,13 @@ import { registerExportCommand } from './sub/export';
export type MainCommandHandler = (opts: CLIOptions) => void;
export type MigrateCommandHandler = () => void;
export type PluginNodeRunnerHandler = (entry: string, args: readonly string[]) => void;
export function createProgram(
version: string,
onMain: MainCommandHandler,
onMigrate: MigrateCommandHandler,
onPluginNodeRunner: PluginNodeRunnerHandler = () => {},
): Command {
const program = new Command(CLI_COMMAND_NAME)
.description('The Starting Point for Next-Gen Agents')
@ -73,6 +75,15 @@ export function createProgram(
registerExportCommand(program);
registerMigrateCommand(program, onMigrate);
program
.command('__plugin_run_node', { hidden: true })
.argument('<entry>')
.argument('[args...]')
.allowUnknownOption(true)
.action((entry: string, args: string[]) => {
onPluginNodeRunner(entry, args);
});
program.action(() => {
const raw = program.opts<Record<string, unknown>>();

View file

@ -0,0 +1,26 @@
import { realpath } from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
export async function runPluginNodeEntry(entry: string, args: readonly string[]): Promise<void> {
const pluginRoot = process.env['KIMI_PLUGIN_ROOT'];
if (pluginRoot === undefined || pluginRoot.trim().length === 0) {
throw new Error('KIMI_PLUGIN_ROOT is required to run a plugin node entry.');
}
const [rootReal, entryReal] = await Promise.all([
realpath(pluginRoot),
realpath(entry),
]);
if (!isWithin(entryReal, rootReal)) {
throw new Error(`Plugin node entry must be inside KIMI_PLUGIN_ROOT: ${entry}`);
}
process.argv = [process.argv[0] ?? process.execPath, entryReal, ...args];
await import(pathToFileURL(entryReal).href);
}
function isWithin(candidate: string, root: string): boolean {
const relative = path.relative(root, candidate);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}

View file

@ -41,6 +41,8 @@ export const FEEDBACK_TELEMETRY_EVENT = 'feedback_submitted';
// CDN source of truth: all version checks and native install scripts pull from here.
export const KIMI_CODE_CDN_BASE = 'https://code.kimi.com/kimi-code';
export const KIMI_CODE_CDN_LATEST_URL = `${KIMI_CODE_CDN_BASE}/latest`;
export const KIMI_CODE_PLUGIN_MARKETPLACE_URL = `${KIMI_CODE_CDN_BASE}/plugins/marketplace.json`;
export const KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL';
export const KIMI_CODE_INSTALL_SH_URL = `${KIMI_CODE_CDN_BASE}/install.sh`;
export const KIMI_CODE_INSTALL_PS1_URL = `${KIMI_CODE_CDN_BASE}/install.ps1`;

View file

@ -19,6 +19,7 @@ import { OptionConflictError, validateOptions } from './cli/options';
import { runPrompt } from './cli/run-prompt';
import { runShell } from './cli/run-shell';
import { formatStartupError } from './cli/startup-error';
import { runPluginNodeEntry } from './cli/sub/plugin-run-node';
import { runUpdatePreflight } from './cli/update/preflight';
import { getVersion } from './cli/version';
import { cleanupStaleNativeCacheForCurrent } from './native/native-assets';
@ -111,6 +112,13 @@ export function main(): void {
process.exit(1);
});
},
(entry, args) => {
void runPluginNodeEntry(entry, args).catch(async (error: unknown) => {
await logStartupFailure('run plugin node entry', error);
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
});
},
);
program.parse(process.argv);

View file

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

View file

@ -19,6 +19,7 @@ import {
import chalk from 'chalk';
import type { ColorPalette } from '#/tui/theme/colors';
import { printableChar } from '#/tui/utils/printable-key';
import { SearchableList } from '#/tui/utils/searchable-list';
export interface ChoiceOption {
@ -33,6 +34,7 @@ export interface ChoiceOption {
export interface ChoicePickerOptions {
readonly title: string;
readonly hint?: string;
readonly notice?: string;
readonly options: readonly ChoiceOption[];
readonly currentValue?: string;
readonly colors: ColorPalette;
@ -102,7 +104,11 @@ export class ChoicePickerComponent extends Container implements Focusable {
this.list.pageDown();
return;
}
if (matchesKey(data, Key.enter)) {
if (
matchesKey(data, Key.enter) ||
matchesKey(data, Key.space) ||
printableChar(data) === ' '
) {
const chosen = this.list.selected();
if (chosen !== undefined) this.opts.onSelect(chosen.value);
return;
@ -131,6 +137,9 @@ export class ChoicePickerComponent extends Container implements Focusable {
lines.push(chalk.hex(colors.primary)(` Search: `) + chalk.hex(colors.text)(view.query));
}
lines.push(chalk.hex(colors.textMuted)(` ${hint}`));
if (this.opts.notice !== undefined) {
lines.push(chalk.hex(colors.success)(` ${this.opts.notice}`));
}
lines.push('');
if (options.length === 0) {

View file

@ -0,0 +1,603 @@
import {
Container,
Key,
matchesKey,
truncateToWidth,
visibleWidth,
type Focusable,
} from '@earendil-works/pi-tui';
import type { PluginInfo, PluginMcpServerInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk';
import chalk from 'chalk';
import type { ColorPalette } from '#/tui/theme/colors';
import { printableChar } from '#/tui/utils/printable-key';
import type { PluginMarketplaceEntry } from '#/utils/plugin-marketplace';
import { ChoicePickerComponent } from './choice-picker';
const OVERVIEW_MARKETPLACE = 'marketplace';
const OVERVIEW_RELOAD = 'reload';
const OVERVIEW_SHOW_LIST = 'show-list';
const OVERVIEW_PLUGIN_PREFIX = 'plugin:';
const MCP_SERVER_PREFIX = 'mcp:';
const REMOVE_CONFIRM_CANCEL = 'cancel';
const REMOVE_CONFIRM_REMOVE = 'remove';
const ELLIPSIS = '…';
interface PluginsOverviewItem {
readonly value: string;
readonly kind: 'plugin' | 'action';
readonly label: string;
readonly status?: string;
readonly description: string;
}
export type PluginsOverviewSelection =
| { readonly kind: 'marketplace' }
| { readonly kind: 'reload' }
| { readonly kind: 'show-list' }
| { readonly kind: 'toggle'; readonly id: string; readonly enabled: boolean }
| { readonly kind: 'mcp'; readonly id: string }
| { readonly kind: 'remove'; readonly id: string }
| { readonly kind: 'info'; readonly id: string };
export interface PluginsOverviewSelectorOptions {
readonly plugins: readonly PluginSummary[];
readonly selectedId?: string;
readonly pluginHint?: {
readonly id: string;
readonly text: string;
};
readonly colors: ColorPalette;
readonly onSelect: (selection: PluginsOverviewSelection) => void;
readonly onCancel: () => void;
}
export class PluginsOverviewSelectorComponent extends Container implements Focusable {
focused = false;
private readonly opts: PluginsOverviewSelectorOptions;
private readonly items: readonly PluginsOverviewItem[];
private selectedIndex = 0;
constructor(opts: PluginsOverviewSelectorOptions) {
super();
this.opts = opts;
this.items = buildOverviewItems(opts.plugins);
const selectedIndex = this.items.findIndex(
(item) => item.value === `${OVERVIEW_PLUGIN_PREFIX}${opts.selectedId}`,
);
this.selectedIndex = Math.max(0, selectedIndex);
}
handleInput(data: string): void {
if (matchesKey(data, Key.escape) || matchesKey(data, Key.left)) {
this.opts.onCancel();
return;
}
if (matchesKey(data, Key.up)) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
return;
}
if (matchesKey(data, Key.down)) {
this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
return;
}
const chosen = this.items[this.selectedIndex];
if (chosen === undefined) return;
const pluginId = overviewItemPluginId(chosen);
const decoded = printableChar(data);
if (matchesKey(data, Key.space) || decoded === ' ') {
if (pluginId === undefined) return;
const plugin = this.opts.plugins.find((item) => item.id === pluginId);
if (plugin !== undefined) {
this.opts.onSelect({ kind: 'toggle', id: pluginId, enabled: !plugin.enabled });
}
return;
}
if (decoded === 'd' || decoded === 'D') {
if (pluginId !== undefined) this.opts.onSelect({ kind: 'remove', id: pluginId });
return;
}
if (decoded === 'm' || decoded === 'M') {
if (pluginId === undefined) return;
const plugin = this.opts.plugins.find((item) => item.id === pluginId);
if (plugin !== undefined && plugin.mcpServerCount > 0) {
this.opts.onSelect({ kind: 'mcp', id: pluginId });
}
return;
}
if (matchesKey(data, Key.enter) || matchesKey(data, Key.right)) {
if (pluginId !== undefined) {
this.opts.onSelect({ kind: 'info', id: pluginId });
return;
}
const selection = parseOverviewSelection(chosen.value);
if (selection !== undefined) this.opts.onSelect(selection);
}
}
override render(width: number): string[] {
const { colors, plugins } = this.opts;
const hint =
'↑↓ navigate · Space enable/disable · M MCP · D remove · Enter/→ info · ←/Esc close';
const pluginItems = this.items.filter((item) => item.kind === 'plugin');
const actionItems = this.items.filter((item) => item.kind === 'action');
const lines: string[] = [
chalk.hex(colors.primary)('─'.repeat(width)),
chalk.hex(colors.primary).bold(' Plugins'),
chalk.hex(colors.textMuted)(` ${hint}`),
'',
sectionLabel(`Installed plugins (${plugins.length})`, colors),
];
if (pluginItems.length === 0) {
lines.push(chalk.hex(colors.textMuted)(' No plugins installed.'));
} else {
let absoluteIndex = 0;
for (const item of pluginItems) {
lines.push(...this.renderItem(item, absoluteIndex, width));
absoluteIndex++;
}
}
lines.push('');
lines.push(sectionLabel('Actions', colors));
for (let i = 0; i < actionItems.length; i++) {
lines.push(...this.renderItem(actionItems[i]!, pluginItems.length + i, width));
}
lines.push('');
lines.push(chalk.hex(colors.primary)('─'.repeat(width)));
return lines.map((line) => truncateToWidth(line, width, ELLIPSIS));
}
private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] {
const { colors } = this.opts;
const selected = index === this.selectedIndex;
const pointer = selected ? '' : ' ';
const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text);
const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `);
let line = prefix + labelStyle(item.label);
if (item.status !== undefined) {
line += ' ' + statusStyle(item, colors)(item.status);
}
const pluginId = overviewItemPluginId(item);
if (pluginId !== undefined && this.opts.pluginHint?.id === pluginId) {
line += ' ' + chalk.hex(colors.warning)(this.opts.pluginHint.text);
}
const descriptionWidth = Math.max(1, width - 4);
const lines = [line];
for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) {
lines.push(chalk.hex(colors.textMuted)(` ${descLine}`));
}
return lines;
}
}
export type PluginMarketplaceSelection =
| { readonly kind: 'install'; readonly entry: PluginMarketplaceEntry }
| { readonly kind: 'back' };
export interface PluginMarketplaceSelectorOptions {
readonly entries: readonly PluginMarketplaceEntry[];
readonly installedIds: ReadonlySet<string>;
readonly source: string;
readonly colors: ColorPalette;
readonly onSelect: (selection: PluginMarketplaceSelection) => void;
readonly onCancel: () => void;
}
export class PluginMarketplaceSelectorComponent extends Container implements Focusable {
focused = false;
private readonly opts: PluginMarketplaceSelectorOptions;
private readonly items: readonly PluginsOverviewItem[];
private selectedIndex = 0;
constructor(opts: PluginMarketplaceSelectorOptions) {
super();
this.opts = opts;
this.items = buildMarketplaceItems(opts.entries, opts.installedIds);
}
handleInput(data: string): void {
if (matchesKey(data, Key.escape) || matchesKey(data, Key.left)) {
this.opts.onCancel();
return;
}
if (matchesKey(data, Key.up)) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
return;
}
if (matchesKey(data, Key.down)) {
this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
return;
}
if (matchesKey(data, Key.enter) || matchesKey(data, Key.space) || printableChar(data) === ' ') {
const chosen = this.items[this.selectedIndex];
if (chosen === undefined) return;
if (chosen.value === 'back') {
this.opts.onSelect({ kind: 'back' });
return;
}
const entry = this.opts.entries.find((item) => item.id === chosen.value);
if (entry === undefined) return;
this.opts.onSelect({ kind: 'install', entry });
}
}
override render(width: number): string[] {
const { colors } = this.opts;
const entries = this.items.filter((item) => item.kind === 'plugin');
const actions = this.items.filter((item) => item.kind === 'action');
const lines: string[] = [
chalk.hex(colors.primary)('─'.repeat(width)),
chalk.hex(colors.primary).bold(' Official plugins'),
chalk.hex(colors.textMuted)(' ↑↓ navigate · Enter/Space install/update · ←/Esc back'),
chalk.hex(colors.textMuted)(` Source: ${this.opts.source}`),
'',
sectionLabel(`Marketplace (${entries.length})`, colors),
];
if (entries.length === 0) {
lines.push(chalk.hex(colors.textMuted)(' No marketplace plugins found.'));
} else {
for (let i = 0; i < entries.length; i++) {
lines.push(...this.renderItem(entries[i]!, i, width));
}
}
lines.push('');
lines.push(sectionLabel('Actions', colors));
for (let i = 0; i < actions.length; i++) {
lines.push(...this.renderItem(actions[i]!, entries.length + i, width));
}
lines.push('');
lines.push(chalk.hex(colors.primary)('─'.repeat(width)));
return lines.map((line) => truncateToWidth(line, width, ELLIPSIS));
}
private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] {
const { colors } = this.opts;
const selected = index === this.selectedIndex;
const pointer = selected ? '' : ' ';
const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text);
const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `);
let line = prefix + labelStyle(item.label);
if (item.status !== undefined) {
line += ' ' + statusStyle(item, colors)(item.status);
}
const descriptionWidth = Math.max(1, width - 4);
const lines = [line];
for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) {
lines.push(chalk.hex(colors.textMuted)(` ${descLine}`));
}
return lines;
}
}
export type PluginMcpSelection =
| { readonly kind: 'toggle'; readonly pluginId: string; readonly server: string; readonly enabled: boolean }
| { readonly kind: 'back'; readonly pluginId: string };
export interface PluginMcpSelectorOptions {
readonly info: PluginInfo;
readonly colors: ColorPalette;
readonly onSelect: (selection: PluginMcpSelection) => void;
readonly onCancel: () => void;
}
export class PluginMcpSelectorComponent extends Container implements Focusable {
focused = false;
private readonly opts: PluginMcpSelectorOptions;
private readonly items: readonly PluginsOverviewItem[];
private selectedIndex = 0;
constructor(opts: PluginMcpSelectorOptions) {
super();
this.opts = opts;
this.items = buildMcpItems(opts.info);
}
handleInput(data: string): void {
if (matchesKey(data, Key.escape) || matchesKey(data, Key.left)) {
this.opts.onCancel();
return;
}
if (matchesKey(data, Key.up)) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
return;
}
if (matchesKey(data, Key.down)) {
this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
return;
}
if (matchesKey(data, Key.enter) || matchesKey(data, Key.space) || printableChar(data) === ' ') {
const chosen = this.items[this.selectedIndex];
if (chosen === undefined) return;
if (chosen.value === 'back') {
this.opts.onSelect({ kind: 'back', pluginId: this.opts.info.id });
return;
}
const serverName = mcpItemServerName(chosen);
if (serverName === undefined) return;
const server = this.opts.info.mcpServers.find((item) => item.name === serverName);
if (server === undefined) return;
this.opts.onSelect({
kind: 'toggle',
pluginId: this.opts.info.id,
server: server.name,
enabled: !server.enabled,
});
}
}
override render(width: number): string[] {
const { colors, info } = this.opts;
const serverItems = this.items.filter((item) => item.kind === 'plugin');
const actionItems = this.items.filter((item) => item.kind === 'action');
const lines: string[] = [
chalk.hex(colors.primary)('─'.repeat(width)),
chalk.hex(colors.primary).bold(` MCP servers · ${info.displayName}`),
chalk.hex(colors.textMuted)(' ↑↓ navigate · Enter/Space enable/disable · ←/Esc back'),
'',
sectionLabel(`MCP servers (${info.enabledMcpServerCount}/${info.mcpServerCount} enabled)`, colors),
];
if (serverItems.length === 0) {
lines.push(chalk.hex(colors.textMuted)(' No MCP servers declared.'));
} else {
for (let i = 0; i < serverItems.length; i++) {
lines.push(...this.renderItem(serverItems[i]!, i, width));
}
}
lines.push('');
lines.push(sectionLabel('Actions', colors));
for (let i = 0; i < actionItems.length; i++) {
lines.push(...this.renderItem(actionItems[i]!, serverItems.length + i, width));
}
lines.push('');
lines.push(chalk.hex(colors.primary)('─'.repeat(width)));
return lines.map((line) => truncateToWidth(line, width, ELLIPSIS));
}
private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] {
const { colors } = this.opts;
const selected = index === this.selectedIndex;
const pointer = selected ? '' : ' ';
const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text);
const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `);
let line = prefix + labelStyle(item.label);
if (item.status !== undefined) {
line += ' ' + statusStyle(item, colors)(item.status);
}
const descriptionWidth = Math.max(1, width - 4);
const lines = [line];
for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) {
lines.push(chalk.hex(colors.textMuted)(` ${descLine}`));
}
return lines;
}
}
export type PluginRemoveConfirmResult =
| { readonly kind: 'confirm' }
| { readonly kind: 'cancel' };
export interface PluginRemoveConfirmOptions {
readonly id: string;
readonly displayName: string;
readonly colors: ColorPalette;
readonly onDone: (result: PluginRemoveConfirmResult) => void;
}
export class PluginRemoveConfirmComponent extends ChoicePickerComponent {
constructor(opts: PluginRemoveConfirmOptions) {
super({
title: `Remove ${opts.displayName} (${opts.id})?`,
hint: '↑↓ navigate · Enter/Space select · ←/Esc cancel',
options: [
{
value: REMOVE_CONFIRM_CANCEL,
label: 'Cancel',
description: 'Keep this plugin installed.',
},
{
value: REMOVE_CONFIRM_REMOVE,
label: 'Remove plugin',
description: 'Remove only the install record; plugin files are left in place.',
},
],
colors: opts.colors,
onSelect: (value) => {
opts.onDone(value === REMOVE_CONFIRM_REMOVE ? { kind: 'confirm' } : { kind: 'cancel' });
},
onCancel: () => {
opts.onDone({ kind: 'cancel' });
},
});
}
}
function buildOverviewItems(plugins: readonly PluginSummary[]): PluginsOverviewItem[] {
const options: PluginsOverviewItem[] = plugins.map((plugin) => ({
value: `${OVERVIEW_PLUGIN_PREFIX}${plugin.id}`,
kind: 'plugin',
label: plugin.displayName,
status: pluginStatus(plugin),
description: overviewPluginDescription(plugin),
}));
options.push(
{
value: OVERVIEW_MARKETPLACE,
kind: 'action',
label: 'Browse official marketplace',
description: 'Install official plugins from marketplace.json.',
},
{
value: OVERVIEW_RELOAD,
kind: 'action',
label: 'Reload plugins',
description: 'Re-read installed.json and plugin manifests.',
},
{
value: OVERVIEW_SHOW_LIST,
kind: 'action',
label: 'Show plugin summary',
description: 'Append the current plugin summary to the transcript.',
},
);
return options;
}
function overviewPluginDescription(plugin: PluginSummary): string {
const mcpShortcut = plugin.mcpServerCount > 0 ? ' · M MCP' : '';
const shortcut = `Space ${plugin.enabled ? 'disable' : 'enable'}${mcpShortcut} · D remove · Enter info`;
const state = plugin.state === 'ok' ? '' : ` · state ${plugin.state}`;
const skills = `${plugin.skillCount} skill${plugin.skillCount === 1 ? '' : 's'}`;
const mcp =
plugin.mcpServerCount > 0
? ` · MCP ${plugin.enabledMcpServerCount}/${plugin.mcpServerCount}`
: '';
const diagnostics = plugin.hasErrors ? ' · diagnostics available' : '';
return `${shortcut} · id ${plugin.id} · ${skills}${mcp}${state}${diagnostics}`;
}
function pluginStatus(plugin: PluginSummary): string {
if (plugin.state !== 'ok') return plugin.state;
return plugin.enabled ? 'enabled' : 'disabled';
}
function parseOverviewSelection(value: string): PluginsOverviewSelection | undefined {
if (value === OVERVIEW_MARKETPLACE) return { kind: 'marketplace' };
if (value === OVERVIEW_RELOAD) return { kind: 'reload' };
if (value === OVERVIEW_SHOW_LIST) return { kind: 'show-list' };
return undefined;
}
function overviewItemPluginId(item: PluginsOverviewItem): string | undefined {
if (!item.value.startsWith(OVERVIEW_PLUGIN_PREFIX)) return undefined;
return item.value.slice(OVERVIEW_PLUGIN_PREFIX.length);
}
function buildMarketplaceItems(
entries: readonly PluginMarketplaceEntry[],
installedIds: ReadonlySet<string>,
): PluginsOverviewItem[] {
const items: PluginsOverviewItem[] = entries.map((entry) => ({
value: entry.id,
kind: 'plugin',
label: entry.displayName,
status: installedIds.has(entry.id) ? 'installed' : installStatus(entry),
description: marketplaceEntryDescription(entry, installedIds.has(entry.id)),
}));
items.push({
value: 'back',
kind: 'action',
label: 'Back to installed plugins',
description: 'Return to the local plugin manager.',
});
return items;
}
function buildMcpItems(info: PluginInfo): PluginsOverviewItem[] {
const items: PluginsOverviewItem[] = info.mcpServers.map((server) => ({
value: `${MCP_SERVER_PREFIX}${server.name}`,
kind: 'plugin',
label: server.name,
status: server.enabled ? 'enabled' : 'disabled',
description: mcpServerDescription(server),
}));
items.push({
value: 'back',
kind: 'action',
label: 'Back to installed plugins',
description: 'Return to the local plugin manager.',
});
return items;
}
function mcpServerDescription(server: PluginMcpServerInfo): string {
const action = server.enabled ? 'Enter/Space disable' : 'Enter/Space enable';
if (server.transport === 'http') {
return `${action} · HTTP · ${server.url ?? server.runtimeName}`;
}
const args = server.args !== undefined && server.args.length > 0 ? ` ${server.args.join(' ')}` : '';
const command = `${server.command ?? ''}${args}`.trim();
const cwd = server.cwd === undefined ? '' : ` · cwd ${server.cwd}`;
return `${action} · stdio · ${command || server.runtimeName}${cwd}`;
}
function mcpItemServerName(item: PluginsOverviewItem): string | undefined {
if (!item.value.startsWith(MCP_SERVER_PREFIX)) return undefined;
return item.value.slice(MCP_SERVER_PREFIX.length);
}
function marketplaceEntryDescription(entry: PluginMarketplaceEntry, installed: boolean): string {
const action = installed ? 'Enter/Space update' : 'Enter/Space install';
const tier = marketplaceTierLabel(entry.tier);
const description = entry.description ?? tier;
const version = entry.version !== undefined ? ` · v${entry.version}` : '';
const keywords =
entry.keywords !== undefined && entry.keywords.length > 0
? ` · ${entry.keywords.join(', ')}`
: '';
const tierSuffix = entry.description !== undefined ? ` · ${tier}` : '';
return `${action} · ${description} · id ${entry.id}${version}${tierSuffix}${keywords}`;
}
function marketplaceTierLabel(tier: PluginMarketplaceEntry['tier']): string {
if (tier === 'official') return 'Official plugin';
if (tier === 'curated') return 'Curated plugin';
return 'Plugin';
}
function installStatus(entry: PluginMarketplaceEntry): string {
return entry.version === undefined ? 'install' : `install v${entry.version}`;
}
function sectionLabel(label: string, colors: ColorPalette): string {
return chalk.hex(colors.textDim).bold(` ${label}`);
}
function statusStyle(
item: PluginsOverviewItem,
colors: ColorPalette,
): (text: string) => string {
if (item.kind === 'action') return chalk.hex(colors.textDim);
if (item.status === 'enabled' || item.status === 'installed') return chalk.hex(colors.success);
if (item.status?.startsWith('install')) return chalk.hex(colors.primary);
if (item.status === 'disabled') return chalk.hex(colors.textDim);
if (item.status !== undefined && /^\d/.test(item.status)) return chalk.hex(colors.textDim);
return chalk.hex(colors.warning);
}
function wrapOverviewDescription(text: string, width: number): string[] {
const maxWidth = Math.max(1, width);
const words = text
.trim()
.split(/\s+/)
.filter((word) => word.length > 0);
const lines: string[] = [];
let current = '';
for (const word of words) {
const candidate = current.length === 0 ? word : `${current} ${word}`;
if (visibleWidth(candidate) <= maxWidth) {
current = candidate;
continue;
}
if (current.length > 0) lines.push(current);
current = visibleWidth(word) <= maxWidth ? word : truncateToWidth(word, maxWidth, ELLIPSIS);
}
if (current.length > 0) lines.push(current);
return lines;
}

View file

@ -0,0 +1,128 @@
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('Run /plugins to install one.'),
];
}
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}`);
}
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):`));
lines.push(muted(` Enabled by default; disable with /plugins mcp disable ${info.id} <server>.`));
for (const server of info.mcpServers) {
const enabled = server.enabled ? success('enabled') : muted('disabled');
lines.push(` ${muted('-')} ${value(server.name)} ${enabled} ${muted(`(${server.runtimeName})`)}`);
if (server.transport === 'stdio') {
const args = server.args !== undefined && server.args.length > 0 ? ` ${server.args.join(' ')}` : '';
lines.push(` ${muted('command:')} ${value(`${server.command ?? ''}${args}`.trim())}`);
if (server.cwd !== undefined) lines.push(` ${muted('cwd:')} ${value(server.cwd)}`);
if (server.envKeys !== undefined && server.envKeys.length > 0) {
lines.push(` ${muted('env:')} ${value(server.envKeys.join(', '))}`);
}
} else {
lines.push(` ${muted('url:')} ${value(server.url ?? '')}`);
if (server.headerKeys !== undefined && server.headerKeys.length > 0) {
lines.push(` ${muted('headers:')} ${value(server.headerKeys.join(', '))}`);
}
}
}
}
const iface = info.manifest?.interface;
if (iface !== undefined) {
lines.push('');
lines.push(value('Display:'));
if (iface.shortDescription !== undefined) lines.push(` ${muted('-')} ${value(iface.shortDescription)}`);
if (iface.developerName !== undefined) lines.push(` ${muted('-')} ${value(`by ${iface.developerName}`)}`);
if (iface.websiteURL !== undefined) lines.push(` ${muted('-')} ${value(iface.websiteURL)}`);
}
if (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.message)}`);
}
}
return lines;
}
function stateText(state: PluginInfo['state'], colors: ColorPalette): string {
if (state === 'ok') return chalk.hex(colors.success)(state);
return chalk.hex(colors.error)(state);
}

View file

@ -9,8 +9,8 @@
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 { homedir as osHomedir, release as osRelease, type as osType } from 'node:os';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import {
@ -73,6 +73,8 @@ import type {
ModelAlias,
McpServerInfo,
PermissionMode,
PluginInfo,
PluginSummary,
PromptOrigin,
PromptPart,
ResumedAgentState,
@ -109,6 +111,7 @@ import type { GitLsFilesCache } from '#/utils/git/git-ls-files';
import { createGitLsFilesCache } from '#/utils/git/git-ls-files';
import { appendInputHistory, loadInputHistory } from '#/utils/history/input-history';
import { parseImageMeta } from '#/utils/image/image-mime';
import { loadPluginMarketplace } from '#/utils/plugin-marketplace';
import { getInputHistoryFile } from '#/utils/paths';
import { editInExternalEditor, resolveEditorCommand } from '#/utils/process/external-editor';
import { detectFdPath } from '#/utils/process/fd-detect';
@ -151,6 +154,16 @@ import { ChoicePickerComponent, type ChoiceOption } from './components/dialogs/c
import { ModelSelectorComponent } from './components/dialogs/model-selector';
import { PlatformSelectorComponent } from './components/dialogs/platform-selector';
import { PermissionSelectorComponent } from './components/dialogs/permission-selector';
import {
PluginMcpSelectorComponent,
PluginMarketplaceSelectorComponent,
PluginRemoveConfirmComponent,
PluginsOverviewSelectorComponent,
type PluginMcpSelection,
type PluginMarketplaceSelection,
type PluginRemoveConfirmResult,
type PluginsOverviewSelection,
} from './components/dialogs/plugins-selector';
import { QuestionDialogComponent } from './components/dialogs/question-dialog';
import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker';
import { TaskOutputViewer } from './components/dialogs/task-output-viewer';
@ -175,6 +188,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 +1567,9 @@ export class KimiTUI {
case 'mcp':
void this.showMcpServers();
return;
case 'plugins':
void this.handlePluginsCommand(args);
return;
case 'editor':
await this.handleEditorCommand(args, {});
return;
@ -5196,6 +5216,121 @@ export class KimiTUI {
}
}
// Shows the plugin manager entry point.
private async showPluginsPicker(options?: {
readonly selectedId?: string;
readonly pluginHint?: {
readonly id: string;
readonly text: string;
};
}): Promise<void> {
let plugins: readonly PluginSummary[];
try {
plugins = await this.requireSession().listPlugins();
} catch (error) {
this.showError(`Failed to load plugins: ${formatErrorMessage(error)}`);
return;
}
this.mountEditorReplacement(
new PluginsOverviewSelectorComponent({
plugins,
selectedId: options?.selectedId,
pluginHint: options?.pluginHint,
colors: this.state.theme.colors,
onSelect: (selection) => {
this.restoreEditor();
void this.handlePluginsOverviewSelection(selection).catch((error: unknown) => {
this.showError(`/plugins failed: ${formatErrorMessage(error)}`);
});
},
onCancel: () => {
this.restoreEditor();
},
}),
);
}
// Loads official marketplace metadata and shows installable plugins.
private async showPluginMarketplacePicker(source?: string): Promise<void> {
try {
const [marketplace, installed] = await Promise.all([
loadPluginMarketplace({ workDir: this.state.appState.workDir, source }),
this.requireSession().listPlugins(),
]);
this.mountEditorReplacement(
new PluginMarketplaceSelectorComponent({
entries: marketplace.plugins,
installedIds: new Set(installed.map((plugin) => plugin.id)),
source: marketplace.source,
colors: this.state.theme.colors,
onSelect: (selection) => {
this.restoreEditor();
void this.handlePluginMarketplaceSelection(selection).catch((error: unknown) => {
this.showError(`/plugins marketplace failed: ${formatErrorMessage(error)}`);
});
},
onCancel: () => {
this.restoreEditor();
void this.showPluginsPicker();
},
}),
);
} catch (error) {
this.showError(`Failed to load plugin marketplace: ${formatErrorMessage(error)}`);
}
}
private async showPluginMcpPicker(id: string): Promise<void> {
let info: PluginInfo;
try {
info = await this.requireSession().getPluginInfo(id);
} catch (error) {
this.showError(`Failed to load plugin MCP servers: ${formatErrorMessage(error)}`);
return;
}
this.mountEditorReplacement(
new PluginMcpSelectorComponent({
info,
colors: this.state.theme.colors,
onSelect: (selection) => {
this.restoreEditor();
void this.handlePluginMcpSelection(selection).catch((error: unknown) => {
this.showError(`/plugins mcp failed: ${formatErrorMessage(error)}`);
});
},
onCancel: () => {
this.restoreEditor();
void this.showPluginsPicker({ selectedId: id });
},
}),
);
}
private async confirmRemovePlugin(id: string): Promise<boolean> {
let displayName = id;
try {
displayName = (await this.requireSession().getPluginInfo(id)).displayName;
} catch {
// Keep the confirmation available even when plugin details cannot be loaded.
}
return new Promise((resolveConfirmed) => {
this.mountEditorReplacement(
new PluginRemoveConfirmComponent({
id,
displayName,
colors: this.state.theme.colors,
onDone: (result: PluginRemoveConfirmResult) => {
this.restoreEditor();
resolveConfirmed(result.kind === 'confirm');
},
}),
);
});
}
// Applies a permission mode choice to the active session and app state.
private async applyPermissionChoice(mode: PermissionMode): Promise<void> {
if (mode === this.state.appState.permissionMode) {
@ -5295,6 +5430,240 @@ export class KimiTUI {
this.state.ui.requestRender();
}
private async handlePluginsCommand(rawArgs: string): Promise<void> {
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) {
await this.showPluginsPicker();
return;
}
if (sub === 'list') {
await this.renderPluginsList();
return;
}
if (sub === 'install') {
const source = rest.join(' ').trim();
if (source.length === 0) {
this.showError('Usage: /plugins install <local-path-or-zip-url>');
return;
}
await this.installPluginFromSource(source);
return;
}
if (sub === 'marketplace') {
await this.showPluginMarketplacePicker(rest.join(' ').trim() || undefined);
return;
}
if (sub === 'info') {
const id = rest[0];
if (id === undefined) {
await this.showPluginsPicker();
return;
}
await this.renderPluginInfo(id);
return;
}
if (sub === 'mcp') {
const action = rest[0];
const id = rest[1];
const server = rest[2];
if ((action !== 'enable' && action !== 'disable') || id === undefined || server === undefined) {
this.showError('Usage: /plugins mcp enable|disable <id> <server>');
return;
}
await session.setPluginMcpServerEnabled(id, server, action === 'enable');
this.showStatus(
`${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /new to apply.`,
);
return;
}
if (sub === 'enable' || sub === 'disable') {
const id = rest[0];
if (id === undefined) {
await this.showPluginsPicker();
return;
}
await this.applyPluginEnabled(id, sub === 'enable');
return;
}
if (sub === 'remove') {
const id = rest[0];
if (id === undefined) {
this.showError('Usage: /plugins remove <id>');
return;
}
if (!(await this.confirmRemovePlugin(id))) {
this.showStatus(`Remove cancelled: ${id}.`);
return;
}
await session.removePlugin(id);
this.showStatus(`Removed ${id} (plugin files left in place).`);
return;
}
if (sub === 'reload') {
await this.reloadPlugins();
return;
}
const plugins = await session.listPlugins();
if (plugins.some((plugin) => plugin.id === sub)) {
await this.renderPluginInfo(sub);
return;
}
this.showError(`Unknown /plugins action: ${sub}. Run /plugins to choose interactively.`);
} catch (error) {
this.showError(`/plugins ${sub ?? ''} failed: ${formatErrorMessage(error)}`);
}
}
// Toggles a plugin's enabled state and surfaces the MCP opt-in hint when relevant.
private async applyPluginEnabled(id: string, enabled: boolean): Promise<void> {
const session = this.requireSession();
await session.setPluginEnabled(id, enabled);
let info: PluginInfo | undefined;
try {
info = await session.getPluginInfo(id);
} catch {
info = undefined;
}
const mcpHint =
enabled && info !== undefined && info.mcpServerCount > info.enabledMcpServerCount
? ` Some MCP servers are disabled; re-enable with /plugins mcp enable ${id} <server>.`
: '';
this.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /new to apply.${mcpHint}`);
}
private async handlePluginsOverviewSelection(
selection: PluginsOverviewSelection,
): Promise<void> {
const session = this.requireSession();
switch (selection.kind) {
case 'marketplace':
await this.showPluginMarketplacePicker();
return;
case 'reload':
await this.reloadPlugins();
await this.showPluginsPicker();
return;
case 'show-list':
await this.renderPluginsList();
return;
case 'toggle':
await this.applyPluginEnabled(selection.id, selection.enabled);
await this.showPluginsPicker({
selectedId: selection.id,
pluginHint: { id: selection.id, text: 'saved · /new to apply' },
});
return;
case 'mcp':
await this.showPluginMcpPicker(selection.id);
return;
case 'remove':
if (!(await this.confirmRemovePlugin(selection.id))) {
this.showStatus(`Remove cancelled: ${selection.id}.`);
await this.showPluginsPicker({ selectedId: selection.id });
return;
}
await session.removePlugin(selection.id);
this.showStatus(`Removed ${selection.id} (plugin files left in place).`);
await this.showPluginsPicker();
return;
case 'info':
await this.renderPluginInfo(selection.id);
return;
}
}
private async handlePluginMcpSelection(selection: PluginMcpSelection): Promise<void> {
switch (selection.kind) {
case 'toggle':
await this.requireSession().setPluginMcpServerEnabled(
selection.pluginId,
selection.server,
selection.enabled,
);
this.showStatus(
`${selection.enabled ? 'Enabled' : 'Disabled'} MCP server ${selection.server} for ${selection.pluginId}. Run /new to apply.`,
);
await this.showPluginMcpPicker(selection.pluginId);
return;
case 'back':
await this.showPluginsPicker({ selectedId: selection.pluginId });
return;
}
}
private async handlePluginMarketplaceSelection(
selection: PluginMarketplaceSelection,
): Promise<void> {
switch (selection.kind) {
case 'install':
this.showStatus(`Installing or updating ${selection.entry.displayName} from marketplace...`);
await this.installPluginFromSource(selection.entry.source, {
successNotice: 'marketplace',
});
await this.showPluginsPicker({ selectedId: selection.entry.id });
return;
case 'back':
await this.showPluginsPicker();
return;
}
}
private async renderPluginsList(plugins?: readonly PluginSummary[]): Promise<void> {
const currentPlugins = plugins ?? (await this.requireSession().listPlugins());
const lines = buildPluginsListLines({
colors: this.state.theme.colors,
plugins: currentPlugins,
});
const title = ` Plugins (${currentPlugins.length}) `;
const panel = new UsagePanelComponent(lines, this.state.theme.colors.primary, title);
this.state.transcriptContainer.addChild(panel);
this.state.ui.requestRender();
}
private async renderPluginInfo(id: string): Promise<void> {
const info = await this.requireSession().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();
}
private async installPluginFromSource(
source: string,
options?: { readonly successNotice?: 'marketplace' },
): Promise<void> {
const summary = await this.requireSession().installPlugin(
resolvePluginInstallSource(source, this.state.appState.workDir),
);
const serverWord = summary.mcpServerCount === 1 ? 'server' : 'servers';
const mcpHint =
summary.mcpServerCount > 0
? ` Declares ${summary.mcpServerCount} MCP ${serverWord}; enabled by default and configurable from /plugins.`
: '';
const installVerb = options?.successNotice === 'marketplace' ? 'Installed or updated' : 'Installed';
this.showStatus(
`${installVerb} ${summary.displayName} (${summary.id}).${mcpHint} Run /new to apply plugin changes.`,
);
if (options?.successNotice === 'marketplace') {
this.showNotice(
`Installed or updated ${summary.displayName}`,
`Marketplace install or update succeeded for ${summary.id}. Run /new to apply plugin changes.`,
);
}
}
private async reloadPlugins(): Promise<void> {
const summary = await this.requireSession().reloadPlugins();
const line = `Reload: +${summary.added.length} -${summary.removed.length}` +
(summary.errors.length > 0 ? ` (${summary.errors.length} errors)` : '');
this.showStatus(line);
}
// Loads and renders current MCP server status.
private async showMcpServers(): Promise<void> {
let servers: readonly McpServerInfo[];
@ -6253,3 +6622,11 @@ 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);
}

View file

@ -0,0 +1,213 @@
import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
KIMI_CODE_PLUGIN_MARKETPLACE_URL,
KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV,
} from '#/constant/app';
export const PLUGIN_MARKETPLACE_TIERS = ['official', 'curated'] as const;
export type PluginMarketplaceTier = (typeof PLUGIN_MARKETPLACE_TIERS)[number];
export interface PluginMarketplaceEntry {
readonly id: string;
readonly displayName: string;
readonly source: string;
readonly tier?: PluginMarketplaceTier;
readonly version?: string;
readonly description?: string;
readonly homepage?: string;
readonly keywords?: readonly string[];
}
export interface PluginMarketplace {
readonly source: string;
readonly version?: string;
readonly plugins: readonly PluginMarketplaceEntry[];
}
interface MarketplaceLocation {
readonly raw: string;
readonly kind: 'remote' | 'local';
readonly resolved: string;
}
export interface LoadPluginMarketplaceOptions {
readonly workDir: string;
readonly source?: string;
readonly fetchImpl?: typeof fetch;
}
export async function loadPluginMarketplace(
options: LoadPluginMarketplaceOptions,
): Promise<PluginMarketplace> {
const location = resolveMarketplaceLocation(
options.source ?? process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV] ?? KIMI_CODE_PLUGIN_MARKETPLACE_URL,
options.workDir,
);
const raw = await readMarketplaceText(location, options.fetchImpl ?? fetch);
return parsePluginMarketplace(raw, location);
}
export function parsePluginMarketplace(raw: string, location: MarketplaceLocation): PluginMarketplace {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(`Plugin marketplace is not valid JSON: ${formatParseError(error)}`, {
cause: error,
});
}
if (!isRecord(parsed)) {
throw new TypeError('Plugin marketplace must be an object.');
}
const rawPlugins = parsed['plugins'];
if (!Array.isArray(rawPlugins)) {
throw new TypeError('Plugin marketplace must contain a "plugins" array.');
}
return {
source: location.resolved,
version: stringField(parsed, 'version'),
plugins: rawPlugins.map((entry, index) => parseMarketplaceEntry(entry, index, location)),
};
}
function resolveMarketplaceLocation(source: string, workDir: string): MarketplaceLocation {
const trimmed = source.trim();
if (trimmed.length === 0) {
throw new Error(`${KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV} cannot be empty.`);
}
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return { raw: trimmed, kind: 'remote', resolved: trimmed };
}
if (trimmed.startsWith('file://')) {
const path = fileURLToPath(trimmed);
return { raw: trimmed, kind: 'local', resolved: path };
}
return { raw: trimmed, kind: 'local', resolved: resolveLocalPath(trimmed, workDir) };
}
async function readMarketplaceText(
location: MarketplaceLocation,
fetchImpl: typeof fetch,
): Promise<string> {
if (location.kind === 'local') {
return readFile(location.resolved, 'utf8');
}
const response = await fetchImpl(location.resolved);
if (!response.ok) {
throw new Error(`Plugin marketplace returned HTTP ${response.status}`);
}
return response.text();
}
function parseMarketplaceEntry(
value: unknown,
index: number,
location: MarketplaceLocation,
): PluginMarketplaceEntry {
if (!isRecord(value)) {
throw new TypeError(`Plugin marketplace entry ${index + 1} must be an object.`);
}
const id = requiredString(value, 'id', index);
const source = stringField(value, 'source') ??
stringField(value, 'url') ??
stringField(value, 'downloadUrl');
if (source === undefined) {
throw new Error(`Plugin marketplace entry ${id} must define "source".`);
}
return {
id,
displayName: stringField(value, 'displayName') ?? stringField(value, 'name') ?? id,
source: resolveEntrySource(source, location),
tier: parseMarketplaceTier(value, id),
version: stringField(value, 'version'),
description: stringField(value, 'description') ?? stringField(value, 'shortDescription'),
homepage: stringField(value, 'homepage') ?? stringField(value, 'websiteURL'),
keywords: stringArrayField(value, 'keywords'),
};
}
function parseMarketplaceTier(
value: Record<string, unknown>,
id: string,
): PluginMarketplaceTier | undefined {
const raw = value['tier'];
if (raw === undefined) return undefined;
if (typeof raw !== 'string') {
throw new TypeError(`Plugin marketplace entry ${id} "tier" must be a string.`);
}
const tier = raw.trim();
if (tier.length === 0) return undefined;
if ((PLUGIN_MARKETPLACE_TIERS as readonly string[]).includes(tier)) {
return tier as PluginMarketplaceTier;
}
throw new Error(
`Plugin marketplace entry ${id} "tier" must be one of: ${PLUGIN_MARKETPLACE_TIERS.join(', ')}.`,
);
}
function resolveEntrySource(source: string, location: MarketplaceLocation): string {
const trimmed = source.trim();
if (
trimmed.startsWith('http://') ||
trimmed.startsWith('https://') ||
trimmed.startsWith('~/') ||
trimmed === '~' ||
isAbsolute(trimmed)
) {
return trimmed;
}
if (trimmed.startsWith('file://')) return fileURLToPath(trimmed);
if (location.kind === 'remote') {
return new URL(trimmed, location.resolved).toString();
}
return resolve(dirname(location.resolved), trimmed);
}
function resolveLocalPath(input: string, workDir: string): string {
if (input === '~') return homedir();
if (input.startsWith('~/')) return join(homedir(), input.slice(2));
return isAbsolute(input) ? input : resolve(workDir, input);
}
function requiredString(value: Record<string, unknown>, field: string, index: number): string {
const result = stringField(value, field);
if (result === undefined) {
throw new Error(`Plugin marketplace entry ${index + 1} must define "${field}".`);
}
return result;
}
function stringField(value: Record<string, unknown>, field: string): string | undefined {
const raw = value[field];
if (typeof raw !== 'string') return undefined;
const trimmed = raw.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function stringArrayField(
value: Record<string, unknown>,
field: string,
): readonly string[] | undefined {
const raw = value[field];
if (!Array.isArray(raw)) return undefined;
const out = raw
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter((item) => item.length > 0);
return out.length > 0 ? out : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function formatParseError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -74,6 +74,41 @@ describe('CLI options parsing', () => {
});
});
describe('hidden plugin node runner', () => {
it('routes __plugin_run_node without calling the main action', () => {
const pluginRunnerCalls: Array<{ entry: string; args: readonly string[] }> = [];
const program = createProgram(
'0.0.0',
() => {
throw new Error('main action should not run');
},
() => {},
(entry, args) => {
pluginRunnerCalls.push({ entry, args });
},
);
program.exitOverride();
program.configureOutput({
writeOut: () => {},
writeErr: () => {},
});
program.parse([
'node',
'kimi',
'__plugin_run_node',
'/plugin/tool.mjs',
'--',
'query',
'--flag',
]);
expect(pluginRunnerCalls).toEqual([
{ entry: '/plugin/tool.mjs', args: ['query', '--flag'] },
]);
});
});
describe('--yolo family', () => {
it('--yolo sets yolo to true', () => {
expect(parse(['--yolo']).yolo).toBe(true);
@ -223,7 +258,9 @@ describe('CLI options parsing', () => {
describe('sub-commands', () => {
it('registers the diagnostic sub-commands during alpha', () => {
const program = createProgram('0.0.0', () => {}, () => {});
const commandNames: string[] = program.commands.map((command) => command.name());
const commandNames: string[] = program.commands
.filter((command) => !command.name().startsWith('__'))
.map((command) => command.name());
expect(commandNames).toEqual(['export', 'migrate']);
});
});

View file

@ -0,0 +1,314 @@
import { describe, expect, it, vi } from 'vitest';
import {
PluginMcpSelectorComponent,
PluginMarketplaceSelectorComponent,
PluginRemoveConfirmComponent,
PluginsOverviewSelectorComponent,
type PluginMcpSelection,
type PluginRemoveConfirmResult,
} from '#/tui/components/dialogs/plugins-selector';
import { darkColors } from '#/tui/theme/colors';
const ANSI_SGR = /\[[0-9;]*m/g;
const MID = '\u00B7';
function strip(text: string): string {
return text.replaceAll(ANSI_SGR, '').replaceAll('\u276F', '?');
}
describe('plugins selector dialogs', () => {
it('renders installed plugins as selectable overview entries', () => {
const onSelect = vi.fn();
const picker = new PluginsOverviewSelectorComponent({
plugins: [
{
id: 'kimi-datasource',
displayName: 'Kimi Datasource',
version: '1.0.0',
enabled: true,
state: 'ok',
skillCount: 2,
mcpServerCount: 1,
enabledMcpServerCount: 1,
hasErrors: false,
},
],
colors: darkColors,
onSelect,
onCancel: vi.fn(),
});
const out = picker.render(120).map(strip).join('\n');
expect(out).toContain('Installed plugins (1)');
expect(out).toContain('Actions');
expect(out).toContain('? Kimi Datasource enabled');
expect(out).toContain(
`Space disable ${MID} M MCP ${MID} D remove ${MID} Enter info ${MID} id kimi-datasource ${MID} 2 skills ${MID} MCP 1/1`,
);
expect(out).toContain('Browse official marketplace');
expect(out).toContain('Show plugin summary');
picker.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith({ kind: 'info', id: 'kimi-datasource' });
});
it('renders marketplace plugins separately from marketplace actions', () => {
const onSelect = vi.fn();
const picker = new PluginMarketplaceSelectorComponent({
entries: [
{
id: 'superpowers',
tier: 'curated',
displayName: 'Superpowers',
version: '5.1.0',
description: 'Workflow skills',
source: 'https://example.com/superpowers.zip',
keywords: ['workflow'],
},
],
installedIds: new Set(),
source: '/tmp/marketplace.json',
colors: darkColors,
onSelect,
onCancel: vi.fn(),
});
const out = picker.render(120).map(strip).join('\n');
expect(out).toContain('Marketplace (1)');
expect(out).toContain('? Superpowers install v5.1.0');
expect(out).toContain(
`Enter/Space install ${MID} Workflow skills ${MID} id superpowers ${MID} v5.1.0 ${MID} Curated plugin ${MID} workflow`,
);
expect(out).toContain('Actions');
expect(out).toContain('Back to installed plugins');
picker.handleInput(' ');
expect(onSelect).toHaveBeenCalledWith({
kind: 'install',
entry: expect.objectContaining({ id: 'superpowers' }),
});
});
it('issues install for installed marketplace entries (update path)', () => {
const onSelect = vi.fn();
const picker = new PluginMarketplaceSelectorComponent({
entries: [
{
id: 'superpowers',
displayName: 'Superpowers',
source: 'https://example.com/superpowers.zip',
},
],
installedIds: new Set(['superpowers']),
source: '/tmp/marketplace.json',
colors: darkColors,
onSelect,
onCancel: vi.fn(),
});
const out = picker.render(120).map(strip).join('\n');
expect(out).toContain('? Superpowers installed');
expect(out).toContain(`Enter/Space update ${MID} Plugin ${MID} id superpowers`);
picker.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith({
kind: 'install',
entry: expect.objectContaining({ id: 'superpowers' }),
});
});
it('toggles an installed plugin from the overview with space', () => {
const onSelect = vi.fn();
const picker = new PluginsOverviewSelectorComponent({
plugins: [
{
id: 'kimi-datasource',
displayName: 'Kimi Datasource',
version: '1.0.0',
enabled: true,
state: 'ok',
skillCount: 1,
mcpServerCount: 0,
enabledMcpServerCount: 0,
hasErrors: false,
},
],
colors: darkColors,
onSelect,
onCancel: vi.fn(),
});
picker.handleInput(' ');
expect(onSelect).toHaveBeenCalledWith({
kind: 'toggle',
id: 'kimi-datasource',
enabled: false,
});
});
it('issues a remove request from the overview on D', () => {
const onSelect = vi.fn();
const picker = new PluginsOverviewSelectorComponent({
plugins: [
{
id: 'kimi-datasource',
displayName: 'Kimi Datasource',
version: '1.0.0',
enabled: true,
state: 'ok',
skillCount: 1,
mcpServerCount: 0,
enabledMcpServerCount: 0,
hasErrors: false,
},
],
colors: darkColors,
onSelect,
onCancel: vi.fn(),
});
picker.handleInput('d');
expect(onSelect).toHaveBeenCalledWith({ kind: 'remove', id: 'kimi-datasource' });
});
it('opens MCP server management from the overview on M', () => {
const onSelect = vi.fn();
const picker = new PluginsOverviewSelectorComponent({
plugins: [
{
id: 'kimi-datasource',
displayName: 'Kimi Datasource',
version: '1.0.0',
enabled: true,
state: 'ok',
skillCount: 1,
mcpServerCount: 1,
enabledMcpServerCount: 1,
hasErrors: false,
},
],
colors: darkColors,
onSelect,
onCancel: vi.fn(),
});
picker.handleInput('m');
expect(onSelect).toHaveBeenCalledWith({ kind: 'mcp', id: 'kimi-datasource' });
});
it('toggles MCP servers from the MCP selector', () => {
const selections: PluginMcpSelection[] = [];
const picker = new PluginMcpSelectorComponent({
info: {
id: 'kimi-datasource',
displayName: 'Kimi Datasource',
version: '1.0.0',
enabled: true,
state: 'ok',
skillCount: 1,
mcpServerCount: 1,
enabledMcpServerCount: 1,
hasErrors: false,
source: 'local-path',
root: '/plugins/kimi-datasource',
manifest: undefined,
mcpServers: [
{
name: 'data',
runtimeName: 'plugin-kimi-datasource-data',
enabled: true,
transport: 'stdio',
command: 'node',
args: ['./bin/kimi-datasource.mjs'],
cwd: '/plugins/kimi-datasource',
},
],
diagnostics: [],
},
colors: darkColors,
onSelect: (selection) => {
selections.push(selection);
},
onCancel: vi.fn(),
});
const out = picker.render(120).map(strip).join('\n');
expect(out).toContain('MCP servers (1/1 enabled)');
expect(out).toContain('? data enabled');
picker.handleInput(' ');
expect(selections).toEqual([
{ kind: 'toggle', pluginId: 'kimi-datasource', server: 'data', enabled: false },
]);
});
it('renders plugin action hints inline on the overview row', () => {
const picker = new PluginsOverviewSelectorComponent({
plugins: [
{
id: 'kimi-datasource',
displayName: 'Kimi Datasource',
version: '1.0.0',
enabled: true,
state: 'ok',
skillCount: 1,
mcpServerCount: 0,
enabledMcpServerCount: 0,
hasErrors: false,
},
],
selectedId: 'kimi-datasource',
pluginHint: { id: 'kimi-datasource', text: `saved ${MID} /new to apply` },
colors: darkColors,
onSelect: vi.fn(),
onCancel: vi.fn(),
});
const out = picker.render(120).map(strip).join('\n');
expect(out).toContain(`? Kimi Datasource enabled saved ${MID} /new to apply`);
});
it('defaults plugin removal confirmation to cancel', () => {
const results: PluginRemoveConfirmResult[] = [];
const picker = new PluginRemoveConfirmComponent({
id: 'kimi-datasource',
displayName: 'Kimi Datasource',
colors: darkColors,
onDone: (result) => {
results.push(result);
},
});
const out = picker.render(120).map(strip);
expect(out).toContain(' Remove Kimi Datasource (kimi-datasource)?');
expect(out).toContain(' ? Cancel');
expect(out).toContain(' Keep this plugin installed.');
expect(out).toContain(' Remove only the install record; plugin files are left in place.');
picker.handleInput('\r');
expect(results).toEqual([{ kind: 'cancel' }]);
});
it('confirms plugin removal only after choosing remove', () => {
const results: PluginRemoveConfirmResult[] = [];
const picker = new PluginRemoveConfirmComponent({
id: 'kimi-datasource',
displayName: 'Kimi Datasource',
colors: darkColors,
onDone: (result) => {
results.push(result);
},
});
picker.handleInput('');
picker.handleInput('\r');
expect(results).toEqual([{ kind: 'confirm' }]);
});
});

View file

@ -1,4 +1,4 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@ -12,6 +12,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel';
import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector';
import {
PluginMcpSelectorComponent,
PluginMarketplaceSelectorComponent,
PluginRemoveConfirmComponent,
PluginsOverviewSelectorComponent,
} from '#/tui/components/dialogs/plugins-selector';
import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui';
import type { QueuedMessage } from '#/tui/types';
import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store';
@ -127,6 +133,38 @@ function makeSession(overrides: Record<string, unknown> = {}) {
},
})),
close: vi.fn(async () => {}),
listPlugins: vi.fn(async () => []),
installPlugin: vi.fn(async () => ({
id: 'demo',
displayName: 'Demo',
version: '1.0.0',
enabled: true,
state: 'ok',
skillCount: 1,
mcpServerCount: 0,
enabledMcpServerCount: 0,
hasErrors: false,
})),
setPluginEnabled: vi.fn(async () => {}),
setPluginMcpServerEnabled: vi.fn(async () => {}),
removePlugin: vi.fn(async () => {}),
reloadPlugins: vi.fn(async () => ({ added: [], removed: [], errors: [] })),
getPluginInfo: vi.fn(async (id: string) => ({
id,
displayName: id,
version: '1.0.0',
enabled: true,
state: 'ok',
skillCount: 1,
mcpServerCount: 0,
enabledMcpServerCount: 0,
hasErrors: false,
source: 'local-path',
root: `/plugins/${id}`,
manifest: undefined,
mcpServers: [],
diagnostics: [],
})),
...overrides,
};
}
@ -189,6 +227,7 @@ function countOccurrences(haystack: string, needle: string): number {
const tempDirs: string[] = [];
const originalKimiCodeHome = process.env['KIMI_CODE_HOME'];
const originalPluginMarketplaceUrl = process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL'];
const originalVisual = process.env['VISUAL'];
const originalEditor = process.env['EDITOR'];
@ -213,6 +252,11 @@ afterEach(async () => {
} else {
process.env['VISUAL'] = originalVisual;
}
if (originalPluginMarketplaceUrl === undefined) {
delete process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL'];
} else {
process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL'] = originalPluginMarketplaceUrl;
}
if (originalEditor === undefined) {
delete process.env['EDITOR'];
} else {
@ -1306,6 +1350,250 @@ describe('KimiTUI message flow', () => {
});
});
it('toggles plugin MCP servers from the text command', async () => {
const session = makeSession();
const { driver } = await makeDriver(session);
driver.handleUserInput('/plugins mcp enable kimi-datasource data');
await vi.waitFor(() => {
expect(session.setPluginMcpServerEnabled).toHaveBeenCalledWith(
'kimi-datasource',
'data',
true,
);
});
});
it('errors when /plugins install has no argument', async () => {
const session = makeSession();
const { driver } = await makeDriver(session);
driver.handleUserInput('/plugins install');
await vi.waitFor(() => {
expect(stripSgr(renderTranscript(driver))).toContain(
'Usage: /plugins install <local-path-or-zip-url>',
);
});
expect(session.installPlugin).not.toHaveBeenCalled();
});
it('installs from a positional source on /plugins install', async () => {
const session = makeSession();
const { driver } = await makeDriver(session);
driver.handleUserInput('/plugins install ./plugins/kimi-datasource');
await vi.waitFor(() => {
expect(session.installPlugin).toHaveBeenCalledWith('/tmp/proj-a/plugins/kimi-datasource');
});
});
it('loads a local plugin marketplace file and installs from it', async () => {
const marketplaceDir = await makeTempHome();
const marketplacePath = join(marketplaceDir, 'marketplace.json');
await writeFile(
marketplacePath,
JSON.stringify({
plugins: [
{
id: 'kimi-datasource',
displayName: 'Kimi Datasource',
description: 'Datasource plugin',
source: './kimi-datasource',
},
],
}),
'utf8',
);
process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL'] = marketplacePath;
const session = makeSession();
const { driver } = await makeDriver(session);
driver.handleUserInput('/plugins marketplace');
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(
PluginMarketplaceSelectorComponent,
);
});
const picker = driver.state.editorContainer.children[0] as PluginMarketplaceSelectorComponent;
picker.handleInput(' ');
await vi.waitFor(() => {
expect(session.installPlugin).toHaveBeenCalledWith(join(marketplaceDir, 'kimi-datasource'));
});
await vi.waitFor(() => {
const transcript = stripSgr(renderTranscript(driver));
expect(transcript).toContain('Installing or updating Kimi Datasource from marketplace...');
expect(transcript).toContain('Installed or updated Demo');
});
});
it('toggles plugins from the overview with space', async () => {
let enabled = true;
const session = makeSession({
listPlugins: vi.fn(async () => [
{
id: 'demo',
displayName: 'Demo',
version: '1.0.0',
enabled,
state: 'ok',
skillCount: 1,
mcpServerCount: 0,
enabledMcpServerCount: 0,
hasErrors: false,
},
]),
setPluginEnabled: vi.fn(async (_id: string, nextEnabled: boolean) => {
enabled = nextEnabled;
}),
});
const { driver } = await makeDriver(session);
driver.handleUserInput('/plugins');
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(
PluginsOverviewSelectorComponent,
);
});
const overview = driver.state.editorContainer.children[0] as PluginsOverviewSelectorComponent;
overview.handleInput(' ');
await vi.waitFor(() => {
expect(session.setPluginEnabled).toHaveBeenCalledWith('demo', false);
});
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(
PluginsOverviewSelectorComponent,
);
});
const out = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n'));
expect(out).toContain(' Demo disabled saved · /new to apply');
expect(stripSgr(renderTranscript(driver))).toContain('Disabled demo. Run /new to apply.');
});
it('toggles plugin MCP servers from the overview MCP picker', async () => {
const session = makeSession({
listPlugins: vi.fn(async () => [
{
id: 'kimi-datasource',
displayName: 'Kimi Datasource',
version: '1.0.0',
enabled: true,
state: 'ok',
skillCount: 1,
mcpServerCount: 1,
enabledMcpServerCount: 1,
hasErrors: false,
},
]),
getPluginInfo: vi.fn(async () => ({
id: 'kimi-datasource',
displayName: 'Kimi Datasource',
version: '1.0.0',
enabled: true,
state: 'ok',
skillCount: 1,
mcpServerCount: 1,
enabledMcpServerCount: 1,
hasErrors: false,
source: 'local-path',
root: '/plugins/kimi-datasource',
manifest: undefined,
mcpServers: [
{
name: 'data',
runtimeName: 'plugin-kimi-datasource-data',
enabled: true,
transport: 'stdio',
command: 'node',
args: ['./bin/kimi-datasource.mjs'],
},
],
diagnostics: [],
})),
});
const { driver } = await makeDriver(session);
driver.handleUserInput('/plugins');
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(
PluginsOverviewSelectorComponent,
);
});
const overview = driver.state.editorContainer.children[0] as PluginsOverviewSelectorComponent;
overview.handleInput('m');
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(
PluginMcpSelectorComponent,
);
});
const mcpPicker = driver.state.editorContainer.children[0] as PluginMcpSelectorComponent;
mcpPicker.handleInput(' ');
await vi.waitFor(() => {
expect(session.setPluginMcpServerEnabled).toHaveBeenCalledWith(
'kimi-datasource',
'data',
false,
);
});
});
it('requires confirmation before /plugins remove removes a plugin', async () => {
const session = makeSession();
const { driver } = await makeDriver(session);
driver.handleUserInput('/plugins remove demo');
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(
PluginRemoveConfirmComponent,
);
});
expect(session.removePlugin).not.toHaveBeenCalled();
const confirm = driver.state.editorContainer.children[0] as PluginRemoveConfirmComponent;
expect(stripSgr(confirm.render(120).join('\n'))).toContain('Remove demo (demo)?');
confirm.handleInput('\r');
await vi.waitFor(() => {
expect(stripSgr(renderTranscript(driver))).toContain('Remove cancelled: demo.');
});
expect(session.removePlugin).not.toHaveBeenCalled();
});
it('renders /plugins <id> info to the transcript', async () => {
const session = makeSession({
listPlugins: vi.fn(async () => [
{
id: 'demo',
displayName: 'Demo',
version: '1.0.0',
enabled: true,
state: 'ok',
skillCount: 1,
mcpServerCount: 0,
enabledMcpServerCount: 0,
hasErrors: false,
},
]),
});
const { driver } = await makeDriver(session);
driver.handleUserInput('/plugins demo');
await vi.waitFor(() => {
expect(session.getPluginInfo).toHaveBeenCalledWith('demo');
});
});
it('applies /model selection with inline thinking state', async () => {
const session = makeSession();
const setConfig = vi.fn(async () => ({ providers: {} }));

View file

@ -0,0 +1,178 @@
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app';
import { loadPluginMarketplace } from '#/utils/plugin-marketplace';
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../../..');
describe('loadPluginMarketplace', () => {
it('loads a local marketplace file and resolves relative plugin sources', async () => {
const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-'));
const file = join(dir, 'marketplace.json');
await writeFile(
file,
JSON.stringify({
version: '1',
plugins: [
{
id: 'kimi-datasource',
tier: 'official',
displayName: 'Kimi Datasource',
version: '1.0.0',
description: 'Datasource tools',
source: './kimi-datasource',
keywords: ['data'],
},
{
id: 'superpowers',
tier: 'curated',
displayName: 'Superpowers',
version: '5.1.0',
description: 'Workflow skills',
homepage: 'https://github.com/obra/superpowers',
source: './curated/superpowers',
keywords: ['skills', 'workflow'],
},
],
}),
'utf8',
);
const marketplace = await loadPluginMarketplace({ workDir: '/tmp/work', source: file });
expect(marketplace).toEqual({
source: file,
version: '1',
plugins: [
{
id: 'kimi-datasource',
displayName: 'Kimi Datasource',
tier: 'official',
version: '1.0.0',
description: 'Datasource tools',
source: join(dir, 'kimi-datasource'),
keywords: ['data'],
homepage: undefined,
},
{
id: 'superpowers',
displayName: 'Superpowers',
tier: 'curated',
version: '5.1.0',
description: 'Workflow skills',
source: join(dir, 'curated', 'superpowers'),
keywords: ['skills', 'workflow'],
homepage: 'https://github.com/obra/superpowers',
},
],
});
});
it('includes Superpowers in the repository marketplace fixture', async () => {
const marketplace = await loadPluginMarketplace({
workDir: REPO_ROOT,
source: join(REPO_ROOT, 'plugins/marketplace.json'),
});
expect(marketplace.plugins).toContainEqual(
expect.objectContaining({
id: 'superpowers',
displayName: 'Superpowers',
tier: 'curated',
source: join(REPO_ROOT, 'plugins/curated/superpowers'),
}),
);
expect(marketplace.plugins).toContainEqual(
expect.objectContaining({
id: 'kimi-datasource',
tier: 'official',
source: join(REPO_ROOT, 'plugins/official/kimi-datasource'),
}),
);
});
it('loads the default CDN marketplace with injectable fetch', async () => {
const fetchImpl = vi.fn(async () => ({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
plugins: [
{
id: 'kimi-datasource',
displayName: 'Kimi Datasource',
source: './official/kimi-datasource.zip',
},
],
}),
})) as unknown as typeof fetch;
const marketplace = await loadPluginMarketplace({ workDir: '/tmp/work', fetchImpl });
expect(fetchImpl).toHaveBeenCalledWith(KIMI_CODE_PLUGIN_MARKETPLACE_URL);
expect(marketplace.plugins[0]).toEqual(
expect.objectContaining({
id: 'kimi-datasource',
displayName: 'Kimi Datasource',
source: new URL(
'./official/kimi-datasource.zip',
KIMI_CODE_PLUGIN_MARKETPLACE_URL,
).toString(),
}),
);
});
it('loads an explicit remote marketplace with injectable fetch', async () => {
const source = 'https://example.com/plugins/marketplace.json';
const fetchImpl = vi.fn(async () => ({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
plugins: [{ id: 'superpowers', name: 'Superpowers', url: 'superpowers.zip' }],
}),
})) as unknown as typeof fetch;
const marketplace = await loadPluginMarketplace({ workDir: '/tmp/work', source, fetchImpl });
expect(fetchImpl).toHaveBeenCalledWith(source);
expect(marketplace.plugins[0]).toEqual(
expect.objectContaining({
id: 'superpowers',
displayName: 'Superpowers',
source: new URL('superpowers.zip', source).toString(),
}),
);
});
it('rejects malformed marketplace entries', async () => {
const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-'));
const file = join(dir, 'marketplace.json');
await writeFile(file, JSON.stringify({ plugins: [{ displayName: 'Missing id' }] }), 'utf8');
await expect(loadPluginMarketplace({ workDir: '/tmp/work', source: file })).rejects.toThrow(
/must define "id"/,
);
});
it('rejects unknown marketplace tier values', async () => {
const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-'));
const file = join(dir, 'marketplace.json');
await writeFile(
file,
JSON.stringify({
plugins: [{ id: 'demo', tier: 'community', source: './demo' }],
}),
'utf8',
);
await expect(loadPluginMarketplace({ workDir: '/tmp/work', source: file })).rejects.toThrow(
/"tier" must be one of/,
);
});
});

View file

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

View file

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

View file

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

View file

@ -73,6 +73,7 @@ When neither `KIMI_CODE_OAUTH_HOST` nor `KIMI_OAUTH_HOST` is set, the OAuth auth
| --- | --- | --- |
| `KIMI_DISABLE_TELEMETRY` | Disable telemetry reporting | `1`, `true`, `t`, `yes`, `y` (case-insensitive) |
| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | Override `[background].keep_alive_on_exit`, controlling whether still-running background tasks are kept when the session closes | True values: `1`, `true`, `yes`, `on`; false values: `0`, `false`, `no`, `off`; when unset, reads `config.toml`, then falls back to `true` |
| `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins`; useful for dev loopback servers, staging CDN files, or alternate marketplace directories | `https://cdn.kimi.com/kimi-code/plugins/marketplace.json`; also accepts `http://`, `file://` URLs, and local paths |
| `KIMI_SHELL_PATH` | Override the absolute path to Git Bash (`bash.exe`) on Windows; only needed when auto-detection fails on Windows | None |
| `KIMI_MODEL_MAX_COMPLETION_TOKENS` | Explicit hard cap for `max_completion_tokens` in a single-step LLM request. When unset, Kimi Code uses the safe remaining context window for models with a known context size. Set to `0` or a negative value to disable clamping entirely. **Currently effective only for providers of type `kimi`**; for Anthropic and other providers, use `[models.<alias>].max_output_size` instead (see [Config files](./config-files.md#models)) | Unset: computed from remaining context; unknown context falls back to `loop_control.reserved_context_size`, then 32000 |

View file

@ -17,6 +17,8 @@ Project entries override user-level entries with the same name.
The easiest entry point is running `/mcp-config` in the TUI, which guides you through adding, editing, or removing servers. To check connection status, run `/mcp`.
Plugins can also declare MCP servers in `kimi.plugin.json` or `.kimi-plugin/plugin.json`. Plugin-declared servers are enabled by default but only start in new sessions; disable or re-enable them from `/plugins` or with `/plugins mcp disable|enable <plugin-id> <server>`, then start a new session. See [Plugins](./plugins.md) for details.
The top-level shape of `mcp.json` is:
```json

View file

@ -0,0 +1,194 @@
# Plugins
Plugins package reusable Kimi Code CLI behavior around a `kimi.plugin.json` manifest. A plugin can contribute skills, plugin-wide skill instructions, a declarative session-start skill, display metadata, and MCP servers. Multi-harness repositories can put the same Kimi manifest under `.kimi-plugin/plugin.json` instead of occupying the repository root.
Installing a plugin does not execute plugin-provided Python, Node.js, shell, or hook scripts. Kimi Code CLI does not run command-backed plugin tools in this version; real tools should be exposed through plugin-declared MCP servers.
## Installing and managing plugins
Run `/plugins` inside the TUI to open the interactive plugin manager. The picker lists installed plugins and lets you install, inspect, enable, disable, remove, reload, browse the official marketplace, and toggle plugin MCP servers. Use `Enter` or `→` to open details, `Space` to enable or disable an installed plugin, `M` to manage that plugin's MCP servers, and `←` or `Esc` to go back. In the marketplace view, `Enter` or `Space` installs or updates the selected plugin.
Kimi Code CLI currently supports only user/global plugin installation. Installed plugins are recorded under `$KIMI_CODE_HOME/plugins/` and are available to the current user across projects. Project-local, repository-shared, managed/admin, and `--scope` plugin installs are not supported yet.
Shortcut commands remain available for quick or scripted actions:
```sh
/plugins
/plugins list
/plugins install /absolute/path/to/plugin
/plugins install ./relative-plugin
/plugins install https://example.com/plugin.zip
/plugins marketplace
/plugins marketplace ./plugins/marketplace.json
/plugins info <id>
/plugins enable <id>
/plugins disable <id>
/plugins remove <id>
/plugins reload
/plugins mcp enable <id> <server>
/plugins mcp disable <id> <server>
```
Hosted example packages:
```sh
/plugins install https://kimi-1300010026.cos.ap-beijing.myqcloud.com/kimi-datasource.zip
/plugins install https://kimi-1300010026.cos.ap-beijing.myqcloud.com/superpowers-kimi-5.1.0-kimi.1.zip
```
The official marketplace loads from `https://cdn.kimi.com/kimi-code/plugins/marketplace.json` by default. In `/plugins`, choose **Browse official marketplace** to list the marketplace entries and install one directly. The CDN can host the whole marketplace directory with `marketplace.json` at its root; relative plugin sources are resolved next to that file.
To build the CDN-ready marketplace directory from this repository, run:
```sh
pnpm run build:plugin-marketplace
```
The command writes `plugins/cdn/marketplace.json` plus plugin zip archives under `plugins/cdn/`. Upload that directory as a unit so the relative `source` entries in `marketplace.json` keep pointing at the generated zip files.
To test a staging CDN file or alternate marketplace, override the marketplace source:
```sh
KIMI_CODE_PLUGIN_MARKETPLACE_URL=https://staging.example.com/plugins/marketplace.json kimi
```
You can also open a one-off marketplace file without changing the environment:
```sh
/plugins marketplace plugins/marketplace.json
```
During CLI development, `pnpm dev:cli` starts a loopback marketplace server for the repository's `plugins/` directory and temporarily sets `KIMI_CODE_PLUGIN_MARKETPLACE_URL=http://127.0.0.1:<port>/marketplace.json` for that dev process. The server rewrites local directory sources to temporary zip URLs so marketplace installs exercise the same download path as the CDN. To test the real CDN from dev, set `KIMI_CODE_PLUGIN_MARKETPLACE_URL=https://cdn.kimi.com/kimi-code/plugins/marketplace.json pnpm dev:cli`; the dev wrapper will use that value instead of starting the local marketplace server.
Local directories and zip URLs are copied into Kimi Code CLI's managed plugin directory under `$KIMI_CODE_HOME/plugins/managed/<id>/`. Installing the same plugin id again overwrites that managed copy, preserving the plugin's enabled state and MCP server toggles. `installed.json` records the managed copy plus the original source for display. Removing a plugin asks for confirmation, then only removes the install record; it does not delete the managed copy or the original local source directory.
Plugin changes apply to new sessions. After installing, enabling, disabling, removing, reloading, or changing a plugin MCP server toggle, 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.
The reload action re-reads `installed.json` and each plugin manifest so that `/plugins` and `/plugins info <id>` show the latest install state and diagnostics. It is not a hot reload for the current session's skills or MCP connections. Because local-path installs run from the managed copy, editing the original source directory after install has no effect until you reinstall the plugin.
## Manifest format
Kimi Code CLI treats a root `kimi.plugin.json` as the primary plugin manifest:
```text
<plugin_root>/kimi.plugin.json
```
If `kimi.plugin.json` is absent, Kimi Code CLI reads the Kimi-scoped manifest:
```text
<plugin_root>/.kimi-plugin/plugin.json
```
Kimi Code CLI does not read root `plugin.json` or `.codex-plugin/plugin.json`. If both `kimi.plugin.json` and `.kimi-plugin/plugin.json` exist, the root `kimi.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 the finance MCP tools for live market data. Do not invent live prices.",
"mcpServers": {
"data": {
"command": "node",
"args": ["./bin/finance-mcp.mjs"],
"cwd": "./"
}
},
"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. Servers are enabled by default and can be disabled from `/plugins` or with `/plugins mcp disable <id> <server>`. |
| `interface` | Display fields for `/plugins info`, such as `displayName`, `shortDescription`, `longDescription`, `developerName`, and `websiteURL`. |
Unsupported runtime fields such as `tools`, `commands`, `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/
kimi.plugin.json
skills/
using-my-plugin/
SKILL.md
another-workflow/
SKILL.md
```
`sessionStart.skill` is a declarative session-start rule: it loads a skill into the main agent's context once at the start of a session. It does not execute code. Use it when the plugin needs to establish workflow rules before the first user task, such as mapping another tool harness's terminology to Kimi Code CLI tools.
`skillInstructions` stays next to the skill content whenever the skill is loaded, whether the skill was loaded by `sessionStart.skill`, by `/skill:<name>`, or by the model's automatic skill invocation.
## MCP servers in plugins
Plugin MCP servers reuse the same server schema as [MCP](./mcp.md). They can be stdio servers:
```json
{
"mcpServers": {
"finance": {
"command": "uvx",
"args": ["kimi-finance-mcp"]
}
}
}
```
Or HTTP servers:
```json
{
"mcpServers": {
"docs": {
"url": "https://example.com/mcp"
}
}
}
```
For stdio servers, `command` may be a command found on `PATH`, or a `./` path inside the plugin root. If `cwd` is omitted, Kimi Code CLI runs the server from the managed plugin root. If `cwd` is set, it must 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.
Plugin MCP servers are enabled by default, but they still only start in new sessions. To disable or re-enable one interactively, run `/plugins`, select the plugin, then press `M` to manage its servers. You can also use shortcut commands:
```sh
/plugins mcp disable kimi-finance finance
/new
/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 `kimi.plugin.json`, `.kimi-plugin/plugin.json`, and Markdown skill files are read during install and session startup.
- Command-backed plugin tools, 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 an enabled plugin are enabled by default, but only start in a new session and can be disabled from `/plugins` or with `/plugins mcp disable`.
- Bad manifests or unsafe paths produce diagnostics shown by `/plugins info <id>` and do not crash unrelated sessions.

View file

@ -55,6 +55,7 @@ Some commands are only available in the idle state. Running them while the sessi
| `/usage` | — | Show token usage, context consumption, and quota information. | Yes |
| `/status` | — | Show the current session runtime status, including version, model, working directory, and permission mode. | Yes |
| `/mcp` | — | List the MCP servers in the current session and their connection status. | Yes |
| `/plugins` | — | Open the interactive plugin manager for user/global installs: install, inspect, enable, disable, confirm removal, reload, browse the official marketplace, and toggle plugin MCP servers. Shortcut subcommands remain available. | Yes |
| `/version` | — | Show the Kimi Code CLI version number. | Yes |
| `/feedback` | — | Submit feedback to help improve Kimi Code CLI. | Yes |

View file

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

View file

@ -73,6 +73,7 @@ OAuth 流程默认连接 Kimi 官方的认证与托管端点,下列变量可
| --- | --- | --- |
| `KIMI_DISABLE_TELEMETRY` | 关闭遥测上报 | `1``true``t``yes``y`(不区分大小写) |
| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | 覆盖 `[background].keep_alive_on_exit`,控制会话关闭时是否保留仍在运行的后台任务 | 真值:`1``true``yes``on`;假值:`0``false``no``off`;未设置时读取 `config.toml`,再回退到 `true` |
| `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | 覆盖 `/plugins` 加载的 plugin marketplace JSON适合 dev loopback server、测试 CDN 文件或替换 marketplace 目录 | `https://cdn.kimi.com/kimi-code/plugins/marketplace.json`;也接受 `http://``file://` URL 和本地路径 |
| `KIMI_SHELL_PATH` | 覆盖 Windows 上 Git Bash (`bash.exe`) 的绝对路径,仅在 Windows 自动探测失败时需要 | 无 |
| `KIMI_MODEL_MAX_COMPLETION_TOKENS` | 单步 LLM 请求 `max_completion_tokens` 的显式硬上限。未设置时对于已知上下文窗口的模型Kimi Code 会使用安全的剩余上下文窗口;设为 `0` 或负数则完全禁用 clamp。**目前只对 `kimi` 类型的供应商生效**Anthropic 等其它供应商请改用 `[models.<alias>].max_output_size`(详见 [配置文件](./config-files.md#models) | 未设置:按剩余上下文计算;未知上下文窗口时回退到 `loop_control.reserved_context_size`,再回退到 32000 |

View file

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

View file

@ -0,0 +1,194 @@
# Plugins
Plugins 围绕 `kimi.plugin.json` manifest 打包可复用的 Kimi Code CLI 行为。一个 plugin 可以贡献 Skills、插件级 Skill 说明、声明式 session-start Skill、展示元数据以及 MCP servers。多宿主仓库可以把同一份 Kimi manifest 放在 `.kimi-plugin/plugin.json`,避免占用仓库根目录。
安装 plugin 本身不会执行 plugin 提供的 Python、Node.js、Shell 或 hook 脚本。当前版本的 Kimi Code CLI 不运行命令型 plugin tools真实工具应通过 plugin 声明的 MCP servers 暴露。
## 安装与管理 plugins
在 TUI 中运行 `/plugins` 会打开交互式 plugin 管理器。选择器会列出已安装的 plugins并让你安装、查看、启用、禁用、移除、重载、浏览官方 marketplace以及启用或禁用 plugin MCP servers。用 `Enter``→` 打开详情,用 `Space` 启用或禁用已安装 plugin`M` 管理该 plugin 的 MCP servers`←``Esc` 返回。Marketplace 里 `Enter``Space` 会安装或更新当前选中的 plugin。
Kimi Code CLI 目前只支持 user/global用户全局plugin 安装。已安装 plugins 记录在 `$KIMI_CODE_HOME/plugins/` 下,并对当前用户的所有项目可用。暂不支持项目本地、仓库共享、托管/管理员范围,以及带 `--scope` 的 plugin 安装。
快捷命令仍然可用于快速操作或脚本:
```sh
/plugins
/plugins list
/plugins install /absolute/path/to/plugin
/plugins install ./relative-plugin
/plugins install https://example.com/plugin.zip
/plugins marketplace
/plugins marketplace ./plugins/marketplace.json
/plugins info <id>
/plugins enable <id>
/plugins disable <id>
/plugins remove <id>
/plugins reload
/plugins mcp enable <id> <server>
/plugins mcp disable <id> <server>
```
当前可直接安装的示例包:
```sh
/plugins install https://kimi-1300010026.cos.ap-beijing.myqcloud.com/kimi-datasource.zip
/plugins install https://kimi-1300010026.cos.ap-beijing.myqcloud.com/superpowers-kimi-5.1.0-kimi.1.zip
```
官方 marketplace 默认从 `https://cdn.kimi.com/kimi-code/plugins/marketplace.json` 加载。在 `/plugins` 中选择 **Browse official marketplace**,即可查看 marketplace 条目并直接安装。CDN 可以托管整个 marketplace 目录,目录根部放 `marketplace.json`;相对 plugin source 会按这个文件所在目录解析。
要从当前仓库生成可上传到 CDN 的 marketplace 目录,运行:
```sh
pnpm run build:plugin-marketplace
```
这个命令会写出 `plugins/cdn/marketplace.json`,并在 `plugins/cdn/` 下生成 plugin zip 包。上传时需要把整个目录作为一个整体上传,这样 `marketplace.json` 里的相对 `source` 才会继续指向生成好的 zip 文件。
需要测试预发 CDN 文件或其他 marketplace 时,可以覆盖 marketplace 来源:
```sh
KIMI_CODE_PLUGIN_MARKETPLACE_URL=https://staging.example.com/plugins/marketplace.json kimi
```
也可以不改环境变量,临时打开一个 marketplace 文件:
```sh
/plugins marketplace plugins/marketplace.json
```
CLI 开发时,`pnpm dev:cli` 会为仓库的 `plugins/` 目录启动一个 loopback marketplace server并只为这次 dev 进程临时设置 `KIMI_CODE_PLUGIN_MARKETPLACE_URL=http://127.0.0.1:<port>/marketplace.json`。这个 server 会把本地目录型 source 临时改成 zip URL让 marketplace 安装走和 CDN 一样的下载路径。需要在 dev 中测试真实 CDN 时,运行 `KIMI_CODE_PLUGIN_MARKETPLACE_URL=https://cdn.kimi.com/kimi-code/plugins/marketplace.json pnpm dev:cli`dev wrapper 会使用这个值,不再启动本地 marketplace server。
本地目录和 zip URL 都会复制到 Kimi Code CLI 管理的 plugin 目录 `$KIMI_CODE_HOME/plugins/managed/<id>/`。再次安装同一个 plugin id 会覆盖这份托管副本,并保留 plugin 的启用状态和 MCP server 开关。`installed.json` 记录这份托管副本,同时保留原始来源用于展示。移除 plugin 会先二次确认,确认后只删除安装记录,不会删除托管副本或原始本地源码目录。
Plugin 变更只对新会话生效。安装、启用、禁用、移除、重载 plugin或修改 plugin MCP server 开关后,需要通过 `/new` 开启新会话,新的 Skills、`sessionStart.skill` 和 MCP servers 才会进入会话。已有会话继续使用启动时的快照。
重载操作会重新读取 `installed.json` 和每个 plugin manifest`/plugins``/plugins info <id>` 展示最新安装状态和 diagnostics。它不会热更新当前会话里的 Skills 或 MCP 连接。因为本地路径安装实际运行的是托管副本,安装后继续修改原始源码目录不会影响已安装 plugin需要重新安装才会更新。
## Manifest 格式
Kimi Code CLI 把根目录 `kimi.plugin.json` 作为优先 plugin manifest
```text
<plugin_root>/kimi.plugin.json
```
如果没有 `kimi.plugin.json`Kimi Code CLI 会读取 Kimi 专属 manifest
```text
<plugin_root>/.kimi-plugin/plugin.json
```
Kimi Code CLI 不读取根目录 `plugin.json``.codex-plugin/plugin.json`。如果同时存在 `kimi.plugin.json``.kimi-plugin/plugin.json`,根目录 `kimi.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 the finance MCP tools for live market data. Do not invent live prices.",
"mcpServers": {
"data": {
"command": "node",
"args": ["./bin/finance-mcp.mjs"],
"cwd": "./"
}
},
"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 声明。Servers 默认启用,可以在 `/plugins` 中禁用,或使用 `/plugins mcp disable <id> <server>` 禁用。 |
| `interface` | `/plugins info` 的展示字段,例如 `displayName``shortDescription``longDescription``developerName``websiteURL`。 |
`tools``commands``configFile``config_file``inject``bootstrap``hooks``apps` 等不支持的运行时字段只会产生 diagnostics 并被忽略。
## Skills 与 session start
Plugin Skills 使用和普通 [Agent Skills](./skills.md) 相同的 `SKILL.md` 格式。常见目录布局如下:
```text
my-plugin/
kimi.plugin.json
skills/
using-my-plugin/
SKILL.md
another-workflow/
SKILL.md
```
`sessionStart.skill` 是声明式会话启动规则:它会在会话开始时,把某个 Skill 一次性加载到主 Agent 上下文中。它不会执行代码。适合用于 plugin 需要在第一个用户任务前建立工作规则的场景,例如把另一个工具环境里的术语映射到 Kimi Code CLI 工具。
无论 Skill 是通过 `sessionStart.skill``/skill:<name>`,还是模型自动调用加载,`skillInstructions` 都会跟 Skill 内容放在一起。
## Plugin 中的 MCP servers
Plugin MCP servers 复用 [MCP](./mcp.md) 的 server schema。可以声明 stdio server
```json
{
"mcpServers": {
"finance": {
"command": "uvx",
"args": ["kimi-finance-mcp"]
}
}
}
```
也可以声明 HTTP server
```json
{
"mcpServers": {
"docs": {
"url": "https://example.com/mcp"
}
}
}
```
对于 stdio server`command` 可以是 `PATH` 上的命令,也可以是 plugin 根目录内以 `./` 开头的路径。如果省略 `cwd`Kimi Code CLI 会从托管 plugin 根目录启动 server。如果设置 `cwd`,它必须以 `./` 开头,并且位于 plugin 根目录内。Plugin MCP servers 会继承当前进程环境变量;写在 `env` 里的值是字面量覆盖,不是 `${VAR}` 插值。
Plugin MCP servers 默认启用,但仍然只会在新会话中启动。要交互式禁用或重新启用,运行 `/plugins`,选中 plugin然后按 `M` 管理它的 servers。也可以使用快捷命令
```sh
/plugins mcp disable kimi-finance finance
/new
/plugins mcp enable kimi-finance finance
/new
```
启用状态保存在 `$KIMI_CODE_HOME/plugins/installed.json`。新会话启动后,已启用的 plugin MCP servers 会进入普通 MCP 生命周期,包括状态事件、工具命名和权限审批流程。
## 安全模型
Plugins 会被保守加载:
- 安装和会话启动时,只读取 `kimi.plugin.json``.kimi-plugin/plugin.json` 与 Markdown Skill 文件。
- 命令型 plugin tools、hooks 和旧式工具运行时不会由 plugin loader 执行。
- Plugin 路径在解析符号链接后必须仍位于 plugin 根目录内。
- 已启用 plugin 声明的 MCP servers 默认启用,但只会在新会话中启动,并且可以在 `/plugins` 中禁用,或使用 `/plugins mcp disable` 禁用。
- 损坏的 manifest 或不安全路径会变成 `/plugins info <id>` 中的 diagnostics不会让无关会话崩溃。

View file

@ -55,6 +55,7 @@
| `/usage` | — | 显示 token 用量、上下文占用以及配额信息。 | 是 |
| `/status` | — | 显示当前会话运行时状态,包括版本、模型、工作目录和权限模式等。 | 是 |
| `/mcp` | — | 列出当前会话中的 MCP server 及其连接状态。 | 是 |
| `/plugins` | — | 打开面向 user/global用户全局安装的交互式 plugin 管理器,用于安装、查看、启用、禁用、确认移除、重载、浏览官方 marketplace以及启用或禁用 plugin MCP servers快捷子命令仍可使用。 | 是 |
| `/version` | — | 显示 Kimi Code CLI 版本号。 | 是 |
| `/feedback` | — | 提交反馈以改进 Kimi Code CLI。 | 是 |

View file

@ -8,6 +8,7 @@
"build": "pnpm -r run build",
"build:packages": "pnpm -r --filter './packages/*' run build",
"dev:cli": "pnpm -C apps/kimi-code run dev",
"build:plugin-marketplace": "pnpm -C apps/kimi-code run build:plugin-marketplace",
"vis": "pnpm -C apps/vis run dev",
"dev:docs": "pnpm -C docs install --ignore-workspace && pnpm -C docs run dev",
"typecheck": "pnpm run build:packages && pnpm -r --filter './packages/*' run typecheck && pnpm --filter @moonshot-ai/kimi-code run typecheck",

View file

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

View file

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

View file

@ -0,0 +1,53 @@
import type { EnabledPluginSessionStart } from '../../plugin/types';
import type { SkillDefinition } from '../../skill';
import { DynamicInjector } from './injector';
export class PluginSessionStartInjector extends DynamicInjector {
protected override readonly injectionVariant = 'plugin_session_start';
protected override async getInjection(): Promise<string | undefined> {
if (this.injectedAt !== null) return undefined;
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.getPluginSkill(sessionStart.pluginId, sessionStart.skillName);
if (skill === undefined) {
this.agent.log.warn('plugin sessionStart skill not found', {
pluginId: sessionStart.pluginId,
skillName: sessionStart.skillName,
});
continue;
}
blocks.push(renderSessionStartBlock(sessionStart, skill, registry.renderSkillPrompt(skill, '')));
}
if (blocks.length === 0) return undefined;
return blocks.join('\n');
}
}
function renderSessionStartBlock(
sessionStart: EnabledPluginSessionStart,
skill: SkillDefinition,
skillContent: string,
): string {
return (
`<plugin_session_start plugin="${escapeAttr(sessionStart.pluginId)}" ` +
`skill="${escapeAttr(skill.name)}">\n${skillContent}\n</plugin_session_start>`
);
}
function escapeAttr(value: string): string {
return value.replaceAll('"', '&quot;');
}

View file

@ -416,7 +416,9 @@ export class ToolManager {
.toSorted((a, b) => a.localeCompare(b))
.map(
(name) =>
this.userTools.get(name) ?? this.mcpTools.get(name)?.tool ?? this.builtinTools.get(name),
this.userTools.get(name) ??
this.mcpTools.get(name)?.tool ??
this.builtinTools.get(name),
)
.filter((tool) => !!tool);
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,147 @@
import { createWriteStream } from 'node:fs';
import { chmod, mkdir, readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { type Entry, fromBuffer as yauzlFromBuffer } from 'yauzl';
export async function downloadZip(url: string, signal?: AbortSignal): Promise<Buffer> {
const controller = new AbortController();
const timeoutHandle = setTimeout(() => {
controller.abort();
}, 5 * 60 * 1000);
try {
const resp = await fetch(url, { signal: signal ?? controller.signal });
if (!resp.ok) {
throw new Error(`Failed to download zip: HTTP ${resp.status} ${resp.statusText}`);
}
return Buffer.from(await resp.arrayBuffer());
} finally {
clearTimeout(timeoutHandle);
}
}
export async function extractZip(buffer: Buffer, destDir: string): Promise<string> {
await mkdir(destDir, { recursive: true });
const destDirResolved = path.resolve(destDir);
let settled = false;
await new Promise<void>((resolve, reject) => {
yauzlFromBuffer(buffer, { lazyEntries: true }, (openErr, zipfile) => {
if (openErr !== null || zipfile === undefined) {
reject(new Error(`Failed to open zip: ${openErr?.message ?? 'unknown error'}`));
return;
}
const onEntry = (entry: Entry): void => {
const fileName = entry.fileName;
const destPath = path.resolve(destDir, fileName);
if (destPath !== destDirResolved && !destPath.startsWith(destDirResolved + path.sep)) {
if (!settled) {
settled = true;
reject(new Error(`Path traversal detected in zip entry: ${fileName}`));
}
zipfile.close();
return;
}
if (fileName.endsWith('/')) {
mkdir(destPath, { recursive: true })
.then(() => {
zipfile.readEntry();
})
.catch((error) => {
if (!settled) {
settled = true;
reject(error);
}
zipfile.close();
});
return;
}
zipfile.openReadStream(entry, (streamErr, stream) => {
if (streamErr !== null || stream === undefined) {
if (!settled) {
settled = true;
reject(
new Error(
`Failed to read ${fileName} from archive: ${streamErr?.message ?? 'unknown error'}`,
),
);
}
zipfile.close();
return;
}
mkdir(path.dirname(destPath), { recursive: true })
.then(() => pipeline(stream, createWriteStream(destPath)))
.then(() => restoreFilePermissions(destPath, entry))
.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 restoreFilePermissions(destPath: string, entry: Entry): Promise<void> {
const mode = entry.externalFileAttributes >>> 16;
if (mode === 0) return;
const permissions = mode & 0o777;
if (permissions === 0) return;
await chmod(destPath, permissions);
}
async function detectPluginRoot(dir: string): Promise<string> {
if (await hasManifest(dir)) return dir;
const entries = await readdir(dir, { withFileTypes: true });
const childDirs = entries.filter((entry) => entry.isDirectory());
const childDir = childDirs.length === 1 ? childDirs[0] : undefined;
if (childDir !== undefined) {
const child = path.join(dir, childDir.name);
if (await hasManifest(child)) return child;
}
return dir;
}
async function hasManifest(dir: string): Promise<boolean> {
const rootManifest = path.join(dir, 'kimi.plugin.json');
const dirManifest = path.join(dir, '.kimi-plugin', 'plugin.json');
return (await isFile(rootManifest)) || (await isFile(dirManifest));
}
async function isFile(p: string): Promise<boolean> {
try {
return (await stat(p)).isFile();
} catch {
return false;
}
}

View file

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

View file

@ -0,0 +1,441 @@
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';
// Hidden Kimi CLI subcommand that re-enters as a Node interpreter.
// Used as fallback when an MCP server declares `"command": "node"` but the
// user is running a single-binary Kimi build that doesn't have `node` on PATH.
const KIMI_NODE_FALLBACK_SUBCOMMAND = '__plugin_run_node';
export interface PluginManagerOptions {
readonly kimiHomeDir: string;
}
export class PluginManager {
private readonly kimiHomeDir: string;
private records = new Map<string, PluginRecord>();
constructor(options: PluginManagerOptions) {
this.kimiHomeDir = options.kimiHomeDir;
}
async load(): Promise<void> {
const file = await readInstalled(this.kimiHomeDir);
const next = new Map<string, PluginRecord>();
for (const entry of file.plugins) {
next.set(entry.id, await this.materialize(entry));
}
this.records = next;
}
list(): readonly PluginRecord[] {
return [...this.records.values()].toSorted((a, b) => a.id.localeCompare(b.id));
}
get(id: string): PluginRecord | undefined {
return this.records.get(normalizePluginId(id));
}
async install(source: string): Promise<PluginRecord> {
const resolved = resolveInstallSource(source);
let normalizedRoot: string;
let originalSource: string;
let sourceType: PluginSource;
let parsed: ParsedManifestResult;
let id: string;
if (resolved.kind === 'local-path') {
const sourceRoot = await normalizeInstallRoot(resolved.path);
originalSource = resolved.path;
sourceType = 'local-path';
parsed = await parseManifest(sourceRoot);
if (parsed.manifest === undefined) {
const msg = parsed.diagnostics.find((d) => d.severity === 'error')?.message ?? 'no manifest';
throw new Error(`Cannot install plugin at ${sourceRoot}: ${msg}`);
}
id = normalizePluginId(parsed.manifest.name);
normalizedRoot = await copyPluginToManagedRoot(this.kimiHomeDir, id, sourceRoot);
parsed = await parseManifest(normalizedRoot);
} else {
// zip-url
const buffer = await downloadZip(resolved.path);
const tmpDir = await mkdtemp(path.join(tmpdir(), 'kimi-plugin-zip-'));
try {
const detectedRoot = await extractZip(buffer, tmpDir);
parsed = await parseManifest(detectedRoot);
if (parsed.manifest === undefined) {
const msg = parsed.diagnostics.find((d) => d.severity === 'error')?.message ?? 'no manifest';
throw new Error(`Cannot install plugin from ${resolved.path}: ${msg}`);
}
id = normalizePluginId(parsed.manifest.name);
normalizedRoot = await copyPluginToManagedRoot(this.kimiHomeDir, id, detectedRoot);
parsed = await parseManifest(normalizedRoot);
} finally {
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}`);
}
id = normalizePluginId(parsed.manifest.name);
const existing = this.records.get(id);
const now = new Date().toISOString();
const record = recordFrom({
id,
root: normalizedRoot,
enabled: existing?.enabled ?? true,
installedAt: existing?.installedAt ?? now,
updatedAt: now,
originalSource,
source: sourceType,
capabilities: existing?.capabilities,
parsed,
});
this.records.set(id, record);
await this.persist();
return record;
}
async setEnabled(id: string, enabled: boolean): Promise<void> {
const key = normalizePluginId(id);
const current = this.records.get(key);
if (current === undefined) throw new Error(`Plugin "${id}" is not installed`);
if (current.enabled === enabled) return;
const now = new Date().toISOString();
this.records.set(key, { ...current, enabled, updatedAt: now });
await this.persist();
}
async setMcpServerEnabled(id: string, server: string, enabled: boolean): Promise<void> {
const key = normalizePluginId(id);
const current = this.records.get(key);
if (current === undefined) throw new Error(`Plugin "${id}" is not installed`);
if (current.manifest?.mcpServers?.[server] === undefined) {
throw new Error(`Plugin "${id}" does not declare MCP server "${server}"`);
}
const currentMcpServers = current.capabilities?.mcpServers ?? {};
const nextCapabilities: PluginCapabilityState = {
...current.capabilities,
mcpServers: {
...currentMcpServers,
[server]: { enabled },
},
};
this.records.set(key, {
...current,
capabilities: nextCapabilities,
updatedAt: new Date().toISOString(),
});
await this.persist();
}
async remove(id: string): Promise<void> {
const key = normalizePluginId(id);
if (!this.records.delete(key)) {
throw new Error(`Plugin "${id}" is not installed`);
}
await this.persist();
}
async reload(): Promise<ReloadSummary> {
const prevIds = new Set(this.records.keys());
const file = await readInstalled(this.kimiHomeDir);
const next = new Map<string, PluginRecord>();
const errors: Array<{ id: string; message: string }> = [];
for (const entry of file.plugins) {
try {
next.set(entry.id, await this.materialize(entry));
} catch (error) {
errors.push({ id: entry.id, message: (error as Error).message });
}
}
const added: string[] = [];
for (const id of next.keys()) if (!prevIds.has(id)) added.push(id);
const removed: string[] = [];
for (const id of prevIds) if (!next.has(id)) removed.push(id);
this.records = next;
return { added, removed, errors };
}
pluginSkillRoots(): readonly SkillRoot[] {
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[] {
const out: EnabledPluginSessionStart[] = [];
for (const record of this.records.values()) {
if (!record.enabled || record.state !== 'ok') continue;
const skill = record.manifest?.sessionStart?.skill;
if (skill === undefined) continue;
out.push({ pluginId: record.id, skillName: skill });
}
return out;
}
enabledMcpServers(): Record<string, McpServerConfig> {
const out: Record<string, McpServerConfig> = {};
for (const record of this.records.values()) {
if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue;
for (const [name, config] of Object.entries(record.manifest.mcpServers ?? {})) {
if (!isMcpServerEnabled(record, name, config)) continue;
out[pluginMcpRuntimeName(record.id, name)] = withPluginMcpRuntime(
withMcpServerEnabled(config, true),
record.root,
this.kimiHomeDir,
);
}
}
return out;
}
summaries(): readonly PluginSummary[] {
return this.list().map((record) => recordToSummary(record));
}
info(id: string): PluginInfo | undefined {
const record = this.get(id);
return record === undefined ? undefined : recordToInfo(record);
}
private async persist(): Promise<void> {
const installed: InstalledRecord[] = [...this.records.values()].map((record) => ({
id: record.id,
root: record.root,
source: record.source,
enabled: record.enabled,
installedAt: record.installedAt,
updatedAt: record.updatedAt,
originalSource: record.originalSource,
capabilities: record.capabilities,
}));
await writeInstalled(this.kimiHomeDir, { version: 1, plugins: installed });
}
private async materialize(entry: InstalledRecord): Promise<PluginRecord> {
const parsed = await parseManifest(entry.root);
return recordFrom({
id: entry.id,
root: entry.root,
enabled: entry.enabled,
installedAt: entry.installedAt,
updatedAt: entry.updatedAt,
originalSource: entry.originalSource,
capabilities: entry.capabilities,
source: entry.source,
parsed,
});
}
}
async function normalizeInstallRoot(rootPath: string): Promise<string> {
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;
}
async function copyPluginToManagedRoot(
kimiHomeDir: string,
id: string,
sourceRoot: string,
): Promise<string> {
const managedRoot = path.join(kimiHomeDir, 'plugins', 'managed', id);
const managedDir = path.dirname(managedRoot);
await mkdir(managedDir, { recursive: true });
const stagingRoot = await mkdtemp(path.join(managedDir, `${id}-`));
try {
await cp(sourceRoot, stagingRoot, { recursive: true });
await rm(managedRoot, { recursive: true, force: true });
await rename(stagingRoot, managedRoot);
} catch (error) {
await rm(stagingRoot, { recursive: true, force: true });
throw error;
}
return realpath(managedRoot);
}
function recordFrom(input: {
id: string;
root: string;
enabled: boolean;
installedAt: string;
updatedAt?: string;
originalSource?: string;
capabilities?: PluginCapabilityState;
source?: PluginSource;
parsed: ParsedManifestResult;
}): PluginRecord {
const { parsed } = input;
const hasError = parsed.diagnostics.some((d) => d.severity === 'error');
return {
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,
};
}
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,
config: McpServerConfig,
): boolean {
return record.capabilities?.mcpServers?.[name]?.enabled ?? config.enabled !== false;
}
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, config),
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, config),
transport: 'stdio',
command: config.command,
args: config.args,
cwd: config.cwd,
envKeys: config.env === undefined ? undefined : Object.keys(config.env).toSorted(),
};
}
function withMcpServerEnabled(config: McpServerConfig, enabled: boolean): McpServerConfig {
return { ...config, enabled };
}
function pluginMcpRuntimeName(pluginId: string, serverName: string): string {
// Plugin ids cannot contain ":", so this keeps plugin/server pairs unambiguous
// even when either side contains "-".
return `plugin-${pluginId}:${serverName}`;
}
function withPluginMcpRuntime(
config: McpServerConfig,
pluginRoot: string,
kimiHomeDir: string,
): McpServerConfig {
if (config.transport === 'http') return config;
const env = {
...config.env,
KIMI_CODE_HOME: kimiHomeDir,
KIMI_PLUGIN_ROOT: pluginRoot,
};
if (config.command === 'node' && isKimiNativeBinary()) {
return {
...config,
command: process.execPath,
args: [KIMI_NODE_FALLBACK_SUBCOMMAND, ...(config.args ?? [])],
cwd: config.cwd ?? pluginRoot,
env,
};
}
return { ...config, cwd: config.cwd ?? pluginRoot, env };
}
function isKimiNativeBinary(): boolean {
return !path.basename(process.execPath).toLowerCase().startsWith('node');
}

View file

@ -0,0 +1,389 @@
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 KIMI_PLUGIN_ROOT_PATH = 'kimi.plugin.json';
const KIMI_PLUGIN_DIR_PATH = '.kimi-plugin/plugin.json';
// Fields that look like third-party runtime extensions (Claude / Codex / old
// Kimi CLI). We do not run them; emit an info diagnostic so plugin authors and
// users can see why a field is silently ignored.
const UNSUPPORTED_RUNTIME_FIELDS = [
'tools',
'commands',
'hooks',
'apps',
'inject',
'configFile',
'config_file',
'bootstrap',
] as const;
export interface ParsedManifestResult {
readonly manifest?: PluginManifest;
readonly manifestKind?: PluginManifestKind;
readonly manifestPath?: string;
readonly shadowedManifestPath?: string;
readonly diagnostics: readonly PluginDiagnostic[];
}
export async function parseManifest(pluginRoot: string): Promise<ParsedManifestResult> {
const rootJsonPath = path.join(pluginRoot, KIMI_PLUGIN_ROOT_PATH);
const dirJsonPath = path.join(pluginRoot, KIMI_PLUGIN_DIR_PATH);
const rootJsonExists = await isFile(rootJsonPath);
const dirJsonExists = await isFile(dirJsonPath);
if (!rootJsonExists && !dirJsonExists) {
return {
diagnostics: [
{
severity: 'error',
message: `No manifest at ${KIMI_PLUGIN_ROOT_PATH} or ${KIMI_PLUGIN_DIR_PATH}`,
},
],
};
}
const manifestPath = rootJsonExists ? rootJsonPath : dirJsonPath;
const manifestKind: PluginManifestKind = rootJsonExists ? 'kimi-plugin-root' : 'kimi-plugin-dir';
const shadowedManifestPath = rootJsonExists && dirJsonExists ? dirJsonPath : undefined;
let raw: unknown;
try {
raw = JSON.parse(await readFile(manifestPath, 'utf8'));
} catch (error) {
return {
manifestKind,
manifestPath,
shadowedManifestPath,
diagnostics: [
{
severity: 'error',
message: `Failed to parse ${path.relative(pluginRoot, manifestPath)}: ${(error as Error).message}`,
},
],
};
}
if (!isObject(raw)) {
return {
manifestKind,
manifestPath,
shadowedManifestPath,
diagnostics: [{ severity: 'error', 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', message: '"name" is required' });
return { manifestKind, manifestPath, shadowedManifestPath, diagnostics };
}
if (!PLUGIN_NAME_REGEX.test(name)) {
diagnostics.push({
severity: 'error',
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;
recordUnsupportedRuntimeFields(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 recordUnsupportedRuntimeFields(
raw: Record<string, unknown>,
diagnostics: PluginDiagnostic[],
): void {
for (const field of UNSUPPORTED_RUNTIME_FIELDS) {
if (raw[field] === undefined) continue;
diagnostics.push({
severity: 'info',
message: `"${field}" is present but not supported by Kimi plugins`,
});
}
}
async function resolveSkillsField(
pluginRoot: string,
raw: unknown,
diagnostics: PluginDiagnostic[],
): Promise<readonly string[]> {
if (raw === undefined) return [];
const entries: string[] = [];
if (typeof raw === 'string') {
entries.push(raw);
} else if (Array.isArray(raw) && raw.every((entry) => typeof entry === 'string')) {
entries.push(...raw);
} else {
diagnostics.push({ severity: 'error', message: '"skills" must be a string or string[]' });
return [];
}
const resolved: string[] = [];
for (const entry of entries) {
if (!entry.startsWith('./')) {
diagnostics.push({
severity: 'error',
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;
}
const rootReal = await realpath(pluginRoot).catch(() => pluginRoot);
if (!isWithin(real, rootReal)) {
diagnostics.push({
severity: 'error',
message: `"skills" path resolves outside the plugin (${entry})`,
});
continue;
}
if (!(await isDir(real))) {
diagnostics.push({
severity: 'warn',
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[];
}): Promise<string | undefined> {
if (!input.value.startsWith('./')) {
input.diagnostics.push({
severity: 'warn',
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',
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', 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',
message: '"sessionStart.skill" is required when sessionStart is present',
});
return undefined;
}
return { skill };
}
async function readMcpServers(
pluginRoot: string,
raw: unknown,
diagnostics: PluginDiagnostic[],
): Promise<PluginManifest['mcpServers']> {
if (raw === undefined) return undefined;
if (!isObject(raw)) {
diagnostics.push({ severity: 'warn', message: '"mcpServers" must be an object' });
return undefined;
}
const out: Record<string, McpServerConfig> = {};
for (const [name, value] of Object.entries(raw)) {
const trimmedName = name.trim();
if (trimmedName.length === 0) {
diagnostics.push({
severity: 'warn',
message: '"mcpServers" entries must have a non-empty name',
});
continue;
}
const parsed = McpServerConfigSchema.safeParse(value);
if (!parsed.success) {
diagnostics.push({
severity: 'warn',
message: `Invalid MCP server "${trimmedName}": ${parsed.error.message}`,
});
continue;
}
const normalized = await normalizePluginMcpServer({
pluginRoot,
name: trimmedName,
config: parsed.data,
diagnostics,
});
if (normalized !== undefined) out[trimmedName] = normalized;
}
return Object.keys(out).length === 0 ? undefined : out;
}
async function normalizePluginMcpServer(input: {
readonly pluginRoot: string;
readonly name: string;
readonly config: McpServerConfig;
readonly diagnostics: PluginDiagnostic[];
}): Promise<McpServerConfig | undefined> {
const { config } = input;
if (config.transport === 'http') return config;
let command = config.command;
if (command.startsWith('./')) {
const resolvedCommand = await resolvePluginPathField({
pluginRoot: input.pluginRoot,
field: `mcpServers.${input.name}.command`,
value: command,
diagnostics: input.diagnostics,
});
if (resolvedCommand === undefined) return undefined;
command = resolvedCommand;
} else if (command.includes('/') || path.isAbsolute(command)) {
input.diagnostics.push({
severity: 'warn',
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,
});
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'),
websiteURL: stringField(raw, 'websiteURL'),
};
const hasAny = Object.values(out).some((value) => value !== undefined);
return hasAny ? out : undefined;
}
function stringField(raw: Record<string, unknown>, key: string): string | undefined {
const value = raw[key];
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed.length === 0 ? undefined : trimmed;
}
function stringArrayField(raw: Record<string, unknown>, key: string): readonly string[] | undefined {
const value = raw[key];
if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) {
return undefined;
}
return value as readonly string[];
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isWithin(child: string, parent: string): boolean {
const relative = path.relative(parent, child);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
async function isFile(p: string): Promise<boolean> {
try {
return (await stat(p)).isFile();
} catch {
return false;
}
}
async function isDir(p: string): Promise<boolean> {
try {
return (await stat(p)).isDirectory();
} catch {
return false;
}
}

View file

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

View file

@ -0,0 +1,59 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { PluginCapabilityState, PluginSource } from './types';
const INSTALLED_REL = path.join('plugins', 'installed.json');
export interface InstalledRecord {
readonly id: string;
readonly root: string;
readonly source: PluginSource;
readonly enabled: boolean;
readonly installedAt: string;
readonly updatedAt?: string;
readonly originalSource?: string;
readonly capabilities?: PluginCapabilityState;
}
export interface InstalledFile {
readonly version: 1;
readonly plugins: readonly InstalledRecord[];
}
const EMPTY: InstalledFile = { version: 1, plugins: [] };
export async function readInstalled(kimiHomeDir: string): Promise<InstalledFile> {
const filePath = path.join(kimiHomeDir, INSTALLED_REL);
let text: string;
try {
text = await readFile(filePath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return EMPTY;
throw error;
}
try {
const parsed = JSON.parse(text) as InstalledFile;
if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.plugins)) {
throw new Error('installed.json is not a valid InstalledFile object');
}
return parsed;
} catch (error) {
throw new Error(
`Failed to parse ${filePath}: ${(error as Error).message}`,
{ cause: error },
);
}
}
export async function writeInstalled(
kimiHomeDir: string,
data: InstalledFile,
): Promise<void> {
const dir = path.join(kimiHomeDir, 'plugins');
await mkdir(dir, { recursive: true });
const final = path.join(dir, 'installed.json');
const tmp = `${final}.tmp`;
await writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
await rename(tmp, final);
}

View file

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

View file

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

View file

@ -25,12 +25,18 @@ import type {
GetBackgroundOutputPathPayload,
GetBackgroundOutputPayload,
GetBackgroundPayload,
GetPluginInfoPayload,
InstallPluginPayload,
ListSessionsPayload,
McpServerInfo,
McpStartupMetrics,
PluginInfo,
PluginSummary,
PromptPayload,
ReconnectMcpServerPayload,
ReloadPluginsResult,
RemoveKimiProviderPayload,
RemovePluginPayload,
RenameSessionPayload,
ResumeSessionPayload,
RegisterToolPayload,
@ -39,6 +45,8 @@ import type {
SetModelPayload,
SetModelResult,
SetPermissionPayload,
SetPluginEnabledPayload,
SetPluginMcpServerEnabledPayload,
SetThinkingPayload,
SkillSummary,
SteerPayload,
@ -53,7 +61,9 @@ import type { SDKRPC } from './sdk-api';
import { proxyWithExtraPayload } from './types';
import type { PromisableMethods } from '#/utils/types';
import { resolveSessionMcpConfig } from '../mcp';
import { PluginManager } from '#/plugin';
import { resolveSessionMcpConfig, type SessionMcpConfig } from '../mcp';
import { Session, type SessionMeta, type SessionSkillConfig } from '../session';
import { SessionAPIImpl } from '../session/rpc';
import {
@ -109,6 +119,9 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
private readonly skillDirs: readonly string[];
private readonly providerManager: ProviderManager;
private readonly sessionStore: SessionStore;
readonly plugins: PluginManager;
private pluginsReady: Promise<void>;
private pluginsLoadError: Error | undefined;
constructor(
protected readonly rpcClient: CoreRPCClient,
@ -132,6 +145,14 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
resolveOAuthTokenProvider: this.resolveOAuthTokenProvider,
});
this.sessionStore = new SessionStore(this.homeDir);
this.plugins = new PluginManager({ kimiHomeDir: this.homeDir });
// 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 +165,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
const modelName = this.providerManager.resolveSelectedModel(options.model);
const thinkingLevel = this.providerManager.resolveThinkingLevel(options.thinking);
const permissionMode = options.permission ?? config.defaultPermissionMode;
const mcpConfig = await resolveSessionMcpConfig({
const baseMcpConfig = await resolveSessionMcpConfig({
cwd: workDir,
homeDir: this.homeDir,
});
@ -157,6 +178,10 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
metadata: options.metadata,
};
await this.pluginsReady;
const pluginSessionStarts = this.plugins.enabledSessionStarts();
const mcpConfig = this.mergePluginMcpConfig(baseMcpConfig);
// Session ctor attaches its own log sink. If anything in the setup-after-
// ctor block throws, `session.close()` releases the sink (and mcp).
const session = new Session({
@ -173,6 +198,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
skills: this.resolveSessionSkillConfig(config),
mcpConfig,
telemetry: withTelemetryContext(this.telemetry, { sessionId: summary.id }),
pluginSessionStarts,
});
try {
session.metadata = {
@ -231,10 +257,13 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
}
const config = this.reloadProviderManager();
const mcpConfig = await resolveSessionMcpConfig({
const baseMcpConfig = await resolveSessionMcpConfig({
cwd: summary.workDir,
homeDir: this.homeDir,
});
await this.pluginsReady;
const pluginSessionStarts = this.plugins.enabledSessionStarts();
const mcpConfig = this.mergePluginMcpConfig(baseMcpConfig);
const session = new Session({
runtime: await this.resolveRuntime(config),
id: summary.id,
@ -250,6 +279,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
mcpConfig,
telemetry: withTelemetryContext(this.telemetry, { sessionId: summary.id }),
initializeMainAgent: false,
pluginSessionStarts,
});
let warning: string | undefined;
try {
@ -536,6 +566,80 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
return this.sessionApi(sessionId).generateAgentsMd(payload);
}
async installPlugin(payload: InstallPluginPayload): Promise<PluginSummary> {
await this.pluginsReady;
this.assertPluginsLoaded();
const record = await this.plugins.install(payload.source);
return this.plugins.summaries().find((s) => s.id === record.id)!;
}
async listPlugins(_: EmptyPayload): Promise<readonly PluginSummary[]> {
await this.pluginsReady;
this.assertPluginsLoaded();
return this.plugins.summaries();
}
async setPluginEnabled({ id, enabled }: SetPluginEnabledPayload): Promise<void> {
await this.pluginsReady;
this.assertPluginsLoaded();
await this.plugins.setEnabled(id, enabled);
}
async setPluginMcpServerEnabled({
id,
server,
enabled,
}: SetPluginMcpServerEnabledPayload): Promise<void> {
await this.pluginsReady;
this.assertPluginsLoaded();
await this.plugins.setMcpServerEnabled(id, server, enabled);
}
async removePlugin({ id }: RemovePluginPayload): Promise<void> {
await this.pluginsReady;
this.assertPluginsLoaded();
await this.plugins.remove(id);
}
async reloadPlugins(_: EmptyPayload): Promise<ReloadPluginsResult> {
try {
const summary = await this.plugins.reload();
this.pluginsLoadError = undefined;
return summary;
} catch (error) {
this.pluginsLoadError = error instanceof Error ? error : new Error(String(error));
throw new KimiError(
ErrorCodes.PLUGIN_LOAD_FAILED,
`Failed to reload plugins: ${this.pluginsLoadError.message}`,
{ cause: error, details: { kimiHomeDir: this.homeDir } },
);
}
}
async getPluginInfo({ id }: GetPluginInfoPayload): Promise<PluginInfo> {
await this.pluginsReady;
this.assertPluginsLoaded();
const info = this.plugins.info(id);
if (info === undefined) {
throw new KimiError(
ErrorCodes.PLUGIN_NOT_FOUND,
`Plugin "${id}" is not installed`,
{ details: { id } },
);
}
return info;
}
private assertPluginsLoaded(): void {
if (this.pluginsLoadError === undefined) return;
throw new KimiError(
ErrorCodes.PLUGIN_LOAD_FAILED,
`Plugin state failed to load: ${this.pluginsLoadError.message}. ` +
`Fix the file at ${this.homeDir}/plugins/installed.json and run /plugins reload.`,
{ cause: this.pluginsLoadError, details: { kimiHomeDir: this.homeDir } },
);
}
private async resolveRuntime(config: KimiConfig): Promise<RuntimeConfig> {
if (this.runtime !== undefined) return this.runtime;
const runtime = await createRuntimeConfig({
@ -553,10 +657,22 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
userHomeDir: this.userHomeDir,
explicitDirs,
extraDirs: config.extraSkillDirs,
pluginSkillRoots: this.plugins.pluginSkillRoots(),
mergeAllAvailableSkills: config.mergeAllAvailableSkills,
};
}
private mergePluginMcpConfig(base: SessionMcpConfig | undefined): SessionMcpConfig | undefined {
const pluginServers = this.plugins.enabledMcpServers();
if (Object.keys(pluginServers).length === 0) return base;
return {
servers: {
...base?.servers,
...pluginServers,
},
};
}
private sessionApi(sessionId: string): SessionAPIImpl {
const session = this.sessions.get(sessionId);
if (session === undefined) {

View file

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

View file

@ -23,6 +23,7 @@ export interface SkillRegistryOptions {
export class SkillRegistry {
private readonly byName = new Map<string, SkillDefinition>();
private readonly byPluginAndName = new Map<string, SkillDefinition>();
private readonly roots: string[] = [];
private readonly skipped: SkippedSkill[] = [];
private readonly discoverImpl: typeof discoverSkills;
@ -44,6 +45,9 @@ export class SkillRegistry {
roots,
onWarning: this.onWarning,
onSkippedByPolicy: (skill) => this.skipped.push(skill),
onDiscoveredSkill: (skill) => {
this.indexPluginSkill(skill);
},
} satisfies DiscoverSkillsOptions);
for (const skill of skills) {
@ -60,19 +64,44 @@ export class SkillRegistry {
if (options.replace === true || !this.byName.has(key)) {
this.byName.set(key, skill);
}
this.indexPluginSkill(skill, options);
}
getSkill(name: string): SkillDefinition | undefined {
return this.byName.get(normalizeSkillName(name));
}
getPluginSkill(pluginId: string, name: string): SkillDefinition | undefined {
return this.byPluginAndName.get(pluginSkillKey(pluginId, name));
}
private indexPluginSkill(
skill: SkillDefinition,
options: { readonly replace?: boolean } = {},
): void {
if (skill.plugin === undefined) return;
const key = pluginSkillKey(skill.plugin.id, skill.name);
if (options.replace === true || !this.byPluginAndName.has(key)) {
this.byPluginAndName.set(key, skill);
}
}
renderSkillPrompt(skill: SkillDefinition, rawArgs: string): string {
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;
return (
`<kimi-plugin-instructions plugin="${escapeAttr(plugin.id)}">\n` +
`${instructions}\n` +
`</kimi-plugin-instructions>\n\n${content}`
);
}
listSkills(): readonly SkillDefinition[] {
@ -109,6 +138,10 @@ export class SkillRegistry {
}
}
function pluginSkillKey(pluginId: string, skillName: string): string {
return `${pluginId}\0${normalizeSkillName(skillName)}`;
}
const SOURCE_GROUPS: ReadonlyArray<{ readonly source: SkillSource; readonly label: string }> = [
{ source: 'project', label: 'Project' },
{ source: 'user', label: 'User' },
@ -148,3 +181,7 @@ function formatModelSkill(skill: SkillDefinition): readonly string[] {
function truncate(value: string, max: number): string {
return value.length > max ? value.slice(0, max) : value;
}
function escapeAttr(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;');
}

View file

@ -24,6 +24,7 @@ export interface ResolveSkillRootsOptions {
readonly builtinDir?: string;
readonly explicitDirs?: readonly string[];
readonly extraDirs?: readonly string[];
readonly pluginSkillRoots?: readonly SkillRoot[];
readonly mergeAllAvailableSkills?: boolean;
readonly realpath?: (p: string) => Promise<string>;
readonly isDir?: (p: string) => Promise<boolean>;
@ -33,6 +34,7 @@ export interface DiscoverSkillsOptions {
readonly roots: readonly SkillRoot[];
readonly onWarning?: (message: string, cause?: unknown) => void;
readonly onSkippedByPolicy?: (skill: SkippedSkill) => void;
readonly onDiscoveredSkill?: (skill: SkillDefinition) => void;
readonly readdir?: (p: string) => Promise<readonly string[]>;
readonly isFile?: (p: string) => Promise<boolean>;
readonly isDir?: (p: string) => Promise<boolean>;
@ -105,6 +107,12 @@ export async function resolveSkillRoots(
);
}
if (options.pluginSkillRoots !== undefined) {
for (const root of options.pluginSkillRoots) {
await pushProvidedRoot(roots, root, isDir, realpath);
}
}
if (options.builtinDir !== undefined) {
await pushExistingRoot(roots, options.builtinDir, 'builtin', isDir, realpath);
}
@ -125,7 +133,7 @@ export async function discoverSkills(
async function walkSkillDir(
dirPath: string,
source: SkillSource,
root: SkillRoot,
isTopLevel: boolean,
depth: number,
): Promise<void> {
@ -160,7 +168,8 @@ export async function discoverSkills(
byName,
skillMdPath: path.join(dirPath, entry, 'SKILL.md'),
skillDirName: entry,
source,
root,
onDiscoveredSkill: options.onDiscoveredSkill,
warn,
skip,
});
@ -169,6 +178,25 @@ 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,
onDiscoveredSkill: options.onDiscoveredSkill,
warn,
skip,
});
}
}
for (const entry of entries) {
if (!entry.endsWith('.md')) continue;
if (entry === 'SKILL.md') continue;
@ -186,7 +214,8 @@ export async function discoverSkills(
byName,
skillMdPath,
skillDirName: skillName,
source,
root,
onDiscoveredSkill: options.onDiscoveredSkill,
warn,
skip,
});
@ -194,12 +223,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 +316,33 @@ async function pushExistingRoot(
return true;
}
async function pushProvidedRoot(
out: SkillRoot[],
root: SkillRoot,
isDir: (p: string) => Promise<boolean>,
realpath: (p: string) => Promise<string>,
): Promise<boolean> {
if (!(await isDir(root.path))) return false;
const resolved = await realpath(root.path);
const existingIndex = out.findIndex((existing) => existing.path === resolved);
if (existingIndex < 0) {
out.push({ ...root, path: resolved });
return true;
}
const existing = out[existingIndex];
if (existing !== undefined && existing.plugin === undefined && root.plugin !== undefined) {
out[existingIndex] = { ...existing, plugin: root.plugin };
}
return true;
}
async function parseAndRegister(input: {
readonly parse: NonNullable<DiscoverSkillsOptions['parse']>;
readonly byName: Map<string, SkillDefinition>;
readonly skillMdPath: string;
readonly skillDirName: string;
readonly source: SkillSource;
readonly root: SkillRoot;
readonly onDiscoveredSkill?: (skill: SkillDefinition) => void;
readonly warn: (message: string, cause?: unknown) => void;
readonly skip: (skill: SkippedSkill) => void;
}): Promise<void> {
@ -300,10 +350,17 @@ 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);
const discovered = input.root.plugin === undefined ? skill : {
...skill,
plugin: input.root.plugin,
};
input.onDiscoveredSkill?.(discovered);
const key = normalizeSkillName(discovered.name);
if (!input.byName.has(key)) {
input.byName.set(key, discovered);
}
} catch (error) {
if (error instanceof UnsupportedSkillTypeError) {
input.skip({

View file

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

View file

@ -0,0 +1,212 @@
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;
getPluginSkill: (pluginId: string, 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 byPluginAndName = new Map(
input.skills.flatMap((s) =>
s.plugin === undefined ? [] : [[`${s.plugin.id}\0${s.name.toLowerCase()}`, s] as const],
),
);
const history: unknown[] = [...(input.history ?? [])];
const warnings: CapturedWarn[] = [];
const agent: StubSessionStartAgent = {
pluginSessionStarts: input.sessionStarts,
skills: {
registry: {
getSkill: (name) => byName.get(name.toLowerCase()),
getPluginSkill: (pluginId, name) =>
byPluginAndName.get(`${pluginId}\0${name.toLowerCase()}`),
renderSkillPrompt: (skill) => {
const plugin = skill.plugin;
if (plugin === undefined) return skill.content;
const instructions = plugin.instructions;
if (instructions === undefined) return skill.content;
return `<kimi-plugin-instructions plugin="${plugin.id}">\n${instructions}\n</kimi-plugin-instructions>\n\n${skill.content}`;
},
},
},
log: {
warn: (message, payload) => warnings.push({ message, payload }),
info: () => {},
debug: () => {},
error: () => {},
},
context: {
history,
appendSystemReminder: (content: string, origin: PromptOrigin) => {
history.push({ role: 'user', content: [{ type: 'text', text: content }], origin });
},
},
};
return { agent: agent as unknown as Agent, warnings };
}
function lastReminder(agent: Agent): string {
const history = (agent.context as unknown as { history: Array<{ role: string; content?: ReadonlyArray<{ text?: string }> }> }).history;
const last = history.findLast((message) => message.role === 'user');
return last?.content?.map((part) => part.text ?? '').join('') ?? '';
}
describe('PluginSessionStartInjector', () => {
it('injects one <plugin_session_start> block per declared sessionStart on first call', async () => {
const { agent } = sessionStartAgent({
sessionStarts: [{ pluginId: 'superpowers', skillName: 'using-superpowers' }],
skills: [
skill('using-superpowers', 'body of skill', {
id: 'superpowers',
instructions: 'Use AskUserQuestion and TodoList.',
}),
],
});
const injector = new PluginSessionStartInjector(agent);
await injector.inject();
const text = lastReminder(agent);
expect(text).toContain('<plugin_session_start plugin="superpowers" skill="using-superpowers">');
expect(text).toContain('<kimi-plugin-instructions plugin="superpowers">');
expect(text).toContain('AskUserQuestion');
expect(text).toContain('TodoList');
expect(text).toContain('body of skill');
expect(text).toContain('</plugin_session_start>');
});
it('does not hard-code Superpowers guidance when the skill has no plugin instructions', async () => {
const { agent } = sessionStartAgent({
sessionStarts: [{ pluginId: 'superpowers', skillName: 'using-superpowers' }],
skills: [skill('using-superpowers', 'body', { id: 'superpowers' })],
});
const injector = new PluginSessionStartInjector(agent);
await injector.inject();
const text = lastReminder(agent);
expect(text).toContain('<plugin_session_start plugin="superpowers" skill="using-superpowers">');
expect(text).toContain('body');
expect(text).not.toContain('<kimi-plugin-instructions plugin="superpowers">');
expect(text).not.toContain('AskUserQuestion');
});
it('does not re-inject on subsequent calls within the same session', async () => {
const { agent } = sessionStartAgent({
sessionStarts: [{ pluginId: 'superpowers', skillName: 'using-superpowers' }],
skills: [skill('using-superpowers', 'body', { id: 'superpowers' })],
});
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', { id: 'superpowers' })],
history: [
{
role: 'user',
content: [{ type: 'text', text: '<system-reminder>old</system-reminder>' }],
origin: { kind: 'injection', variant: 'plugin_session_start' },
},
],
});
const injector = new PluginSessionStartInjector(agent);
await injector.inject();
const history = (agent.context as unknown as { history: unknown[] }).history;
expect(history).toHaveLength(1);
});
it('skips a sessionStart whose skill is not registered and warns', async () => {
const { agent, warnings } = sessionStartAgent({
sessionStarts: [
{ pluginId: 'demo', skillName: 'missing' },
{ pluginId: 'superpowers', skillName: 'using-superpowers' },
],
skills: [skill('using-superpowers', 'body', { id: 'superpowers' })],
});
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([]);
});
it('resolves sessionStart skills by plugin identity when names collide', async () => {
const { agent } = sessionStartAgent({
sessionStarts: [{ pluginId: 'superpowers', skillName: 'using-superpowers' }],
skills: [
skill('using-superpowers', 'project body'),
skill('using-superpowers', 'plugin body', { id: 'superpowers' }),
],
});
const injector = new PluginSessionStartInjector(agent);
await injector.inject();
const text = lastReminder(agent);
expect(text).toContain('plugin body');
expect(text).not.toContain('project body');
});
});

View file

@ -0,0 +1,237 @@
import { describe, expect, it } from 'vitest';
import { mkdtemp, readFile, stat } 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; mode?: number }>,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const zipfile = new yazl.ZipFile();
const chunks: Buffer[] = [];
zipfile.outputStream.on('data', (chunk) => chunks.push(chunk));
zipfile.outputStream.on('end', () => {
resolve(Buffer.concat(chunks));
});
zipfile.outputStream.on('error', reject);
for (const entry of entries) {
zipfile.addBuffer(
Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data),
entry.name,
entry.mode === undefined ? undefined : { mode: entry.mode },
);
}
zipfile.end();
});
}
function createMinimalZip(entryName: string, content: string): Buffer {
const nameBuf = Buffer.from(entryName, 'utf8');
const dataBuf = Buffer.from(content, 'utf8');
const crc = crc32(dataBuf);
const localHeader = Buffer.alloc(30);
localHeader.writeUInt32LE(0x04034b50, 0);
localHeader.writeUInt16LE(20, 4);
localHeader.writeUInt16LE(0, 6);
localHeader.writeUInt16LE(0, 8);
localHeader.writeUInt16LE(0, 10);
localHeader.writeUInt16LE(0, 12);
localHeader.writeUInt32LE(crc, 14);
localHeader.writeUInt32LE(dataBuf.length, 18);
localHeader.writeUInt32LE(dataBuf.length, 22);
localHeader.writeUInt16LE(nameBuf.length, 26);
localHeader.writeUInt16LE(0, 28);
const cdHeader = Buffer.alloc(46);
cdHeader.writeUInt32LE(0x02014b50, 0);
cdHeader.writeUInt16LE(20, 4);
cdHeader.writeUInt16LE(20, 6);
cdHeader.writeUInt16LE(0, 8);
cdHeader.writeUInt16LE(0, 10);
cdHeader.writeUInt16LE(0, 12);
cdHeader.writeUInt16LE(0, 14);
cdHeader.writeUInt32LE(crc, 16);
cdHeader.writeUInt32LE(dataBuf.length, 20);
cdHeader.writeUInt32LE(dataBuf.length, 24);
cdHeader.writeUInt16LE(nameBuf.length, 28);
cdHeader.writeUInt16LE(0, 30);
cdHeader.writeUInt16LE(0, 32);
cdHeader.writeUInt16LE(0, 34);
cdHeader.writeUInt16LE(0, 36);
cdHeader.writeUInt32LE(0, 38);
cdHeader.writeUInt32LE(0, 42);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(1, 8);
eocd.writeUInt16LE(1, 10);
eocd.writeUInt32LE(46 + nameBuf.length, 12);
eocd.writeUInt32LE(30 + nameBuf.length + dataBuf.length, 16);
eocd.writeUInt16LE(0, 20);
return Buffer.concat([localHeader, nameBuf, dataBuf, cdHeader, nameBuf, eocd]);
}
async function serveOnce(buffer: Buffer): Promise<string> {
const { createServer } = await import('node:http');
return new Promise((resolve) => {
const server = createServer((_, res) => {
res.writeHead(200, { 'Content-Type': 'application/zip' });
res.end(buffer);
server.close();
});
server.listen(0, '127.0.0.1', () => {
const addr = server.address()!;
resolve(`http://127.0.0.1:${(addr as any).port}`);
});
});
}
describe('downloadZip', () => {
it('downloads a zip from a URL', async () => {
const zipBuffer = await createZipBuffer([{ name: 'test.txt', data: 'hello' }]);
const url = await serveOnce(zipBuffer);
const result = await downloadZip(url);
expect(result).toBeInstanceOf(Buffer);
expect(result.length).toBeGreaterThan(0);
});
it('throws on HTTP error', async () => {
const { createServer } = await import('node:http');
const url = await new Promise<string>((resolve) => {
const server = createServer((_, res) => {
res.writeHead(404);
res.end('Not found');
server.close();
});
server.listen(0, '127.0.0.1', () => {
const addr = server.address()!;
resolve(`http://127.0.0.1:${(addr as any).port}`);
});
});
await expect(downloadZip(url)).rejects.toThrow(/Failed to download zip: HTTP 404/i);
});
});
describe('extractZip', () => {
it('extracts flat files and returns root when no manifest found', async () => {
const destDir = await mkdtemp(path.join(tmpdir(), 'archive-test-'));
const zipBuffer = await createZipBuffer([
{ name: 'readme.txt', data: 'hello' },
{ name: 'data/info.json', data: '{}' },
]);
const root = await extractZip(zipBuffer, destDir);
expect(root).toBe(destDir);
const readme = await readFile(path.join(destDir, 'readme.txt'), 'utf8');
expect(readme).toBe('hello');
});
it('prefers a plugin manifest at the archive root', async () => {
const destDir = await mkdtemp(path.join(tmpdir(), 'archive-test-'));
const zipBuffer = await createZipBuffer([
{ name: 'kimi.plugin.json', data: '{"name":"root"}' },
{ name: 'examples/demo/kimi.plugin.json', data: '{"name":"demo"}' },
]);
const root = await extractZip(zipBuffer, destDir);
expect(root).toBe(destDir);
});
it('detects plugin root with kimi.plugin.json', async () => {
const destDir = await mkdtemp(path.join(tmpdir(), 'archive-test-'));
const zipBuffer = await createZipBuffer([
{ name: 'my-plugin/kimi.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, 'kimi.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 a single wrapper directory before nested manifests', async () => {
const destDir = await mkdtemp(path.join(tmpdir(), 'archive-test-'));
const zipBuffer = await createZipBuffer([
{ name: 'outer/kimi.plugin.json', data: '{"name":"outer"}' },
{ name: 'outer/inner/kimi.plugin.json', data: '{"name":"inner"}' },
]);
const root = await extractZip(zipBuffer, destDir);
expect(root).toBe(path.join(destDir, 'outer'));
});
it('ignores deep nested plugin manifests when there is no root or wrapper manifest', async () => {
const destDir = await mkdtemp(path.join(tmpdir(), 'archive-test-'));
const zipBuffer = await createZipBuffer([
{ name: 'examples/demo/kimi.plugin.json', data: '{"name":"demo"}' },
{ name: 'examples/demo/readme.md', data: '# Demo' },
]);
const root = await extractZip(zipBuffer, destDir);
expect(root).toBe(destDir);
});
it('preserves executable file permission bits', async () => {
if (process.platform === 'win32') return;
const destDir = await mkdtemp(path.join(tmpdir(), 'archive-test-'));
const zipBuffer = await createZipBuffer([
{ name: 'my-plugin/kimi.plugin.json', data: '{"name":"test"}' },
{ name: 'my-plugin/bin/server', data: '#!/bin/sh\n', mode: 0o100755 },
]);
const root = await extractZip(zipBuffer, destDir);
const executable = await stat(path.join(root, 'bin', 'server'));
expect(executable.mode & 0o777).toBe(0o755);
});
it('rejects entries with path traversal', async () => {
const destDir = await mkdtemp(path.join(tmpdir(), 'archive-test-'));
const zipBuffer = createMinimalZip('../escape.txt', 'bad');
await expect(extractZip(zipBuffer, destDir)).rejects.toThrow(/invalid relative path/i);
});
it('rejects entries with .. in path', async () => {
const destDir = await mkdtemp(path.join(tmpdir(), 'archive-test-'));
const zipBuffer = createMinimalZip('a/../../escape.txt', 'bad');
await expect(extractZip(zipBuffer, destDir)).rejects.toThrow(/invalid relative path/i);
});
it('accepts file names containing dots that are not path components', async () => {
const destDir = await mkdtemp(path.join(tmpdir(), 'archive-test-'));
const zipBuffer = await createZipBuffer([
{ name: 'foo..bar.txt', data: 'ok' },
{ name: 'dir/..hidden.md', data: 'ok' },
]);
await extractZip(zipBuffer, destDir);
expect(await readFile(path.join(destDir, 'foo..bar.txt'), 'utf8')).toBe('ok');
expect(await readFile(path.join(destDir, 'dir', '..hidden.md'), 'utf8')).toBe('ok');
});
});

View file

@ -0,0 +1,35 @@
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, 'kimi.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);
const managedRoot = await realpath(path.join(home, 'plugins', 'managed', 'demo'));
expect(manager.pluginSkillRoots()).toContainEqual({
path: path.join(managedRoot, 'skills'),
source: 'extra',
plugin: { id: 'demo', instructions: undefined },
});
});
});

View file

@ -0,0 +1,663 @@
import { mkdir, mkdtemp, realpath, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import yazl from 'yazl';
import { PluginManager } from '../../src/plugin/manager';
async function makeKimiHome(): Promise<string> {
return mkdtemp(path.join(tmpdir(), 'kimi-home-'));
}
async function managedPluginRoot(home: string, id: string): Promise<string> {
return realpath(path.join(home, 'plugins', 'managed', id));
}
async function makePlugin(
name: string,
options: {
skills?: boolean;
version?: string;
sessionStartSkill?: string;
mcpServers?: Record<string, unknown>;
} = {},
): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), `plugin-${name}-`));
const manifest: Record<string, unknown> = { name };
if (options.version !== undefined) {
manifest['version'] = options.version;
}
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, 'kimi.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(await managedPluginRoot(home, 'demo'));
expect(manager.get('demo')?.originalSource).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 managedRoot = await managedPluginRoot(home, 'superpowers');
expect(record.id).toBe('superpowers');
expect(record.manifestKind).toBe('kimi-plugin-dir');
expect(record.root).toBe(managedRoot);
expect(record.originalSource).toBe(root);
expect(record.manifest?.skills).toEqual([path.join(managedRoot, 'skills')]);
expect(manager.pluginSkillRoots()).toContainEqual({
path: path.join(managedRoot, '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() copies a symlinked plugin root into the managed plugins dir', 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);
const managedRoot = await managedPluginRoot(home, 'demo');
expect(record.root).toBe(managedRoot);
expect(record.originalSource).toBe(link);
const reloaded = new PluginManager({ kimiHomeDir: home });
await reloaded.load();
expect(reloaded.get('demo')?.root).toBe(managedRoot);
});
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);
const managedA = await managedPluginRoot(home, 'a');
const managedB = await managedPluginRoot(home, 'b');
expect(manager.pluginSkillRoots()).toContainEqual({
path: path.join(managedA, 'skills'),
source: 'extra',
plugin: { id: 'a', instructions: undefined },
});
expect(manager.pluginSkillRoots()).not.toContainEqual({
path: path.join(managedB, 'skills'),
source: 'extra',
plugin: { id: 'b', instructions: undefined },
});
});
it('reload() picks up edits to the managed plugin copy', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo');
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
const managedRoot = await managedPluginRoot(home, 'demo');
await writeFile(
path.join(managedRoot, 'kimi.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('reload() does not reread the original local source after install', 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, 'kimi.plugin.json'),
JSON.stringify({ name: 'demo', version: 'source-edit' }),
'utf8',
);
const summary = await manager.reload();
expect(summary.errors).toEqual([]);
expect(manager.get('demo')?.manifest?.version).toBeUndefined();
});
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() overwrites the same local plugin and preserves user state', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo', {
version: '1.0.0',
mcpServers: { finance: { command: 'finance-mcp' } },
});
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
const first = await manager.install(root);
await manager.setMcpServerEnabled('demo', 'finance', false);
await manager.setEnabled('demo', false);
await new Promise((r) => setTimeout(r, 10));
const updatedRoot = await makePlugin('demo', {
version: '2.0.0',
mcpServers: { finance: { command: 'finance-mcp-v2' } },
});
const updated = await manager.install(updatedRoot);
expect(manager.list()).toHaveLength(1);
expect(updated.manifest?.version).toBe('2.0.0');
expect(updated.enabled).toBe(false);
expect(updated.installedAt).toBe(first.installedAt);
expect(updated.updatedAt).not.toBe(first.updatedAt);
expect(updated.originalSource).toBe(updatedRoot);
expect(manager.info('demo')?.mcpServers[0]?.enabled).toBe(false);
});
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(await managedPluginRoot(home, 'demo'), 'kimi.plugin.json'),
'{ not json',
'utf8',
);
await manager.reload();
const record = manager.get('demo');
expect(record?.state).toBe('error');
expect(record?.diagnostics).toContainEqual(
expect.objectContaining({
severity: 'error',
message: expect.stringContaining('Failed to parse'),
}),
);
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, 'kimi.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);
const managedRoot = await managedPluginRoot(home, 'demo');
expect(manager.info('demo')?.mcpServers).toContainEqual(
expect.objectContaining({
name: 'finance',
runtimeName: 'plugin-demo:finance',
enabled: true,
command: 'finance-mcp',
}),
);
expect(manager.summaries()[0]).toEqual(
expect.objectContaining({
mcpServerCount: 2,
enabledMcpServerCount: 2,
}),
);
expect(manager.enabledMcpServers()).toEqual(
expect.objectContaining({
'plugin-demo:finance': expect.objectContaining({
command: 'finance-mcp',
cwd: managedRoot,
env: expect.objectContaining({ KIMI_CODE_HOME: home, KIMI_PLUGIN_ROOT: managedRoot }),
}),
'plugin-demo:docs': expect.objectContaining({
url: 'https://example.com/mcp',
}),
}),
);
await manager.setMcpServerEnabled('demo', 'finance', false);
expect(manager.enabledMcpServers()).not.toHaveProperty('plugin-demo:finance');
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: false }),
);
});
it('merges manifest MCP enabled defaults with explicit user state', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo', {
mcpServers: {
finance: { command: 'finance-mcp', enabled: false },
},
});
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
expect(manager.info('demo')?.mcpServers).toContainEqual(
expect.objectContaining({ name: 'finance', enabled: false }),
);
expect(manager.summaries()[0]).toEqual(
expect.objectContaining({
mcpServerCount: 1,
enabledMcpServerCount: 0,
}),
);
expect(manager.enabledMcpServers()).toEqual({});
await manager.setMcpServerEnabled('demo', 'finance', true);
expect(manager.info('demo')?.mcpServers).toContainEqual(
expect.objectContaining({ name: 'finance', enabled: true }),
);
expect(manager.enabledMcpServers()).toEqual(
expect.objectContaining({
'plugin-demo:finance': expect.objectContaining({
command: 'finance-mcp',
enabled: true,
}),
}),
);
const reloaded = new PluginManager({ kimiHomeDir: home });
await reloaded.load();
expect(reloaded.info('demo')?.mcpServers).toContainEqual(
expect.objectContaining({ name: 'finance', enabled: true }),
);
expect(reloaded.enabledMcpServers()).toHaveProperty('plugin-demo:finance');
});
it('uses unambiguous runtime names for plugin MCP servers', async () => {
const home = await makeKimiHome();
const first = await makePlugin('a-b', {
mcpServers: {
c: { command: 'first-mcp' },
},
});
const second = await makePlugin('a', {
mcpServers: {
'b-c': { command: 'second-mcp' },
},
});
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(first);
await manager.install(second);
expect(manager.info('a-b')?.mcpServers).toContainEqual(
expect.objectContaining({ name: 'c', runtimeName: 'plugin-a-b:c' }),
);
expect(manager.info('a')?.mcpServers).toContainEqual(
expect.objectContaining({ name: 'b-c', runtimeName: 'plugin-a:b-c' }),
);
const servers = manager.enabledMcpServers();
expect(servers).toEqual(
expect.objectContaining({
'plugin-a-b:c': expect.objectContaining({ command: 'first-mcp' }),
'plugin-a:b-c': expect.objectContaining({ command: 'second-mcp' }),
}),
);
expect(Object.keys(servers)).toHaveLength(2);
});
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.root).toBe(await managedPluginRoot(home, 'demo'));
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?.root).toBe(await managedPluginRoot(home, 'demo'));
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/kimi.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/kimi.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/kimi.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 overwrites existing local-path plugin', async () => {
const home = await makeKimiHome();
const root = await makePlugin('zip-demo');
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
const first = await manager.install(root);
await manager.setEnabled('zip-demo', false);
const zipBuffer = await createZipBuffer([
{ name: 'plugin/kimi.plugin.json', data: JSON.stringify({ name: 'zip-demo', version: '2.0.0' }) },
]);
const url = await serveOnce(zipBuffer);
const updated = await manager.install(url);
expect(updated.source).toBe('zip-url');
expect(updated.originalSource).toBe(url);
expect(updated.manifest?.version).toBe('2.0.0');
expect(updated.enabled).toBe(false);
expect(updated.installedAt).toBe(first.installedAt);
expect(manager.list()).toHaveLength(1);
});
it('install() rejects zip URL without manifest', async () => {
const home = await makeKimiHome();
const zipBuffer = await createZipBuffer([
{ name: 'readme.txt', data: 'no manifest here' },
]);
const url = await serveOnce(zipBuffer);
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await expect(manager.install(url)).rejects.toThrow(/manifest/i);
});
});
async function createZipBuffer(entries: Array<{ name: string; data: string | Buffer }>): Promise<Buffer> {
return new Promise((resolve, reject) => {
const zipfile = new yazl.ZipFile();
const chunks: Buffer[] = [];
zipfile.outputStream.on('data', (chunk) => chunks.push(chunk));
zipfile.outputStream.on('end', () => {
resolve(Buffer.concat(chunks));
});
zipfile.outputStream.on('error', reject);
for (const entry of entries) {
zipfile.addBuffer(Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data), entry.name);
}
zipfile.end();
});
}
async function serveOnce(buffer: Buffer): Promise<string> {
const { createServer } = await import('node:http');
return new Promise((resolve) => {
const server = createServer((_, res) => {
res.writeHead(200, { 'Content-Type': 'application/zip' });
res.end(buffer);
server.close();
});
server.listen(0, '127.0.0.1', () => {
const addr = server.address()!;
resolve(`http://127.0.0.1:${(addr as any).port}`);
});
});
}

View file

@ -0,0 +1,359 @@
import { mkdtemp, mkdir, writeFile, symlink, realpath } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { parseManifest } from '../../src/plugin/manifest';
async function makePlugin(
files: Record<string, string>,
options: { dirs?: readonly string[] } = {},
): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), 'kimi-plugin-test-'));
for (const dir of options.dirs ?? []) {
await mkdir(path.join(root, dir), { recursive: true });
}
for (const [rel, body] of Object.entries(files)) {
await mkdir(path.dirname(path.join(root, rel)), { recursive: true });
await writeFile(path.join(root, rel), body, 'utf8');
}
return realpath(root);
}
describe('parseManifest', () => {
it('reads a minimal kimi.plugin.json at the plugin root', async () => {
const root = await makePlugin({
'kimi.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('kimi-plugin-root');
expect(result.diagnostics).toEqual([]);
});
it('prefers root kimi.plugin.json when .kimi-plugin/plugin.json also exists', async () => {
const root = await makePlugin({
'kimi.plugin.json': JSON.stringify({ name: 'root-version', version: '1.0.0' }),
'.kimi-plugin/plugin.json': JSON.stringify({ name: 'dir-version' }),
});
const result = await parseManifest(root);
expect(result.manifestKind).toBe('kimi-plugin-root');
expect(result.manifest?.name).toBe('root-version');
expect(result.shadowedManifestPath).toBe(path.join(root, '.kimi-plugin/plugin.json'));
});
it('falls back to .kimi-plugin/plugin.json when kimi.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-dir');
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 kimi.plugin.json is invalid JSON', async () => {
const root = await makePlugin({
'kimi.plugin.json': '{ not json',
'.kimi-plugin/plugin.json': JSON.stringify({ name: 'dir-version' }),
});
const result = await parseManifest(root);
expect(result.manifest).toBeUndefined();
expect(result.manifestKind).toBe('kimi-plugin-root');
expect(result.diagnostics).toContainEqual(
expect.objectContaining({
severity: 'error',
message: expect.stringContaining('Failed to parse'),
}),
);
expect(result.shadowedManifestPath).toBe(path.join(root, '.kimi-plugin/plugin.json'));
});
it('rejects names that violate the regex', async () => {
const root = await makePlugin({
'kimi.plugin.json': JSON.stringify({ name: 'Bad Name!' }),
});
const result = await parseManifest(root);
expect(result.manifest).toBeUndefined();
expect(result.diagnostics).toContainEqual(
expect.objectContaining({
severity: 'error',
message: expect.stringContaining('"name" must match'),
}),
);
});
it('reports an error when no manifest file exists', async () => {
const root = await makePlugin({});
const result = await parseManifest(root);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({
severity: 'error',
message: expect.stringContaining('No manifest at'),
}),
);
});
it('resolves a single skills path', async () => {
const root = await makePlugin(
{ 'kimi.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(
{
'kimi.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({
'kimi.plugin.json': JSON.stringify({ name: 'demo', skills: 'skills/' }),
});
const result = await parseManifest(root);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({
severity: 'error',
message: expect.stringContaining('"skills" path must start with "./"'),
}),
);
expect(result.manifest?.skills).toEqual([]);
});
it('rejects a skills path that escapes plugin_root', async () => {
const root = await makePlugin({
'kimi.plugin.json': JSON.stringify({ name: 'demo', skills: './../escape' }),
});
const result = await parseManifest(root);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({
severity: 'error',
message: expect.stringContaining('resolves outside the plugin'),
}),
);
});
it('rejects a skills path that escapes via a symlink', async () => {
const root = await makePlugin({
'kimi.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({
severity: 'error',
message: expect.stringContaining('resolves outside the plugin'),
}),
);
});
it('warns when skills resolves to a non-directory', async () => {
const root = await makePlugin({
'kimi.plugin.json': JSON.stringify({ name: 'demo', skills: './notes.md' }),
'notes.md': 'hi',
});
const result = await parseManifest(root);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({
severity: 'warn',
message: expect.stringContaining('is not a directory'),
}),
);
});
it('falls back to root SKILL.md when skills field is absent', async () => {
const root = await makePlugin({
'kimi.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(
{
'kimi.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('emits info diagnostics for unsupported runtime extension fields', async () => {
const root = await makePlugin({
'kimi.plugin.json': JSON.stringify({
name: 'demo',
tools: { foo: { description: 'x' } },
commands: ['x'],
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',
'commands',
'configFile',
'config_file',
'inject',
'bootstrap',
'hooks',
'apps',
]) {
expect(result.diagnostics).toContainEqual(
expect.objectContaining({
severity: 'info',
message: expect.stringContaining(`"${field}" is present but not supported`),
}),
);
}
});
it('parses skillInstructions', async () => {
const root = await makePlugin({
'kimi.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({
'kimi.plugin.json': JSON.stringify({ name: 'demo', keywords: ['finance', 'workflow'] }),
});
const result = await parseManifest(root);
expect(result.manifest?.keywords).toEqual(['finance', 'workflow']);
});
it('reads sessionStart', async () => {
const root = await makePlugin({
'kimi.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({
severity: 'error',
message: expect.stringContaining('No manifest at'),
}),
);
});
it('parses plugin mcpServers', async () => {
const root = await makePlugin(
{
'kimi.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({
'kimi.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({
severity: 'warn',
message: expect.stringContaining('must be a PATH command or start with "./"'),
}),
);
});
it('captures interface.displayName and shortDescription', async () => {
const root = await makePlugin({
'kimi.plugin.json': JSON.stringify({
name: 'demo',
interface: { displayName: 'Demo', shortDescription: 'A demo.' },
}),
});
const result = await parseManifest(root);
expect(result.manifest?.interface?.displayName).toBe('Demo');
expect(result.manifest?.interface?.shortDescription).toBe('A demo.');
});
});

View file

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

View file

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

View file

@ -0,0 +1,131 @@
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { KimiCore } from '../../src/rpc/core-impl';
describe('KimiCore plugin RPCs', () => {
it('install → list → setEnabled → remove round trip', async () => {
const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-'));
const pluginRoot = await mkdtemp(path.join(tmpdir(), 'plugin-'));
await writeFile(
path.join(pluginRoot, 'kimi.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, 'kimi.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, 'kimi.plugin.json'),
JSON.stringify({ name: 'demo' }),
'utf8',
);
await mkdir(path.join(home, 'plugins'), { recursive: true });
await writeFile(
path.join(home, 'plugins', 'installed.json'),
JSON.stringify({
version: 1,
plugins: [
{
id: 'demo',
root: pluginRoot,
source: 'local-path',
enabled: true,
installedAt: '2026-05-25T09:00:00Z',
},
],
}),
'utf8',
);
const core = new KimiCore(async () => ({}) as never, { homeDir: home });
await expect(core.listPlugins({})).resolves.toContainEqual(
expect.objectContaining({ id: 'demo' }),
);
});
});

View file

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

View file

@ -665,6 +665,72 @@ 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('keeps plugin-specific skill lookup when a project skill has the same name', async () => {
const { repoDir } = await makeWorkspace();
const projectRoot = path.join(repoDir, '.kimi-code', 'skills');
const pluginRoot = path.join(repoDir, 'plugin-skills');
await writeSkill(projectRoot, path.join('using-superpowers', 'SKILL.md'), [
'---',
'name: using-superpowers',
'description: Project override',
'---',
'',
'project body',
]);
await writeSkill(pluginRoot, path.join('using-superpowers', 'SKILL.md'), [
'---',
'name: using-superpowers',
'description: Plugin startup',
'---',
'',
'plugin body',
]);
const registry = new SkillRegistry();
await registry.loadRoots([
{ path: projectRoot, source: 'project' },
{
path: pluginRoot,
source: 'extra',
plugin: { id: 'superpowers' },
},
]);
expect(registry.getSkill('using-superpowers')?.content).toBe('project body');
expect(registry.getPluginSkill('superpowers', 'using-superpowers')?.content).toBe(
'plugin body',
);
});
it('stamps skills discovered via extra dirs with source=extra', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const extra = path.join(repoDir, 'my-extra');

View file

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

View file

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

View file

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

View file

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

24
plugins/marketplace.json Normal file
View file

@ -0,0 +1,24 @@
{
"version": "1",
"plugins": [
{
"id": "kimi-datasource",
"tier": "official",
"displayName": "Kimi Datasource",
"version": "3.0.0",
"description": "Official datasource workflows.",
"keywords": ["data", "mcp"],
"source": "./official/kimi-datasource"
},
{
"id": "superpowers",
"tier": "curated",
"displayName": "Superpowers",
"version": "5.1.0",
"description": "Planning, TDD, debugging, and delivery workflows for coding agents.",
"homepage": "https://github.com/obra/superpowers",
"keywords": ["skills", "planning", "tdd", "debugging", "code-review"],
"source": "./curated/superpowers"
}
]
}

View file

@ -0,0 +1,6 @@
# 编辑器
.vscode/
.idea/
*.swp
*.swo
*~

View file

@ -0,0 +1,150 @@
---
name: kimi-datasource
description: |
通用数据源助手。当用户要查股票/财报/技术指标/全球宏观经济/中国企业工商/学术论文这类外部数据时,使用这个 skill。
本 plugin 通过 MCP server `plugin-kimi-datasource-data` 提供工具;优先调用 `mcp__plugin-kimi-datasource-data__query_stock``mcp__plugin-kimi-datasource-data__get_data_source_desc``mcp__plugin-kimi-datasource-data__call_data_source_tool`
---
# kimi-datasource — 通用数据源助手
## 0. 调用方式
本 skill 使用 datasource MCP server 注册的三个工具,不要通过 Bash 手动执行脚本:
- `mcp__plugin-kimi-datasource-data__query_stock`
- `mcp__plugin-kimi-datasource-data__get_data_source_desc`
- `mcp__plugin-kimi-datasource-data__call_data_source_tool`
这三个工具由 Kimi Code 托管执行,参数直接按 tool schema 传 JSON。
工具会读取 `$KIMI_CODE_HOME/credentials/kimi-code.json`。如果没有登录凭据,让用户先在 Kimi Code 里执行 `/login`
## 1. 这个 skill 提供什么能力
本 plugin 后面挂了 6 个外部数据源。每一行的"数据源名"就是传给 `get_data_source_desc``name`
| 能力域 | 数据源名 | 典型问题 |
|---|---|---|
| **A股 / 港股 / 美股 行情和财务** | `stock_finance_data` | "茅台现在多少钱"、"宁德时代 2024 年财报"、"腾讯股东"、"杭州的人工智能股票" |
| **Yahoo Finance 全球金融** | `yahoo_finance` | "苹果分析师评级"、"AAPL 期权链"、"标普 500 历年价格" |
| **世界银行宏观经济** | `world_bank_open_data` | "中国历年 GDP"、"印度通胀率"、"各国人口增长对比" |
| **中国企业工商信息** | `tianyancha` | "字节跳动股东"、"比亚迪司法风险"、"宁德时代专利" |
| **arXiv 论文预印本** | `arxiv` | "找 RAG 综述"、"下载 2406.xxxxx" |
| **Google Scholar 学术搜索** | `scholar` | "Hinton 最新论文"、"transformer 综述高引文献" |
**不支持的能力**:通用 Web 搜索 / 实时新闻。问到这类问题,告诉用户当前数据源不覆盖。
## 2. 标准工作流:`get_data_source_desc``call_data_source_tool`
后端可用 API 经常会调整,**这份 skill 故意不抄具体的 API 名和参数表**。每次调用前你都应当现场问数据源:"你都有什么接口?"
```
1. 根据用户问题,从上表挑出一个 data_source_name
2. 执行 get_data_source_desc读取该数据源的 Markdown 文档
3. 仔细读返回的 Markdown里面列了
- 该数据源整体说明(含 ticker 格式、全局约束)
- 每个 API 的描述 / 必填参数 / 可选参数 / 默认值 / 取值范围
4. 选最匹配的 API按文档拼 params
5. 执行 call_data_source_tool
6. 读返回结果给用户
```
### 例 1用户问"茅台最近一年走势"
1. 股票走势 → `stock_finance_data`
2. 调用 `mcp__plugin-kimi-datasource-data__get_data_source_desc`,参数 `{"name":"stock_finance_data"}`
3. 从文档里找到"获取历史价格"那个 API看它要 `ticker / start_date / end_date / file_path`
4. 用 web_search 核对 → 茅台 = `600519.SH`
5. 调用 `mcp__plugin-kimi-datasource-data__call_data_source_tool`,参数形如 `{"data_source_name":"stock_finance_data","api_name":"<文档里写的 api>","params":{"ticker":"600519.SH","start_date":"...","end_date":"...","file_path":"/tmp/mao_1y.csv"}}`
### 例 2用户问"找几篇 retrieval augmented generation 的综述"
1. 论文搜索 → `arxiv`(或 `scholar`arxiv 更适合预印本scholar 引用更全)
2. 调用 `mcp__plugin-kimi-datasource-data__get_data_source_desc`,参数 `{"name":"arxiv"}`
3. 从文档里找到搜索类 API看它要 `query / file_path / max_results`
4. 执行 `call_data_source_tool`
### 例 3用户问"字节跳动有哪些股东"
1. 企业工商 → `tianyancha`
2. 调用 `mcp__plugin-kimi-datasource-data__get_data_source_desc`,参数 `{"name":"tianyancha"}`
3. 注意tianyancha 的 API 是动态注册的,文档会指引你**先用搜索类接口找到合适的 API 名,再调用**
4. **必须使用企业全称**"北京字节跳动科技有限公司"),不要用简称。不知道全称就先用 tianyancha 文档里的"公司搜索"接口查
## 3. 调用前的几条铁律
### 3.1 股票代码必须核对,不能凭记忆猜
A 股 `.SH/.SZ/.BJ`,港股 `.HK`,美股 `.US` 等。用户通常只说中文名("茅台"、"宁德时代"、"腾讯"),不会给代码。
**调任何股票相关 API 前**,先用 `web_search` / `WebSearch` 一类联网工具确认正确代码 + 后缀。
如果当前环境没有任何联网工具,**让用户亲口确认代码**,不要硬猜。错了的话接口会静默返回错数据或空数据。
### 3.2 企业相关查询必须用全称
`tianyancha` 拒收"特斯拉"、"网易"、"腾讯"这种简称,必须给"北京特斯拉销售有限公司"这种全名。不知道全名时,先调它的公司搜索 API。
### 3.3 多数 API 需要 `file_path`
绝大部分数据源 API 把完整结果以 CSV 形式写到 `file_path`。漏传会报 `Missing required parameters: file_path`。不知道传啥时,给一个 `/tmp/<场景>_<时间戳>.csv` 即可。
### 3.4 一次调用不要堆太多 ticker
`stock_finance_data` 的实时接口最多 3 个 ticker历史接口最多 10 个。超过会被截断或报错。多了就分批调。
## 4. 怎么读返回结果
`call_data_source_tool``query_stock` 的 stdout 一般含两段:
1. **`data_preview`**CSV 头 + 前几行(通常 1~3 行),方便你直接答简单问题
2. **`CSV 数据已写入:/tmp/xxx.csv`**:完整数据落盘路径
策略:
- 用户只问"XX 现在多少钱"、"中国 2023 GDP 多少"这种单值 → `data_preview` 一般够,直接答
- 用户要画图、对比、算盈亏、列清单 → 用 `Read` 工具把 CSV 读出来再处理
- 混合 A+港股查询时服务端会自动把 CSV 拆成 `_a.csv` / `_hk.csv` 两份,原 `file_path` 那个文件不存在
如果接口返回失败,提示文字一般会写明原因(参数不对 / 不支持 / 数据空等)。把人话原因反馈给用户,不要硬走第二次。
## 5. `query_stock` — 实时行情快捷命令
对**实时**类的常见股票查询,可以**不走** `get_data_source_desc → call_data_source_tool` 这套流程,直接用 `query_stock`,省一次调用。它等价于 `stock_finance_data` 的实时接口子集。
调用 `mcp__plugin-kimi-datasource-data__query_stock`,参数形如 `{"ticker":"600519.SH","type":"realtime_price","file_path":"/tmp/stock_600519.csv"}`
适用场景(且仅限):
- 看当前价 / 当日分钟 K 线(`type=realtime_price`
- 看实时技术指标 MA/MACD/KDJ/RSI/BOLL 等(`type=realtime_tech`**仅 A 股**,港股/美股会报错)
- 开盘摘要(`type=open_summary`
- 收盘摘要(`type=close_summary`**港股盘后看当天数据必须用这个**`realtime_price` 拿不到)
涉及**历史日 K / 财报 / 公告 / 股东 / 财务指标 / 选股 / 业务分部 / 预测**等任何"非实时"的股票查询,走标准 `get_data_source_desc → call_data_source_tool` 流程,不要在 `query_stock` 上硬凑。
## 6. `watchlist.json` — 用户自选股
`${KIMI_SKILL_DIR}/watchlist.json` 是用户的自选股列表。用户问"看一下我的自选股"时,读这个文件然后按每 3 只一组分批执行 `query_stock`
格式:
```json
[
{"code": "600519.SH", "name": "贵州茅台"},
{"code": "0700.HK", "name": "腾讯控股", "hold_cost": 350.5, "hold_quantity": 100}
]
```
- `code``name` 必填;`hold_cost``hold_quantity` 可选
- 两者都有时顺便算盈亏:`(当前价 - hold_cost) * hold_quantity`
- 用户说"帮我加 XX 到自选股"时:先 web_search 核对代码,再追加到 JSON 数组
## 7. 注意事项
- **不要凭记忆猜股票代码 / 企业全称**。错代码会让接口静默返回错数据,用户察觉不到
- **不要在没读 desc 的情况下硬传 `api_name`**。后端会报 `API_NOT_FOUND`。除非这次会话里你已经读过该数据源的 desc 并记得参数
- **不要给投资建议**。给完数据加一句"AI 生成,不构成投资建议"即可
- **不要在 `query_stock` 上塞历史/财报查询**。它只覆盖实时类,别的会失败
- 如果某个数据源接口返回的报错明显是后端 bug参数 schema 自相矛盾、内部 Python 报错等),**汇报错误给用户,不要硬试**——这种 bug 我们这边修不了,要后端服务侧改

View file

@ -0,0 +1,446 @@
#!/usr/bin/env node
// Stdio MCP server for kimi-datasource.
//
// Speaks newline-delimited JSON-RPC 2.0 on stdin/stdout per the MCP "stdio"
// transport. Implements the minimal surface the Kimi Code host calls:
// - initialize
// - notifications/initialized
// - tools/list
// - tools/call
// - ping
//
// Business logic (API call, credentials, headers) is unchanged from the
// previous one-shot CLI; only the transport changed.
import { randomUUID } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { arch, homedir, hostname, release, type } from 'node:os';
import path from 'node:path';
import readline from 'node:readline';
const VERSION = '3.0.0';
const API_URL = process.env.KIMI_DATASOURCE_API_URL ?? 'https://api.kimi.com/coding/v1/tools';
const REQUEST_TIMEOUT_MS = 30_000;
const PROTOCOL_VERSION = '2025-06-18';
const VALID_STOCK_QUERY_TYPES = new Set([
'realtime_price',
'realtime_tech',
'open_summary',
'close_summary',
]);
const TOOLS = [
{
name: 'query_stock',
description:
'Query realtime stock price, realtime technical indicators, open summaries, or close summaries for up to 3 tickers.',
inputSchema: {
type: 'object',
properties: {
ticker: {
type: 'string',
description: 'Ticker code list separated by commas, for example 600519.SH or 0700.HK.',
},
type: {
type: 'string',
enum: ['realtime_price', 'realtime_tech', 'open_summary', 'close_summary'],
description: 'Realtime stock query type.',
},
time: {
type: 'string',
description: 'Optional time parameter for supported realtime endpoints.',
},
file_path: {
type: 'string',
description: 'Optional CSV output path. When omitted, the tool chooses a temporary path.',
},
},
required: ['ticker'],
},
},
{
name: 'get_data_source_desc',
description:
'Get the current API documentation for one Kimi data source before calling a specific API.',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
enum: [
'stock_finance_data',
'yahoo_finance',
'world_bank_open_data',
'tianyancha',
'arxiv',
'scholar',
],
description: 'Data source name.',
},
},
required: ['name'],
},
},
{
name: 'call_data_source_tool',
description: 'Call one API from a Kimi data source after reading get_data_source_desc.',
inputSchema: {
type: 'object',
properties: {
data_source_name: {
type: 'string',
description: 'Data source name returned or documented by get_data_source_desc.',
},
api_name: {
type: 'string',
description: 'API name from the data source description.',
},
params: {
type: 'object',
description: 'API parameters that match the data source description.',
},
},
required: ['data_source_name', 'api_name', 'params'],
},
},
];
const HANDLERS = {
query_stock: {
method: 'get_stock_realtime_price',
buildParams(args) {
const ticker = requiredString(args, 'ticker');
const tickerList = ticker
.split(',')
.map((item) => item.trim())
.filter(Boolean);
if (tickerList.length === 0) throw new Error('Missing required argument: ticker.');
if (tickerList.length > 3) {
throw new Error('ticker accepts at most 3 values separated by commas.');
}
const queryType = optionalString(args, 'type') ?? 'realtime_price';
if (!VALID_STOCK_QUERY_TYPES.has(queryType)) {
throw new Error(
`type must be one of ${JSON.stringify([...VALID_STOCK_QUERY_TYPES])}; received: ${queryType}`,
);
}
const params = {
ticker,
type: queryType,
file_path: optionalString(args, 'file_path') ?? defaultStockFilePath(ticker, queryType),
};
const time = optionalString(args, 'time');
if (time !== undefined) params.time = time;
return params;
},
format(text, params) {
return `${text}\n\nCSV data written to: ${params.file_path}`;
},
},
get_data_source_desc: {
method: 'get_data_source_desc',
buildParams(args) {
return { name: requiredString(args, 'name') };
},
},
call_data_source_tool: {
method: 'call_data_source_tool',
buildParams(args) {
return {
data_source_name: requiredString(args, 'data_source_name'),
api_name: requiredString(args, 'api_name'),
params: requiredObject(args, 'params'),
};
},
},
};
async function handleRequest(message) {
const { method, id, params } = message;
switch (method) {
case 'initialize':
return {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: { name: 'kimi-datasource', version: VERSION },
};
case 'ping':
return {};
case 'tools/list':
return { tools: TOOLS };
case 'tools/call':
return runTool(params);
default:
throw jsonRpcError(-32601, `Method not found: ${method}`, { id });
}
}
async function runTool(params) {
const name = params?.name;
const args = params?.arguments ?? {};
const handler = HANDLERS[name];
if (handler === undefined) {
return {
content: [{ type: 'text', text: `Unknown tool: ${String(name)}` }],
isError: true,
};
}
try {
const built = handler.buildParams(args);
const response = await callKimiTool(handler.method, built);
const text = extractText(response);
const formatted = (handler.format?.(text, built) ?? text).trim();
return { content: [{ type: 'text', text: formatted }] };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
content: [{ type: 'text', text: message }],
isError: true,
};
}
}
function resolveKimiHome() {
const explicit = process.env.KIMI_CODE_HOME?.trim();
return explicit && explicit.length > 0 ? explicit : path.join(homedir(), '.kimi-code');
}
async function loadAccessToken() {
const kimiHome = resolveKimiHome();
const credentialsFile = path.join(kimiHome, 'credentials', 'kimi-code.json');
let parsed;
try {
parsed = JSON.parse(await readFile(credentialsFile, 'utf8'));
} catch (err) {
if (isNotFound(err)) {
throw new Error(
`Kimi Code credentials file not found: ${credentialsFile}\nRun /login in Kimi Code first.`,
);
}
if (err instanceof SyntaxError) {
throw new Error(`Failed to parse Kimi Code credentials file: ${err.message}`);
}
throw err;
}
if (!isRecord(parsed)) {
throw new Error(`Invalid Kimi Code credentials file: ${credentialsFile}`);
}
const token = typeof parsed.access_token === 'string' ? parsed.access_token : '';
if (token.length === 0) {
throw new Error('Kimi Code credentials do not contain access_token. Run /login again.');
}
const expiresAt = typeof parsed.expires_at === 'number' ? parsed.expires_at : 0;
if (expiresAt > 0 && expiresAt <= Math.floor(Date.now() / 1000)) {
throw new Error('Kimi Code access_token has expired. Run /login again and retry.');
}
return { kimiHome, token };
}
async function callKimiTool(method, params) {
const { kimiHome, token } = await loadAccessToken();
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, REQUEST_TIMEOUT_MS);
try {
const response = await fetch(API_URL, {
method: 'POST',
headers: await buildHeaders(kimiHome, token),
body: JSON.stringify({ method, params }),
signal: controller.signal,
});
const text = await response.text();
if (!response.ok) {
throw new Error(`HTTP ${response.status} error: ${text}`);
}
try {
return JSON.parse(text);
} catch {
return text;
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS / 1000} seconds.`);
}
throw err;
} finally {
clearTimeout(timeout);
}
}
async function buildHeaders(kimiHome, token) {
return {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'X-Msh-Tool-Call-Id': randomUUID(),
'X-Msh-Platform': asciiHeader(process.env.KIMI_MSH_PLATFORM ?? 'kimi-code-cli'),
'X-Msh-Version': asciiHeader(process.env.KIMI_MSH_VERSION ?? VERSION),
'X-Msh-Device-Name': asciiHeader(process.env.KIMI_MSH_DEVICE_NAME ?? hostname()),
'X-Msh-Device-Model': asciiHeader(process.env.KIMI_MSH_DEVICE_MODEL ?? deviceModel()),
'X-Msh-Os-Version': asciiHeader(process.env.KIMI_MSH_OS_VERSION ?? release()),
'X-Msh-Device-Id': asciiHeader(process.env.KIMI_MSH_DEVICE_ID ?? (await createDeviceId(kimiHome))),
'User-Agent': `kimi-datasource/${VERSION}`,
};
}
async function createDeviceId(kimiHome) {
const deviceIdPath = path.join(kimiHome, 'device_id');
try {
const existing = (await readFile(deviceIdPath, 'utf8')).trim();
if (existing.length > 0) return existing;
} catch {
// Fall through to create a best-effort local device id.
}
const id = randomUUID();
try {
await mkdir(kimiHome, { recursive: true, mode: 0o700 });
await writeFile(deviceIdPath, `${id}\n`, { encoding: 'utf8', mode: 0o600 });
} catch {
// Headers can still use the in-memory id if the file cannot be written.
}
return id;
}
function deviceModel() {
const os = type();
const osVersion = release();
const osArch = arch();
if (os === 'Darwin') return `macOS ${osVersion} ${osArch}`;
if (os === 'Windows_NT') return `Windows ${osVersion} ${osArch}`;
return `${os} ${osVersion} ${osArch}`.trim();
}
function extractText(response) {
if (typeof response === 'string') return response;
if (!isRecord(response)) return String(response);
if (response.is_success === false) {
const message = extractUserText(response.error) ?? JSON.stringify(response);
throw new Error(`Tool API returned an error: ${message}`);
}
const text = extractUserText(response.result);
if (text !== undefined) return text;
return `Tool API succeeded but did not return user text. Raw response: ${JSON.stringify(response)}`;
}
function extractUserText(value) {
if (!isRecord(value) || !Array.isArray(value.user)) return undefined;
const text = value.user
.filter((item) => isRecord(item) && item.type === 'text' && typeof item.text === 'string')
.map((item) => item.text)
.filter(Boolean)
.join('\n\n');
return text.length > 0 ? text : undefined;
}
function defaultStockFilePath(ticker, queryType) {
const safeTicker = ticker.replaceAll(',', '_').replaceAll('.', '_');
return `/tmp/stock_${safeTicker}_${queryType}.csv`;
}
function requiredString(args, field) {
const value = optionalString(args, field);
if (value === undefined) throw new Error(`Missing required argument: ${field}.`);
return value;
}
function optionalString(args, field) {
if (!isRecord(args)) return undefined;
const value = args[field];
if (value === undefined || value === null) return undefined;
if (typeof value !== 'string') throw new Error(`${field} must be a string.`);
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function requiredObject(args, field) {
if (!isRecord(args)) throw new Error(`Missing required argument: ${field}.`);
const value = args[field];
if (!isRecord(value)) throw new Error(`${field} must be an object.`);
return value;
}
function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isNotFound(err) {
return isRecord(err) && err.code === 'ENOENT';
}
function asciiHeader(value, fallback = 'unknown') {
const cleaned = String(value).replaceAll(/[^ -~]/g, '').trim();
return cleaned.length > 0 ? cleaned : fallback;
}
function jsonRpcError(code, message, data) {
const err = new Error(message);
err.jsonRpc = { code, message, data };
return err;
}
function send(message) {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function sendResult(id, result) {
send({ jsonrpc: '2.0', id, result });
}
function sendError(id, error) {
send({ jsonrpc: '2.0', id, error });
}
async function dispatch(message) {
if (message?.jsonrpc !== '2.0') return;
// Notifications carry no id and never expect a response.
if (message.id === undefined || message.id === null) {
if (message.method === 'notifications/initialized' || message.method === 'notifications/cancelled') {
return;
}
return;
}
const id = message.id;
try {
const result = await handleRequest(message);
sendResult(id, result ?? {});
} catch (err) {
if (err && typeof err === 'object' && err.jsonRpc !== undefined) {
sendError(id, err.jsonRpc);
return;
}
sendError(id, {
code: -32603,
message: err instanceof Error ? err.message : String(err),
});
}
}
function start() {
const rl = readline.createInterface({ input: process.stdin });
rl.on('line', (line) => {
const trimmed = line.trim();
if (trimmed.length === 0) return;
let message;
try {
message = JSON.parse(trimmed);
} catch (err) {
sendError(null, {
code: -32700,
message: `Parse error: ${err instanceof Error ? err.message : String(err)}`,
});
return;
}
void dispatch(message);
});
rl.on('close', () => {
process.exit(0);
});
}
start();

View file

@ -0,0 +1,18 @@
{
"name": "kimi-datasource",
"version": "3.0.0",
"description": "Finance, macro, enterprise, and academic data tools for Kimi Code.",
"keywords": ["finance", "data-source", "mcp"],
"mcpServers": {
"data": {
"command": "node",
"args": ["./bin/kimi-datasource.mjs"],
"cwd": "./"
}
},
"interface": {
"displayName": "Kimi Datasource",
"shortDescription": "Finance, macro, enterprise, and academic data tools",
"developerName": "Moonshot AI"
}
}

View file

@ -0,0 +1,14 @@
[
{
"code": "600519.SH",
"name": "贵州茅台"
},
{
"code": "000001.SZ",
"name": "平安银行"
},
{
"code": "0700.HK",
"name": "腾讯控股"
}
]