From ae86b160d551c365e5d1321e7c927f96976dc317 Mon Sep 17 00:00:00 2001 From: Chen Yijun Date: Sun, 5 Jul 2026 20:33:15 +0800 Subject: [PATCH 01/17] feat(node-sdk): expose global plugin methods on KimiHarness --- packages/node-sdk/src/kimi-harness.ts | 22 ++++++ .../test/kimi-harness-plugins.test.ts | 68 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 packages/node-sdk/test/kimi-harness-plugins.test.ts diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index d503143ea..4fefd01bb 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -20,6 +20,8 @@ import type { KimiConfigPatch, KimiHostIdentity, ListSessionsOptions, + PluginInfo, + PluginSummary, RenameSessionInput, ResumeSessionInput, ReloadSessionInput, @@ -246,6 +248,26 @@ export class KimiHarness { return this.rpc.removeProvider(providerId); } + async listPlugins(): Promise { + return this.rpc.listPlugins(); + } + + async installPlugin(source: string): Promise { + return this.rpc.installPlugin(source); + } + + async setPluginEnabled(id: string, enabled: boolean): Promise { + await this.rpc.setPluginEnabled(id, enabled); + } + + async removePlugin(id: string): Promise { + await this.rpc.removePlugin(id); + } + + async getPluginInfo(id: string): Promise { + return this.rpc.getPluginInfo(id); + } + async close(): Promise { await Promise.all(Array.from(this.activeSessions.values(), (session) => session.close())); await this.closeImpl(); diff --git a/packages/node-sdk/test/kimi-harness-plugins.test.ts b/packages/node-sdk/test/kimi-harness-plugins.test.ts new file mode 100644 index 000000000..9d2e93c0c --- /dev/null +++ b/packages/node-sdk/test/kimi-harness-plugins.test.ts @@ -0,0 +1,68 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; +import { createKimiHarness } from '#/index'; +import { TEST_IDENTITY } from './test-identity'; +import { recordingTelemetry, type TelemetryRecord } from './telemetry'; + +const tempDirs: string[] = []; + +afterEach(async () => { + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } +}); + +async function makeTempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'kimi-sdk-plugins-')); + tempDirs.push(dir); + return dir; +} + +describe('KimiHarness plugin management', () => { + it('lists, installs, enables, disables, and removes plugins', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const records: TelemetryRecord[] = []; + const harness = createKimiHarness({ + identity: TEST_IDENTITY, + homeDir, + telemetry: recordingTelemetry(records), + }); + + expect(await harness.listPlugins()).toEqual([]); + + const pluginDir = await makeTempDir(); + await writeFile( + join(pluginDir, 'kimi.plugin.json'), + JSON.stringify({ + name: 'demo-plugin', + version: '1.0.0', + interface: { displayName: 'Demo Plugin' }, + }), + 'utf8', + ); + + const summary = await harness.installPlugin(pluginDir); + expect(summary.id).toBe('demo-plugin'); + expect(summary.enabled).toBe(true); + + const listed = await harness.listPlugins(); + expect(listed).toHaveLength(1); + expect(listed[0]?.id).toBe('demo-plugin'); + + await harness.setPluginEnabled('demo-plugin', false); + expect((await harness.listPlugins())[0]?.enabled).toBe(false); + + const info = await harness.getPluginInfo('demo-plugin'); + expect(info.id).toBe('demo-plugin'); + expect(info.enabled).toBe(false); + + await harness.removePlugin('demo-plugin'); + expect(await harness.listPlugins()).toEqual([]); + + await harness.close(); + }); +}); From a46515362814a2b6c41a323dd1a149e4f66bd652 Mon Sep 17 00:00:00 2001 From: Chen Yijun Date: Sun, 5 Jul 2026 20:34:38 +0800 Subject: [PATCH 02/17] feat(kimi-code): add plugin registry persistence utility --- apps/kimi-code/src/utils/plugin-registries.ts | 125 ++++++++++++++++++ .../test/utils/plugin-registries.test.ts | 68 ++++++++++ 2 files changed, 193 insertions(+) create mode 100644 apps/kimi-code/src/utils/plugin-registries.ts create mode 100644 apps/kimi-code/test/utils/plugin-registries.test.ts diff --git a/apps/kimi-code/src/utils/plugin-registries.ts b/apps/kimi-code/src/utils/plugin-registries.ts new file mode 100644 index 000000000..00e3249fd --- /dev/null +++ b/apps/kimi-code/src/utils/plugin-registries.ts @@ -0,0 +1,125 @@ +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { isAbsolute, join } from 'node:path'; + +export interface PluginRegistry { + readonly name?: string; + readonly url: string; +} + +export interface RegistriesFile { + readonly version: 1; + readonly registries: PluginRegistry[]; +} + +const REGISTRIES_REL = join('plugins', 'registries.json'); + +const EMPTY: RegistriesFile = { version: 1, registries: [] }; + +export async function readRegistries(kimiHomeDir: string): Promise { + const filePath = join(kimiHomeDir, REGISTRIES_REL); + let text: string; + try { + text = await readFile(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return EMPTY; + throw error; + } + const parsed = JSON.parse(text) as unknown; + if (!isRegistriesFile(parsed)) { + throw new Error(`${filePath} is not a valid registries file`); + } + return parsed; +} + +export async function writeRegistries( + kimiHomeDir: string, + data: RegistriesFile, +): Promise { + const dir = join(kimiHomeDir, 'plugins'); + await mkdir(dir, { recursive: true }); + const final = join(kimiHomeDir, REGISTRIES_REL); + const tmp = `${final}.tmp`; + await writeFile(tmp, JSON.stringify(data, null, 2), 'utf8'); + await rename(tmp, final); +} + +export async function addRegistry( + kimiHomeDir: string, + registry: PluginRegistry, +): Promise { + const trimmedUrl = registry.url.trim(); + if (trimmedUrl.length === 0) throw new Error('Registry URL cannot be empty'); + const file = await readRegistries(kimiHomeDir); + if (file.registries.some((r) => r.url === trimmedUrl)) { + throw new Error(`Registry URL is already registered: ${trimmedUrl}`); + } + const trimmedName = registry.name?.trim(); + const next: RegistriesFile = { + ...file, + registries: [ + ...file.registries, + { + url: trimmedUrl, + ...(trimmedName !== undefined && trimmedName.length > 0 ? { name: trimmedName } : {}), + }, + ], + }; + await writeRegistries(kimiHomeDir, next); +} + +export async function removeRegistry( + kimiHomeDir: string, + nameOrUrl: string, +): Promise { + const key = nameOrUrl.trim(); + if (key.length === 0) throw new Error('Registry name or URL cannot be empty'); + const file = await readRegistries(kimiHomeDir); + const byNameIndex = file.registries.findIndex((r) => r.name === key); + const byUrlIndex = file.registries.findIndex((r) => r.url === key); + const index = byNameIndex !== -1 ? byNameIndex : byUrlIndex; + if (index === -1) throw new Error(`Registry not found: ${key}`); + const next = { + ...file, + registries: file.registries.filter((_, i) => i !== index), + }; + await writeRegistries(kimiHomeDir, next); +} + +export async function resolveRegistryUrl( + kimiHomeDir: string, + nameOrUrl: string, +): Promise { + const key = nameOrUrl.trim(); + if (key.length === 0) throw new Error('Registry name or URL cannot be empty'); + if (isUrlLike(key)) return key; + const file = await readRegistries(kimiHomeDir); + const found = file.registries.find((r) => r.name === key); + if (found === undefined) throw new Error(`Registry not found: ${key}`); + return found.url; +} + +function isRegistriesFile(value: unknown): value is RegistriesFile { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const obj = value as Record; + return ( + obj['version'] === 1 && + Array.isArray(obj['registries']) && + obj['registries'].every( + (r) => + typeof r === 'object' && + r !== null && + typeof (r as Record)['url'] === 'string', + ) + ); +} + +function isUrlLike(value: string): boolean { + return ( + value.startsWith('http://') || + value.startsWith('https://') || + value.startsWith('file://') || + value === '~' || + value.startsWith('~/') || + isAbsolute(value) + ); +} diff --git a/apps/kimi-code/test/utils/plugin-registries.test.ts b/apps/kimi-code/test/utils/plugin-registries.test.ts new file mode 100644 index 000000000..0fa54f960 --- /dev/null +++ b/apps/kimi-code/test/utils/plugin-registries.test.ts @@ -0,0 +1,68 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { addRegistry, readRegistries, removeRegistry, resolveRegistryUrl } from '#/utils/plugin-registries'; + +const tempDirs: string[] = []; + +afterEach(async () => { + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } +}); + +async function makeTempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'kimi-registries-')); + tempDirs.push(dir); + return dir; +} + +describe('plugin-registries', () => { + it('reads an empty file when none exists', async () => { + const homeDir = await makeTempDir(); + const file = await readRegistries(homeDir); + expect(file).toEqual({ version: 1, registries: [] }); + }); + + it('adds and lists registries', async () => { + const homeDir = await makeTempDir(); + await addRegistry(homeDir, { url: 'https://example.com/m.json', name: 'example' }); + const file = await readRegistries(homeDir); + expect(file.registries).toEqual([{ name: 'example', url: 'https://example.com/m.json' }]); + }); + + it('rejects duplicate URLs', async () => { + const homeDir = await makeTempDir(); + await addRegistry(homeDir, { url: 'https://example.com/m.json' }); + await expect(addRegistry(homeDir, { url: 'https://example.com/m.json' })).rejects.toThrow( + /already registered/, + ); + }); + + it('removes by name then by URL fallback', async () => { + const homeDir = await makeTempDir(); + await addRegistry(homeDir, { url: 'https://a.com/m.json', name: 'a' }); + await addRegistry(homeDir, { url: 'https://b.com/m.json' }); + + await removeRegistry(homeDir, 'a'); + let file = await readRegistries(homeDir); + expect(file.registries).toHaveLength(1); + + await removeRegistry(homeDir, 'https://b.com/m.json'); + file = await readRegistries(homeDir); + expect(file.registries).toHaveLength(0); + }); + + it('resolves registry names and URLs', async () => { + const homeDir = await makeTempDir(); + await addRegistry(homeDir, { url: 'https://named.com/m.json', name: 'named' }); + + expect(await resolveRegistryUrl(homeDir, 'named')).toBe('https://named.com/m.json'); + expect(await resolveRegistryUrl(homeDir, 'https://direct.com/m.json')).toBe( + 'https://direct.com/m.json', + ); + await expect(resolveRegistryUrl(homeDir, 'missing')).rejects.toThrow(/not found/); + }); +}); From 7682b66a1b9abae0ad150a4383b99133d18c5ae1 Mon Sep 17 00:00:00 2001 From: Chen Yijun Date: Sun, 5 Jul 2026 20:42:19 +0800 Subject: [PATCH 03/17] fix(plugin-registries): return fresh default object and avoid conditional spread --- apps/kimi-code/src/utils/plugin-registries.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/kimi-code/src/utils/plugin-registries.ts b/apps/kimi-code/src/utils/plugin-registries.ts index 00e3249fd..37ed68276 100644 --- a/apps/kimi-code/src/utils/plugin-registries.ts +++ b/apps/kimi-code/src/utils/plugin-registries.ts @@ -13,15 +13,13 @@ export interface RegistriesFile { const REGISTRIES_REL = join('plugins', 'registries.json'); -const EMPTY: RegistriesFile = { version: 1, registries: [] }; - export async function readRegistries(kimiHomeDir: string): Promise { const filePath = join(kimiHomeDir, REGISTRIES_REL); let text: string; try { text = await readFile(filePath, 'utf8'); } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return EMPTY; + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { version: 1, registries: [] }; throw error; } const parsed = JSON.parse(text) as unknown; @@ -60,7 +58,7 @@ export async function addRegistry( ...file.registries, { url: trimmedUrl, - ...(trimmedName !== undefined && trimmedName.length > 0 ? { name: trimmedName } : {}), + name: trimmedName || undefined, }, ], }; From f1cc788e0c219fef6c985352857d0d20f5769ea6 Mon Sep 17 00:00:00 2001 From: Chen Yijun Date: Sun, 5 Jul 2026 20:43:44 +0800 Subject: [PATCH 04/17] test(node-sdk): remove unused workDir variable in KimiHarness plugin test --- packages/node-sdk/test/kimi-harness-plugins.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/node-sdk/test/kimi-harness-plugins.test.ts b/packages/node-sdk/test/kimi-harness-plugins.test.ts index 9d2e93c0c..431b7c9f3 100644 --- a/packages/node-sdk/test/kimi-harness-plugins.test.ts +++ b/packages/node-sdk/test/kimi-harness-plugins.test.ts @@ -24,7 +24,6 @@ async function makeTempDir(): Promise { describe('KimiHarness plugin management', () => { it('lists, installs, enables, disables, and removes plugins', async () => { const homeDir = await makeTempDir(); - const workDir = await makeTempDir(); const records: TelemetryRecord[] = []; const harness = createKimiHarness({ identity: TEST_IDENTITY, From 57e87b6a74611407d8d3ab559f18f7a6c290fec4 Mon Sep 17 00:00:00 2001 From: Chen Yijun Date: Sun, 5 Jul 2026 20:47:15 +0800 Subject: [PATCH 05/17] feat(kimi-code): support loading merged default and custom marketplaces --- .../kimi-code/src/utils/plugin-marketplace.ts | 48 +++++++++++++++ .../test/utils/plugin-marketplace.test.ts | 59 ++++++++++++++++++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/utils/plugin-marketplace.ts b/apps/kimi-code/src/utils/plugin-marketplace.ts index be55e798e..ce4b0c6c2 100644 --- a/apps/kimi-code/src/utils/plugin-marketplace.ts +++ b/apps/kimi-code/src/utils/plugin-marketplace.ts @@ -9,6 +9,7 @@ import { KIMI_CODE_PLUGIN_MARKETPLACE_URL, KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV, } from '#/constant/app'; +import { readRegistries } from '#/utils/plugin-registries'; export const PLUGIN_MARKETPLACE_TIERS = ['official', 'curated'] as const; @@ -95,6 +96,53 @@ export async function loadPluginMarketplace( return withLatestVersions(parsePluginMarketplace(raw, location), fetchImpl); } +export interface LoadMergedMarketplaceOptions { + readonly kimiHomeDir: string; + readonly workDir: string; + readonly fetchImpl?: typeof fetch; +} + +export async function loadMergedMarketplace( + options: LoadMergedMarketplaceOptions, +): Promise { + const registries = await readRegistries(options.kimiHomeDir); + const configuredSource = process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV]; + const defaultSource = configuredSource ?? KIMI_CODE_PLUGIN_MARKETPLACE_URL; + const defaultMarketplace = await loadPluginMarketplace({ + workDir: options.workDir, + source: defaultSource, + fetchImpl: options.fetchImpl, + }); + + const merged = new Map(); + for (const entry of defaultMarketplace.plugins) { + merged.set(entry.id, entry); + } + + for (const registry of registries.registries) { + try { + const marketplace = await loadPluginMarketplace({ + workDir: options.workDir, + source: registry.url, + fetchImpl: options.fetchImpl, + }); + for (const entry of marketplace.plugins) { + if (!merged.has(entry.id)) { + merged.set(entry.id, entry); + } + } + } catch { + // Best-effort: skip unreachable custom registries. + } + } + + return { + source: defaultMarketplace.source, + version: defaultMarketplace.version, + plugins: [...merged.values()].toSorted((a, b) => a.id.localeCompare(b.id)), + }; +} + async function withLatestVersions( marketplace: PluginMarketplace, fetchImpl: typeof fetch, diff --git a/apps/kimi-code/test/utils/plugin-marketplace.test.ts b/apps/kimi-code/test/utils/plugin-marketplace.test.ts index 9c220b868..15ec35cbf 100644 --- a/apps/kimi-code/test/utils/plugin-marketplace.test.ts +++ b/apps/kimi-code/test/utils/plugin-marketplace.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -9,7 +9,7 @@ import { KIMI_CODE_PLUGIN_MARKETPLACE_URL, KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV, } from '#/constant/app'; -import { computeUpdateStatus, loadPluginMarketplace } from '#/utils/plugin-marketplace'; +import { computeUpdateStatus, loadMergedMarketplace, loadPluginMarketplace } from '#/utils/plugin-marketplace'; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../../..'); @@ -503,3 +503,58 @@ describe('loadPluginMarketplace', () => { }); }); + +describe('loadMergedMarketplace', () => { + it('merges default and persistent registries, default wins on duplicate ids', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kimi-merged-marketplace-')); + const defaultFile = join(dir, 'default.json'); + const customFile = join(dir, 'custom.json'); + await writeFile( + defaultFile, + JSON.stringify({ + plugins: [ + { id: 'a', displayName: 'A Default', source: './a', version: '1.0.0' }, + { id: 'b', displayName: 'B Default', source: './b', version: '1.0.0' }, + ], + }), + 'utf8', + ); + await writeFile( + customFile, + JSON.stringify({ + plugins: [ + { id: 'a', displayName: 'A Custom', source: './a-custom', version: '2.0.0' }, + { id: 'c', displayName: 'C Custom', source: './c', version: '1.0.0' }, + ], + }), + 'utf8', + ); + + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-registries-home-')); + await mkdir(join(homeDir, 'plugins'), { recursive: true }); + await writeFile( + join(homeDir, 'plugins', 'registries.json'), + JSON.stringify({ version: 1, registries: [{ name: 'custom', url: customFile }] }), + 'utf8', + ); + + const previous = process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV]; + process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV] = defaultFile; + try { + const marketplace = await loadMergedMarketplace({ + kimiHomeDir: homeDir, + workDir: '/tmp/work', + }); + const byId = new Map(marketplace.plugins.map((p) => [p.id, p])); + expect(byId.get('a')?.displayName).toBe('A Default'); + expect(byId.get('b')?.displayName).toBe('B Default'); + expect(byId.get('c')?.displayName).toBe('C Custom'); + } finally { + if (previous === undefined) { + delete process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV]; + } else { + process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV] = previous; + } + } + }); +}); From 88b459b1c777b6eb554dd2b94807bd3bece02e53 Mon Sep 17 00:00:00 2001 From: Chen Yijun Date: Sun, 5 Jul 2026 21:03:00 +0800 Subject: [PATCH 06/17] feat(kimi-code): add kimi plugins CLI handlers --- apps/kimi-code/src/cli/sub/plugins.ts | 454 ++++++++++++++++++++ apps/kimi-code/test/cli/sub/plugins.test.ts | 97 +++++ 2 files changed, 551 insertions(+) create mode 100644 apps/kimi-code/src/cli/sub/plugins.ts create mode 100644 apps/kimi-code/test/cli/sub/plugins.test.ts diff --git a/apps/kimi-code/src/cli/sub/plugins.ts b/apps/kimi-code/src/cli/sub/plugins.ts new file mode 100644 index 000000000..c000afa0d --- /dev/null +++ b/apps/kimi-code/src/cli/sub/plugins.ts @@ -0,0 +1,454 @@ +import { createInterface } from 'node:readline/promises'; +import { homedir as osHomedir } from 'node:os'; +import { isAbsolute, join, resolve } from 'node:path'; +import type { Command } from 'commander'; + +import { + createKimiHarness, + type KimiHarness, + type PluginInfo, + type PluginSummary, + type TelemetryClient, +} from '@moonshot-ai/kimi-code-sdk'; +import { + setTelemetryContext, + track, + withTelemetryContext, +} from '@moonshot-ai/kimi-telemetry'; + +import { CLI_UI_MODE } from '#/constant/app'; +import { createCliTelemetryBootstrap } from '#/cli/telemetry'; +import { createKimiCodeHostIdentity } from '#/cli/version'; +import { + addRegistry, + readRegistries, + removeRegistry, + resolveRegistryUrl, +} from '#/utils/plugin-registries'; +import { loadMergedMarketplace, loadPluginMarketplace } from '#/utils/plugin-marketplace'; +import { + formatPluginSourceLabel, + isOfficialPluginSource, +} from '#/tui/utils/plugin-source-label'; + +interface WritableLike { + write(chunk: string): boolean; +} + +export interface PluginsDeps { + readonly getHarness: () => KimiHarness; + readonly getHomeDir: () => string; + readonly confirm: (message: string) => Promise; + readonly cwd: () => string; + readonly stdout: WritableLike; + readonly stderr: WritableLike; + readonly exit: (code: number) => never; + readonly addRegistry: (homeDir: string, registry: { url: string; name?: string }) => Promise; + readonly readRegistries: (homeDir: string) => Promise<{ registries: ReadonlyArray<{ name?: string; url: string }> }>; + readonly removeRegistry: (homeDir: string, nameOrUrl: string) => Promise; + readonly resolveRegistryUrl: (homeDir: string, nameOrUrl: string) => Promise; + readonly loadPluginMarketplace: typeof loadPluginMarketplace; + readonly loadMergedMarketplace: typeof loadMergedMarketplace; +} + +export interface ListOptions { + readonly json?: boolean; +} + +export interface InstallOptions { + readonly yes?: boolean; +} + +export interface RemoveOptions { + readonly yes?: boolean; +} + +export interface InfoOptions { + readonly json?: boolean; +} + +export interface MarketplaceOptions { + readonly registry?: string; + readonly json?: boolean; +} + +export interface RegistryAddOptions { + readonly name?: string; +} + +export interface RegistryListOptions { + readonly json?: boolean; +} + +export async function handlePluginsList( + deps: PluginsDeps, + options: ListOptions, +): Promise { + try { + const plugins = await deps.getHarness().listPlugins(); + if (options.json) { + deps.stdout.write(`${JSON.stringify(plugins, null, 2)}\n`); + return; + } + if (plugins.length === 0) { + deps.stdout.write('No plugins installed.\n'); + return; + } + deps.stdout.write(formatPluginsTable(plugins)); + } catch (error) { + deps.stderr.write(`Failed to list plugins: ${errorMessage(error)}\n`); + deps.exit(1); + } +} + +export async function handlePluginsInfo( + deps: PluginsDeps, + id: string, + options: InfoOptions, +): Promise { + try { + const info = await deps.getHarness().getPluginInfo(id); + if (options.json) { + deps.stdout.write(`${JSON.stringify(info, null, 2)}\n`); + return; + } + deps.stdout.write(formatPluginInfo(info)); + } catch (error) { + deps.stderr.write(`Failed to get plugin info: ${errorMessage(error)}\n`); + deps.exit(1); + } +} + +export async function handlePluginsInstall( + deps: PluginsDeps, + options: { source: string } & InstallOptions, +): Promise { + const source = resolvePluginInstallSource(options.source, deps.cwd()); + const official = isOfficialPluginSource(source); + try { + if (!official && !options.yes) { + const confirmed = await deps.confirm( + `Install plugin from third-party source "${source}"? [y/N] `, + ); + if (!confirmed) { + deps.stdout.write('Install cancelled.\n'); + return; + } + } + const summary = await deps.getHarness().installPlugin(source); + deps.stdout.write(`Installed ${summary.displayName} (${summary.id}).\n`); + } catch (error) { + deps.stderr.write(`Failed to install plugin: ${errorMessage(error)}\n`); + deps.exit(1); + } +} + +export async function handlePluginsRemove( + deps: PluginsDeps, + options: { id: string } & RemoveOptions, +): Promise { + try { + const info = await deps.getHarness().getPluginInfo(options.id); + if (!options.yes) { + const confirmed = await deps.confirm( + `Remove plugin "${info.displayName}" (${info.id})? [y/N] `, + ); + if (!confirmed) { + deps.stdout.write('Remove cancelled.\n'); + return; + } + } + await deps.getHarness().removePlugin(options.id); + deps.stdout.write(`Removed ${options.id}.\n`); + } catch (error) { + deps.stderr.write(`Failed to remove plugin: ${errorMessage(error)}\n`); + deps.exit(1); + } +} + +export async function handlePluginsEnable( + deps: PluginsDeps, + options: { id: string; enabled: boolean }, +): Promise { + try { + await deps.getHarness().setPluginEnabled(options.id, options.enabled); + deps.stdout.write(`${options.enabled ? 'Enabled' : 'Disabled'} ${options.id}.\n`); + } catch (error) { + deps.stderr.write(`Failed to ${options.enabled ? 'enable' : 'disable'} plugin: ${errorMessage(error)}\n`); + deps.exit(1); + } +} + +export async function handlePluginsMarketplace( + deps: PluginsDeps, + options: MarketplaceOptions, +): Promise { + try { + const homeDir = deps.getHomeDir(); + let marketplace; + if (options.registry !== undefined) { + const source = await deps.resolveRegistryUrl(homeDir, options.registry); + marketplace = await deps.loadPluginMarketplace({ + workDir: deps.cwd(), + source, + }); + } else { + marketplace = await deps.loadMergedMarketplace({ + kimiHomeDir: homeDir, + workDir: deps.cwd(), + }); + } + if (options.json) { + deps.stdout.write(`${JSON.stringify(marketplace.plugins, null, 2)}\n`); + return; + } + if (marketplace.plugins.length === 0) { + deps.stdout.write('No plugins available.\n'); + return; + } + deps.stdout.write(formatMarketplaceTable(marketplace.plugins)); + } catch (error) { + deps.stderr.write(`Failed to load marketplace: ${errorMessage(error)}\n`); + deps.exit(1); + } +} + +export async function handlePluginsRegistryList( + deps: PluginsDeps, + options: RegistryListOptions, +): Promise { + try { + const file = await deps.readRegistries(deps.getHomeDir()); + if (options.json) { + deps.stdout.write(`${JSON.stringify(file.registries, null, 2)}\n`); + return; + } + if (file.registries.length === 0) { + deps.stdout.write('No custom registries.\n'); + return; + } + deps.stdout.write(formatRegistriesTable(file.registries)); + } catch (error) { + deps.stderr.write(`Failed to list registries: ${errorMessage(error)}\n`); + deps.exit(1); + } +} + +export async function handlePluginsRegistryAdd( + deps: PluginsDeps, + options: { url: string } & RegistryAddOptions, +): Promise { + try { + await deps.addRegistry(deps.getHomeDir(), { + url: options.url, + name: options.name, + }); + deps.stdout.write(`Added registry ${options.name ?? options.url}.\n`); + } catch (error) { + deps.stderr.write(`Failed to add registry: ${errorMessage(error)}\n`); + deps.exit(1); + } +} + +export async function handlePluginsRegistryRemove( + deps: PluginsDeps, + options: { nameOrUrl: string }, +): Promise { + try { + await deps.removeRegistry(deps.getHomeDir(), options.nameOrUrl); + deps.stdout.write(`Removed registry ${options.nameOrUrl}.\n`); + } catch (error) { + deps.stderr.write(`Failed to remove registry: ${errorMessage(error)}\n`); + deps.exit(1); + } +} + +export function registerPluginsCommand(parent: Command, deps?: Partial): void { + const program = parent.command('plugins').description('Manage Kimi Code plugins.'); + + program + .command('list') + .description('List installed plugins.') + .option('--json', 'Output as JSON.') + .action(async (options: { json?: boolean }) => { + await handlePluginsList(createDefaultPluginsDeps(deps), options); + }); + + program + .command('info ') + .description('Show details of an installed plugin.') + .option('--json', 'Output as JSON.') + .action(async (id: string, options: { json?: boolean }) => { + await handlePluginsInfo(createDefaultPluginsDeps(deps), id, options); + }); + + program + .command('install ') + .description('Install a plugin from a local path, zip URL, or GitHub URL.') + .option('-y, --yes', 'Skip trust confirmation for third-party sources.') + .action(async (source: string, options: { yes?: boolean }) => { + await handlePluginsInstall(createDefaultPluginsDeps(deps), { source, yes: options.yes }); + }); + + program + .command('remove ') + .description('Remove an installed plugin.') + .option('-y, --yes', 'Skip confirmation.') + .action(async (id: string, options: { yes?: boolean }) => { + await handlePluginsRemove(createDefaultPluginsDeps(deps), { id, yes: options.yes }); + }); + + program + .command('enable ') + .description('Enable an installed plugin.') + .action(async (id: string) => { + await handlePluginsEnable(createDefaultPluginsDeps(deps), { id, enabled: true }); + }); + + program + .command('disable ') + .description('Disable an installed plugin.') + .action(async (id: string) => { + await handlePluginsEnable(createDefaultPluginsDeps(deps), { id, enabled: false }); + }); + + program + .command('marketplace') + .description('List available plugins from the marketplace and custom registries.') + .option('--registry ', 'Use a specific registry.') + .option('--json', 'Output as JSON.') + .action(async (options: { registry?: string; json?: boolean }) => { + await handlePluginsMarketplace(createDefaultPluginsDeps(deps), options); + }); + + const registry = program.command('registry').description('Manage custom plugin registries.'); + + registry + .command('list') + .description('List custom registries.') + .option('--json', 'Output as JSON.') + .action(async (options: { json?: boolean }) => { + await handlePluginsRegistryList(createDefaultPluginsDeps(deps), options); + }); + + registry + .command('add ') + .description('Add a custom registry.') + .option('--name ', 'Optional display name.') + .action(async (url: string, options: { name?: string }) => { + await handlePluginsRegistryAdd(createDefaultPluginsDeps(deps), { url, name: options.name }); + }); + + registry + .command('remove ') + .description('Remove a custom registry by name or URL.') + .action(async (nameOrUrl: string) => { + await handlePluginsRegistryRemove(createDefaultPluginsDeps(deps), { nameOrUrl }); + }); +} + +function createDefaultPluginsDeps(overrides: Partial = {}): PluginsDeps { + let harness: KimiHarness | undefined; + let telemetryBootstrap: ReturnType | undefined; + const identity = createKimiCodeHostIdentity(); + const telemetryClient: TelemetryClient = { + track, + withContext: withTelemetryContext, + setContext: setTelemetryContext, + }; + const getTelemetryBootstrap = (): ReturnType => { + telemetryBootstrap ??= createCliTelemetryBootstrap(); + return telemetryBootstrap; + }; + const getHarness = (): KimiHarness => { + const currentTelemetryBootstrap = getTelemetryBootstrap(); + harness ??= createKimiHarness({ + homeDir: currentTelemetryBootstrap.homeDir, + identity, + telemetry: telemetryClient, + uiMode: CLI_UI_MODE, + }); + return harness; + }; + const getHomeDir = (): string => getTelemetryBootstrap().homeDir; + return { + getHarness: overrides.getHarness ?? getHarness, + getHomeDir: overrides.getHomeDir ?? getHomeDir, + cwd: overrides.cwd ?? (() => process.cwd()), + stdout: overrides.stdout ?? process.stdout, + stderr: overrides.stderr ?? process.stderr, + exit: overrides.exit ?? ((code: number) => process.exit(code)), + confirm: overrides.confirm ?? confirmPrompt, + addRegistry: overrides.addRegistry ?? addRegistry, + readRegistries: overrides.readRegistries ?? readRegistries, + removeRegistry: overrides.removeRegistry ?? removeRegistry, + resolveRegistryUrl: overrides.resolveRegistryUrl ?? resolveRegistryUrl, + loadPluginMarketplace: overrides.loadPluginMarketplace ?? loadPluginMarketplace, + loadMergedMarketplace: overrides.loadMergedMarketplace ?? loadMergedMarketplace, + }; +} + +async function confirmPrompt(message: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + try { + const answer = await rl.question(message); + const trimmed = answer.trim().toLowerCase(); + return trimmed === 'y' || trimmed === 'yes'; + } finally { + rl.close(); + } +} + +function resolvePluginInstallSource(source: string, workDir: string): string { + const trimmed = source.trim(); + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return trimmed; + if (trimmed === '~') return osHomedir(); + if (trimmed.startsWith('~/')) return join(osHomedir(), trimmed.slice(2)); + return isAbsolute(trimmed) ? trimmed : resolve(workDir, trimmed); +} + +function formatPluginsTable(plugins: readonly PluginSummary[]): string { + const lines = plugins.map((p) => { + const status = p.enabled ? 'enabled' : 'disabled'; + const version = p.version ?? '-'; + return `${p.id}\t${p.displayName}\t${version}\t${status}\t${formatPluginSourceLabel(p)}`; + }); + return ['ID\tNAME\tVERSION\tSTATUS\tSOURCE', ...lines, ''].join('\n'); +} + +function formatPluginInfo(info: PluginInfo): string { + const lines = [ + `ID: ${info.id}`, + `Name: ${info.displayName}`, + `Version: ${info.version ?? '-'}`, + `Enabled: ${info.enabled}`, + `State: ${info.state}`, + `Source: ${formatPluginSourceLabel(info)}`, + `Skills: ${info.skillCount}`, + `MCP: ${info.enabledMcpServerCount}/${info.mcpServerCount}`, + `Hooks: ${info.hookCount}`, + `Commands: ${info.commandCount}`, + ]; + return `${lines.join('\n')}\n`; +} + +function formatMarketplaceTable( + plugins: ReadonlyArray<{ id: string; displayName: string; version?: string; description?: string }>, +): string { + const lines = plugins.map((p) => { + const version = p.version ?? '-'; + return `${p.id}\t${p.displayName}\t${version}\t${p.description ?? ''}`; + }); + return ['ID\tNAME\tVERSION\tDESCRIPTION', ...lines, ''].join('\n'); +} + +function formatRegistriesTable( + registries: ReadonlyArray<{ name?: string; url: string }>, +): string { + const lines = registries.map((r) => `${r.name ?? '-'}\t${r.url}`); + return ['NAME\tURL', ...lines, ''].join('\n'); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/kimi-code/test/cli/sub/plugins.test.ts b/apps/kimi-code/test/cli/sub/plugins.test.ts new file mode 100644 index 000000000..843d360f8 --- /dev/null +++ b/apps/kimi-code/test/cli/sub/plugins.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + handlePluginsInstall, + handlePluginsList, + handlePluginsRegistryAdd, + handlePluginsRegistryList, + handlePluginsRemove, +} from '#/cli/sub/plugins'; +import type { PluginSummary } from '@moonshot-ai/kimi-code-sdk'; + +function makeDeps(overrides: Record = {}) { + return { + cwd: () => '/tmp/work', + stdout: { write: vi.fn() }, + stderr: { write: vi.fn() }, + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }) as (code: number) => never, + getHarness: vi.fn(), + getHomeDir: () => '/home/cyijun/.kimi-code', + ...overrides, + }; +} + +describe('handlePluginsList', () => { + it('prints installed plugins as JSON', async () => { + const summary: PluginSummary = { + id: 'demo', + displayName: 'Demo', + version: '1.0.0', + enabled: true, + state: 'ok', + skillCount: 0, + mcpServerCount: 0, + enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, + hasErrors: false, + source: 'local-path', + }; + const deps = makeDeps({ + getHarness: vi.fn(() => ({ + listPlugins: vi.fn(async () => [summary]), + })), + }); + + await handlePluginsList(deps as never, { json: true }); + + const written = (deps.stdout.write as ReturnType).mock.calls.map((c) => c[0]).join(''); + expect(written).toContain('"id": "demo"'); + }); +}); + +describe('handlePluginsInstall', () => { + it('installs a plugin without confirmation when --yes is passed', async () => { + const summary: PluginSummary = { + id: 'demo', + displayName: 'Demo', + version: '1.0.0', + enabled: true, + state: 'ok', + skillCount: 0, + mcpServerCount: 0, + enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, + hasErrors: false, + source: 'local-path', + }; + const deps = makeDeps({ + getHarness: vi.fn(() => ({ + listPlugins: vi.fn(async () => []), + installPlugin: vi.fn(async () => summary), + })), + }); + + await handlePluginsInstall(deps as never, { source: '/tmp/demo', yes: true }); + + expect(deps.exit).not.toHaveBeenCalled(); + expect(deps.stdout.write).toHaveBeenCalledWith(expect.stringContaining('demo')); + }); +}); + +describe('handlePluginsRegistryAdd', () => { + it('adds a registry', async () => { + const addRegistry = vi.fn(async () => undefined); + const deps = makeDeps({ addRegistry }); + + await handlePluginsRegistryAdd(deps as never, { url: 'https://example.com/m.json', name: 'example' }); + + expect(addRegistry).toHaveBeenCalledWith('/home/cyijun/.kimi-code', { + url: 'https://example.com/m.json', + name: 'example', + }); + }); +}); From 7f46d1de7eeceb1e639d6d0379ec1cd1dbd0bf95 Mon Sep 17 00:00:00 2001 From: Chen Yijun Date: Sun, 5 Jul 2026 21:03:35 +0800 Subject: [PATCH 07/17] chore: add changeset for kimi plugins command --- .changeset/add-kimi-plugins-command.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/add-kimi-plugins-command.md diff --git a/.changeset/add-kimi-plugins-command.md b/.changeset/add-kimi-plugins-command.md new file mode 100644 index 000000000..05860e7dc --- /dev/null +++ b/.changeset/add-kimi-plugins-command.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the `kimi plugins` command to manage plugins and custom registries. Run `kimi plugins --help` to see available subcommands. From 96a8aea2377fabf7119fdda418767e6fe002e46c Mon Sep 17 00:00:00 2001 From: Chen Yijun Date: Sun, 5 Jul 2026 21:10:44 +0800 Subject: [PATCH 08/17] feat(kimi-code): register plugins subcommand --- apps/kimi-code/src/cli/commands.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 32a65eb0e..1051b50d6 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -7,6 +7,7 @@ import { registerAcpCommand } from './sub/acp'; import { registerDoctorCommand } from './sub/doctor'; import { registerExportCommand } from './sub/export'; import { registerLoginCommand } from './sub/login'; +import { registerPluginsCommand } from './sub/plugins'; import { registerProviderCommand } from './sub/provider'; import { registerServerCommand } from './sub/server'; import { registerVisCommand } from './sub/vis'; @@ -94,6 +95,7 @@ export function createProgram( registerDoctorCommand(program); registerVisCommand(program); registerMigrateCommand(program, onMigrate); + registerPluginsCommand(program); program .command('upgrade') .alias('update') From 34ba90bf67a078b4fc04c762674b67f6fe41dc8f Mon Sep 17 00:00:00 2001 From: Chen Yijun Date: Sun, 5 Jul 2026 21:18:04 +0800 Subject: [PATCH 09/17] chore: verify tests and lint for kimi plugins CLI --- apps/kimi-code/test/cli/options.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 73d759841..3c8d3e6d1 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -390,6 +390,7 @@ describe('CLI options parsing', () => { 'doctor', 'vis', 'migrate', + 'plugins', 'upgrade', ]); }); From 976a834dcc946209d0e41ea1045ed301f2f4a711 Mon Sep 17 00:00:00 2001 From: Chen Yijun Date: Sun, 5 Jul 2026 21:35:37 +0800 Subject: [PATCH 10/17] fix(cli/plugins): initialize and shutdown telemetry, close harness, and move official check --- apps/kimi-code/src/cli/sub/plugins.ts | 79 ++++++++++++++++++++++----- 1 file changed, 64 insertions(+), 15 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/plugins.ts b/apps/kimi-code/src/cli/sub/plugins.ts index c000afa0d..2afb40ec9 100644 --- a/apps/kimi-code/src/cli/sub/plugins.ts +++ b/apps/kimi-code/src/cli/sub/plugins.ts @@ -12,12 +12,13 @@ import { } from '@moonshot-ai/kimi-code-sdk'; import { setTelemetryContext, + shutdownTelemetry, track, withTelemetryContext, } from '@moonshot-ai/kimi-telemetry'; -import { CLI_UI_MODE } from '#/constant/app'; -import { createCliTelemetryBootstrap } from '#/cli/telemetry'; +import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app'; +import { createCliTelemetryBootstrap, initializeCliTelemetry } from '#/cli/telemetry'; import { createKimiCodeHostIdentity } from '#/cli/version'; import { addRegistry, @@ -123,9 +124,9 @@ export async function handlePluginsInstall( deps: PluginsDeps, options: { source: string } & InstallOptions, ): Promise { - const source = resolvePluginInstallSource(options.source, deps.cwd()); - const official = isOfficialPluginSource(source); try { + const source = resolvePluginInstallSource(options.source, deps.cwd()); + const official = isOfficialPluginSource(source); if (!official && !options.yes) { const confirmed = await deps.confirm( `Install plugin from third-party source "${source}"? [y/N] `, @@ -264,6 +265,17 @@ export async function handlePluginsRegistryRemove( } export function registerPluginsCommand(parent: Command, deps?: Partial): void { + const runWithLifecycle = async (fn: (d: DefaultPluginsDeps) => Promise): Promise => { + const d = createDefaultPluginsDeps(deps); + try { + await d.initializeDefaultTelemetry(); + await fn(d); + } finally { + await d.shutdownDefaultTelemetry(); + await d.closeDefaultHarness(); + } + }; + const program = parent.command('plugins').description('Manage Kimi Code plugins.'); program @@ -271,7 +283,7 @@ export function registerPluginsCommand(parent: Command, deps?: Partial { - await handlePluginsList(createDefaultPluginsDeps(deps), options); + await runWithLifecycle((d) => handlePluginsList(d, options)); }); program @@ -279,7 +291,7 @@ export function registerPluginsCommand(parent: Command, deps?: Partial { - await handlePluginsInfo(createDefaultPluginsDeps(deps), id, options); + await runWithLifecycle((d) => handlePluginsInfo(d, id, options)); }); program @@ -287,7 +299,7 @@ export function registerPluginsCommand(parent: Command, deps?: Partial { - await handlePluginsInstall(createDefaultPluginsDeps(deps), { source, yes: options.yes }); + await runWithLifecycle((d) => handlePluginsInstall(d, { source, yes: options.yes })); }); program @@ -295,21 +307,21 @@ export function registerPluginsCommand(parent: Command, deps?: Partial { - await handlePluginsRemove(createDefaultPluginsDeps(deps), { id, yes: options.yes }); + await runWithLifecycle((d) => handlePluginsRemove(d, { id, yes: options.yes })); }); program .command('enable ') .description('Enable an installed plugin.') .action(async (id: string) => { - await handlePluginsEnable(createDefaultPluginsDeps(deps), { id, enabled: true }); + await runWithLifecycle((d) => handlePluginsEnable(d, { id, enabled: true })); }); program .command('disable ') .description('Disable an installed plugin.') .action(async (id: string) => { - await handlePluginsEnable(createDefaultPluginsDeps(deps), { id, enabled: false }); + await runWithLifecycle((d) => handlePluginsEnable(d, { id, enabled: false })); }); program @@ -318,7 +330,7 @@ export function registerPluginsCommand(parent: Command, deps?: Partial', 'Use a specific registry.') .option('--json', 'Output as JSON.') .action(async (options: { registry?: string; json?: boolean }) => { - await handlePluginsMarketplace(createDefaultPluginsDeps(deps), options); + await runWithLifecycle((d) => handlePluginsMarketplace(d, options)); }); const registry = program.command('registry').description('Manage custom plugin registries.'); @@ -328,7 +340,7 @@ export function registerPluginsCommand(parent: Command, deps?: Partial { - await handlePluginsRegistryList(createDefaultPluginsDeps(deps), options); + await runWithLifecycle((d) => handlePluginsRegistryList(d, options)); }); registry @@ -336,20 +348,28 @@ export function registerPluginsCommand(parent: Command, deps?: Partial', 'Optional display name.') .action(async (url: string, options: { name?: string }) => { - await handlePluginsRegistryAdd(createDefaultPluginsDeps(deps), { url, name: options.name }); + await runWithLifecycle((d) => handlePluginsRegistryAdd(d, { url, name: options.name })); }); registry .command('remove ') .description('Remove a custom registry by name or URL.') .action(async (nameOrUrl: string) => { - await handlePluginsRegistryRemove(createDefaultPluginsDeps(deps), { nameOrUrl }); + await runWithLifecycle((d) => handlePluginsRegistryRemove(d, { nameOrUrl })); }); } -function createDefaultPluginsDeps(overrides: Partial = {}): PluginsDeps { +interface DefaultPluginsDeps extends PluginsDeps { + readonly initializeDefaultTelemetry: () => Promise; + readonly shutdownDefaultTelemetry: () => Promise; + readonly closeDefaultHarness: () => Promise; +} + +function createDefaultPluginsDeps(overrides: Partial = {}): DefaultPluginsDeps { let harness: KimiHarness | undefined; let telemetryBootstrap: ReturnType | undefined; + let telemetryInitialized = false; + let telemetryShutdown = false; const identity = createKimiCodeHostIdentity(); const telemetryClient: TelemetryClient = { track, @@ -370,6 +390,32 @@ function createDefaultPluginsDeps(overrides: Partial = {}): Plugins }); return harness; }; + const initializeDefaultTelemetry = async (): Promise => { + if (telemetryInitialized) return; + const currentTelemetryBootstrap = getTelemetryBootstrap(); + const currentHarness = getHarness(); + await currentHarness.ensureConfigFile(); + const config = await currentHarness.getConfig(); + initializeCliTelemetry({ + harness: currentHarness, + bootstrap: currentTelemetryBootstrap, + config, + version: identity.version, + uiMode: CLI_UI_MODE, + }); + telemetryInitialized = true; + }; + const shutdownDefaultTelemetry = async (): Promise => { + if (!telemetryInitialized || telemetryShutdown) return; + telemetryShutdown = true; + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + }; + const closeDefaultHarness = async (): Promise => { + if (harness === undefined) return; + const currentHarness = harness; + harness = undefined; + await currentHarness.close(); + }; const getHomeDir = (): string => getTelemetryBootstrap().homeDir; return { getHarness: overrides.getHarness ?? getHarness, @@ -385,6 +431,9 @@ function createDefaultPluginsDeps(overrides: Partial = {}): Plugins resolveRegistryUrl: overrides.resolveRegistryUrl ?? resolveRegistryUrl, loadPluginMarketplace: overrides.loadPluginMarketplace ?? loadPluginMarketplace, loadMergedMarketplace: overrides.loadMergedMarketplace ?? loadMergedMarketplace, + initializeDefaultTelemetry, + shutdownDefaultTelemetry, + closeDefaultHarness, }; } From 0b01f81bbdf63c6edc4a1cc3cd537f13377259db Mon Sep 17 00:00:00 2001 From: Chen Yijun Date: Sun, 5 Jul 2026 21:35:39 +0800 Subject: [PATCH 11/17] test(cli/plugins): expand handler coverage for plugins commands --- apps/kimi-code/test/cli/sub/plugins.test.ts | 277 +++++++++++++++++--- 1 file changed, 246 insertions(+), 31 deletions(-) diff --git a/apps/kimi-code/test/cli/sub/plugins.test.ts b/apps/kimi-code/test/cli/sub/plugins.test.ts index 843d360f8..8ef48f954 100644 --- a/apps/kimi-code/test/cli/sub/plugins.test.ts +++ b/apps/kimi-code/test/cli/sub/plugins.test.ts @@ -1,13 +1,18 @@ import { describe, expect, it, vi } from 'vitest'; import { + handlePluginsEnable, + handlePluginsInfo, handlePluginsInstall, handlePluginsList, + handlePluginsMarketplace, handlePluginsRegistryAdd, handlePluginsRegistryList, + handlePluginsRegistryRemove, handlePluginsRemove, } from '#/cli/sub/plugins'; -import type { PluginSummary } from '@moonshot-ai/kimi-code-sdk'; +import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; +import type { PluginMarketplace } from '#/utils/plugin-marketplace'; function makeDeps(overrides: Record = {}) { return { @@ -23,22 +28,58 @@ function makeDeps(overrides: Record = {}) { }; } +function getWritten(stream: { write: ReturnType }): string { + return stream.write.mock.calls.map((c: unknown[]) => c[0]).join(''); +} + +function makePluginSummary(overrides: Partial = {}): PluginSummary { + return { + id: 'demo', + displayName: 'Demo', + version: '1.0.0', + enabled: true, + state: 'ok', + skillCount: 0, + mcpServerCount: 0, + enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, + hasErrors: false, + source: 'local-path', + ...overrides, + }; +} + +function makePluginInfo(overrides: Partial = {}): PluginInfo { + return { + ...makePluginSummary(), + root: '/plugins/demo', + installedAt: new Date().toISOString(), + mcpServers: [], + diagnostics: [], + ...overrides, + }; +} + +function makeMarketplace(overrides: Partial = {}): PluginMarketplace { + return { + source: 'https://example.com/marketplace.json', + plugins: [ + { + id: 'market-demo', + displayName: 'Market Demo', + version: '2.0.0', + description: 'A demo plugin', + source: 'https://example.com/market-demo.zip', + }, + ], + ...overrides, + }; +} + describe('handlePluginsList', () => { it('prints installed plugins as JSON', async () => { - const summary: PluginSummary = { - id: 'demo', - displayName: 'Demo', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 0, - mcpServerCount: 0, - enabledMcpServerCount: 0, - hookCount: 0, - commandCount: 0, - hasErrors: false, - source: 'local-path', - }; + const summary = makePluginSummary(); const deps = makeDeps({ getHarness: vi.fn(() => ({ listPlugins: vi.fn(async () => [summary]), @@ -47,27 +88,42 @@ describe('handlePluginsList', () => { await handlePluginsList(deps as never, { json: true }); - const written = (deps.stdout.write as ReturnType).mock.calls.map((c) => c[0]).join(''); - expect(written).toContain('"id": "demo"'); + expect(getWritten(deps.stdout)).toContain('"id": "demo"'); + }); +}); + +describe('handlePluginsInfo', () => { + it('prints plugin info as JSON', async () => { + const info = makePluginInfo(); + const deps = makeDeps({ + getHarness: vi.fn(() => ({ + getPluginInfo: vi.fn(async () => info), + })), + }); + + await handlePluginsInfo(deps as never, 'demo', { json: true }); + + expect(getWritten(deps.stdout)).toContain('"id": "demo"'); + }); + + it('prints plugin info as text', async () => { + const info = makePluginInfo({ id: 'demo', displayName: 'Demo' }); + const deps = makeDeps({ + getHarness: vi.fn(() => ({ + getPluginInfo: vi.fn(async () => info), + })), + }); + + await handlePluginsInfo(deps as never, 'demo', {}); + + expect(getWritten(deps.stdout)).toContain('ID:'); + expect(getWritten(deps.stdout)).toContain('demo'); }); }); describe('handlePluginsInstall', () => { it('installs a plugin without confirmation when --yes is passed', async () => { - const summary: PluginSummary = { - id: 'demo', - displayName: 'Demo', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 0, - mcpServerCount: 0, - enabledMcpServerCount: 0, - hookCount: 0, - commandCount: 0, - hasErrors: false, - source: 'local-path', - }; + const summary = makePluginSummary(); const deps = makeDeps({ getHarness: vi.fn(() => ({ listPlugins: vi.fn(async () => []), @@ -80,6 +136,138 @@ describe('handlePluginsInstall', () => { expect(deps.exit).not.toHaveBeenCalled(); expect(deps.stdout.write).toHaveBeenCalledWith(expect.stringContaining('demo')); }); + + it('cancels third-party install when not confirmed', async () => { + const installPlugin = vi.fn(async () => makePluginSummary()); + const deps = makeDeps({ + confirm: vi.fn(async () => false), + getHarness: vi.fn(() => ({ + installPlugin, + })), + }); + + await handlePluginsInstall(deps as never, { source: '/tmp/demo' }); + + expect(installPlugin).not.toHaveBeenCalled(); + expect(getWritten(deps.stdout)).toContain('Install cancelled.'); + }); +}); + +describe('handlePluginsRemove', () => { + it('removes a plugin after confirmation', async () => { + const removePlugin = vi.fn(async () => undefined); + const deps = makeDeps({ + confirm: vi.fn(async () => true), + getHarness: vi.fn(() => ({ + getPluginInfo: vi.fn(async () => makePluginInfo({ id: 'demo', displayName: 'Demo' })), + removePlugin, + })), + }); + + await handlePluginsRemove(deps as never, { id: 'demo' }); + + expect(removePlugin).toHaveBeenCalledWith('demo'); + expect(getWritten(deps.stdout)).toContain('Removed demo.'); + }); + + it('cancels remove when not confirmed', async () => { + const removePlugin = vi.fn(async () => undefined); + const deps = makeDeps({ + confirm: vi.fn(async () => false), + getHarness: vi.fn(() => ({ + getPluginInfo: vi.fn(async () => makePluginInfo({ id: 'demo', displayName: 'Demo' })), + removePlugin, + })), + }); + + await handlePluginsRemove(deps as never, { id: 'demo' }); + + expect(removePlugin).not.toHaveBeenCalled(); + expect(getWritten(deps.stdout)).toContain('Remove cancelled.'); + }); +}); + +describe('handlePluginsEnable', () => { + it('enables a plugin', async () => { + const setPluginEnabled = vi.fn(async () => undefined); + const deps = makeDeps({ + getHarness: vi.fn(() => ({ + setPluginEnabled, + })), + }); + + await handlePluginsEnable(deps as never, { id: 'demo', enabled: true }); + + expect(setPluginEnabled).toHaveBeenCalledWith('demo', true); + expect(getWritten(deps.stdout)).toContain('Enabled demo.'); + }); + + it('disables a plugin', async () => { + const setPluginEnabled = vi.fn(async () => undefined); + const deps = makeDeps({ + getHarness: vi.fn(() => ({ + setPluginEnabled, + })), + }); + + await handlePluginsEnable(deps as never, { id: 'demo', enabled: false }); + + expect(setPluginEnabled).toHaveBeenCalledWith('demo', false); + expect(getWritten(deps.stdout)).toContain('Disabled demo.'); + }); +}); + +describe('handlePluginsMarketplace', () => { + it('prints merged marketplace plugins as JSON', async () => { + const loadMergedMarketplace = vi.fn(async () => makeMarketplace()); + const deps = makeDeps({ loadMergedMarketplace }); + + await handlePluginsMarketplace(deps as never, { json: true }); + + expect(loadMergedMarketplace).toHaveBeenCalledWith({ + kimiHomeDir: '/home/cyijun/.kimi-code', + workDir: '/tmp/work', + }); + expect(getWritten(deps.stdout)).toContain('"id": "market-demo"'); + }); + + it('prints marketplace from a specific registry', async () => { + const resolveRegistryUrl = vi.fn(async () => 'https://custom.example.com/m.json'); + const loadPluginMarketplace = vi.fn(async () => makeMarketplace()); + const deps = makeDeps({ resolveRegistryUrl, loadPluginMarketplace }); + + await handlePluginsMarketplace(deps as never, { registry: 'custom', json: true }); + + expect(resolveRegistryUrl).toHaveBeenCalledWith('/home/cyijun/.kimi-code', 'custom'); + expect(loadPluginMarketplace).toHaveBeenCalledWith({ + workDir: '/tmp/work', + source: 'https://custom.example.com/m.json', + }); + expect(getWritten(deps.stdout)).toContain('"id": "market-demo"'); + }); +}); + +describe('handlePluginsRegistryList', () => { + it('lists registries as JSON', async () => { + const readRegistries = vi.fn(async () => ({ + registries: [{ name: 'custom', url: 'https://custom.example.com/m.json' }], + })); + const deps = makeDeps({ readRegistries }); + + await handlePluginsRegistryList(deps as never, { json: true }); + + expect(readRegistries).toHaveBeenCalledWith('/home/cyijun/.kimi-code'); + expect(getWritten(deps.stdout)).toContain('https://custom.example.com/m.json'); + }); + + it('reports no custom registries', async () => { + const readRegistries = vi.fn(async () => ({ registries: [] })); + const deps = makeDeps({ readRegistries }); + + await handlePluginsRegistryList(deps as never, {}); + + expect(getWritten(deps.stdout)).toContain('No custom registries.'); + }); }); describe('handlePluginsRegistryAdd', () => { @@ -95,3 +283,30 @@ describe('handlePluginsRegistryAdd', () => { }); }); }); + +describe('handlePluginsRegistryRemove', () => { + it('removes a registry', async () => { + const removeRegistry = vi.fn(async () => undefined); + const deps = makeDeps({ removeRegistry }); + + await handlePluginsRegistryRemove(deps as never, { nameOrUrl: 'custom' }); + + expect(removeRegistry).toHaveBeenCalledWith('/home/cyijun/.kimi-code', 'custom'); + expect(getWritten(deps.stdout)).toContain('Removed registry custom.'); + }); +}); + +describe('handler errors', () => { + it('exits when getting plugin info fails', async () => { + const deps = makeDeps({ + getHarness: vi.fn(() => ({ + getPluginInfo: vi.fn(async () => { + throw new Error('boom'); + }), + })), + }); + + await expect(handlePluginsInfo(deps as never, 'demo', { json: true })).rejects.toThrow('exit:1'); + expect(getWritten(deps.stderr)).toContain('Failed to get plugin info: boom'); + }); +}); From ab037593545f86b387e49ef73e2395bac825c2eb Mon Sep 17 00:00:00 2001 From: Chen Yijun Date: Sun, 5 Jul 2026 21:35:42 +0800 Subject: [PATCH 12/17] chore: add changeset for plugin SDK methods --- .changeset/plugin-management-sdk-methods.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/plugin-management-sdk-methods.md diff --git a/.changeset/plugin-management-sdk-methods.md b/.changeset/plugin-management-sdk-methods.md new file mode 100644 index 000000000..6c3c8a9ce --- /dev/null +++ b/.changeset/plugin-management-sdk-methods.md @@ -0,0 +1,5 @@ +--- +'@moonshot-ai/kimi-code-sdk': minor +--- + +Expose plugin management methods on `KimiHarness`: `listPlugins`, `installPlugin`, `setPluginEnabled`, `removePlugin`, and `getPluginInfo`. From 8f38bf943740fe7ca26a5689bf4cd08cad22122b Mon Sep 17 00:00:00 2001 From: Chen Yijun Date: Sun, 5 Jul 2026 21:48:31 +0800 Subject: [PATCH 13/17] fix(plugins): ensure lifecycle cleanup runs before exit on handler errors Handlers previously wrote to stderr and called deps.exit(1) directly, which terminated the process before runWithLifecycle's finally block could shutdown telemetry and close the harness. Move error writing and exit into runWithLifecycle so cleanup always runs first. --- apps/kimi-code/src/cli/sub/plugins.ts | 34 ++++---- apps/kimi-code/test/cli/sub/plugins.test.ts | 87 ++++++++++++++++++++- 2 files changed, 100 insertions(+), 21 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/plugins.ts b/apps/kimi-code/src/cli/sub/plugins.ts index 2afb40ec9..5ae6395ed 100644 --- a/apps/kimi-code/src/cli/sub/plugins.ts +++ b/apps/kimi-code/src/cli/sub/plugins.ts @@ -81,6 +81,8 @@ export interface RegistryListOptions { readonly json?: boolean; } +class PluginsCommandError extends Error {} + export async function handlePluginsList( deps: PluginsDeps, options: ListOptions, @@ -97,8 +99,7 @@ export async function handlePluginsList( } deps.stdout.write(formatPluginsTable(plugins)); } catch (error) { - deps.stderr.write(`Failed to list plugins: ${errorMessage(error)}\n`); - deps.exit(1); + throw new PluginsCommandError(`Failed to list plugins: ${errorMessage(error)}`); } } @@ -115,8 +116,7 @@ export async function handlePluginsInfo( } deps.stdout.write(formatPluginInfo(info)); } catch (error) { - deps.stderr.write(`Failed to get plugin info: ${errorMessage(error)}\n`); - deps.exit(1); + throw new PluginsCommandError(`Failed to get plugin info: ${errorMessage(error)}`); } } @@ -139,8 +139,7 @@ export async function handlePluginsInstall( const summary = await deps.getHarness().installPlugin(source); deps.stdout.write(`Installed ${summary.displayName} (${summary.id}).\n`); } catch (error) { - deps.stderr.write(`Failed to install plugin: ${errorMessage(error)}\n`); - deps.exit(1); + throw new PluginsCommandError(`Failed to install plugin: ${errorMessage(error)}`); } } @@ -162,8 +161,7 @@ export async function handlePluginsRemove( await deps.getHarness().removePlugin(options.id); deps.stdout.write(`Removed ${options.id}.\n`); } catch (error) { - deps.stderr.write(`Failed to remove plugin: ${errorMessage(error)}\n`); - deps.exit(1); + throw new PluginsCommandError(`Failed to remove plugin: ${errorMessage(error)}`); } } @@ -175,8 +173,7 @@ export async function handlePluginsEnable( await deps.getHarness().setPluginEnabled(options.id, options.enabled); deps.stdout.write(`${options.enabled ? 'Enabled' : 'Disabled'} ${options.id}.\n`); } catch (error) { - deps.stderr.write(`Failed to ${options.enabled ? 'enable' : 'disable'} plugin: ${errorMessage(error)}\n`); - deps.exit(1); + throw new PluginsCommandError(`Failed to ${options.enabled ? 'enable' : 'disable'} plugin: ${errorMessage(error)}`); } } @@ -209,8 +206,7 @@ export async function handlePluginsMarketplace( } deps.stdout.write(formatMarketplaceTable(marketplace.plugins)); } catch (error) { - deps.stderr.write(`Failed to load marketplace: ${errorMessage(error)}\n`); - deps.exit(1); + throw new PluginsCommandError(`Failed to load marketplace: ${errorMessage(error)}`); } } @@ -230,8 +226,7 @@ export async function handlePluginsRegistryList( } deps.stdout.write(formatRegistriesTable(file.registries)); } catch (error) { - deps.stderr.write(`Failed to list registries: ${errorMessage(error)}\n`); - deps.exit(1); + throw new PluginsCommandError(`Failed to list registries: ${errorMessage(error)}`); } } @@ -246,8 +241,7 @@ export async function handlePluginsRegistryAdd( }); deps.stdout.write(`Added registry ${options.name ?? options.url}.\n`); } catch (error) { - deps.stderr.write(`Failed to add registry: ${errorMessage(error)}\n`); - deps.exit(1); + throw new PluginsCommandError(`Failed to add registry: ${errorMessage(error)}`); } } @@ -259,21 +253,25 @@ export async function handlePluginsRegistryRemove( await deps.removeRegistry(deps.getHomeDir(), options.nameOrUrl); deps.stdout.write(`Removed registry ${options.nameOrUrl}.\n`); } catch (error) { - deps.stderr.write(`Failed to remove registry: ${errorMessage(error)}\n`); - deps.exit(1); + throw new PluginsCommandError(`Failed to remove registry: ${errorMessage(error)}`); } } export function registerPluginsCommand(parent: Command, deps?: Partial): void { const runWithLifecycle = async (fn: (d: DefaultPluginsDeps) => Promise): Promise => { const d = createDefaultPluginsDeps(deps); + let failed = false; try { await d.initializeDefaultTelemetry(); await fn(d); + } catch (error) { + failed = true; + d.stderr.write(`${errorMessage(error)}\n`); } finally { await d.shutdownDefaultTelemetry(); await d.closeDefaultHarness(); } + d.exit(failed ? 1 : 0); }; const program = parent.command('plugins').description('Manage Kimi Code plugins.'); diff --git a/apps/kimi-code/test/cli/sub/plugins.test.ts b/apps/kimi-code/test/cli/sub/plugins.test.ts index 8ef48f954..a6cc16884 100644 --- a/apps/kimi-code/test/cli/sub/plugins.test.ts +++ b/apps/kimi-code/test/cli/sub/plugins.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import { Command } from 'commander'; import { handlePluginsEnable, @@ -10,10 +11,33 @@ import { handlePluginsRegistryList, handlePluginsRegistryRemove, handlePluginsRemove, + registerPluginsCommand, } from '#/cli/sub/plugins'; import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; import type { PluginMarketplace } from '#/utils/plugin-marketplace'; +const mocks = vi.hoisted(() => ({ + shutdownTelemetry: vi.fn(), + createCliTelemetryBootstrap: vi.fn(() => ({ + homeDir: '/tmp/kimi-home', + deviceId: 'device-id', + firstLaunch: false, + })), + initializeCliTelemetry: vi.fn(), +})); + +vi.mock('@moonshot-ai/kimi-telemetry', () => ({ + track: vi.fn(), + setTelemetryContext: vi.fn(), + withTelemetryContext: vi.fn(), + shutdownTelemetry: mocks.shutdownTelemetry, +})); + +vi.mock('../../../src/cli/telemetry', () => ({ + createCliTelemetryBootstrap: mocks.createCliTelemetryBootstrap, + initializeCliTelemetry: mocks.initializeCliTelemetry, +})); + function makeDeps(overrides: Record = {}) { return { cwd: () => '/tmp/work', @@ -297,7 +321,7 @@ describe('handlePluginsRegistryRemove', () => { }); describe('handler errors', () => { - it('exits when getting plugin info fails', async () => { + it('throws when getting plugin info fails', async () => { const deps = makeDeps({ getHarness: vi.fn(() => ({ getPluginInfo: vi.fn(async () => { @@ -306,7 +330,64 @@ describe('handler errors', () => { })), }); - await expect(handlePluginsInfo(deps as never, 'demo', { json: true })).rejects.toThrow('exit:1'); - expect(getWritten(deps.stderr)).toContain('Failed to get plugin info: boom'); + await expect(handlePluginsInfo(deps as never, 'demo', { json: true })).rejects.toThrow( + 'Failed to get plugin info: boom', + ); + expect(deps.exit).not.toHaveBeenCalled(); + }); +}); + +describe('registerPluginsCommand', () => { + function makeHarness(overrides: Record = {}) { + return { + listPlugins: vi.fn(async () => []), + ensureConfigFile: vi.fn(async () => undefined), + getConfig: vi.fn(async () => ({ telemetry: false, defaultModel: 'kimi' })), + close: vi.fn(async () => undefined), + ...overrides, + }; + } + + it('cleans up and exits 1 when a handler throws', async () => { + const harness = makeHarness({ + listPlugins: vi.fn(async () => { + throw new Error('boom'); + }), + }); + const exit = vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }) as (code: number) => never; + const stderr = { write: vi.fn() }; + const parent = new Command(); + registerPluginsCommand(parent, { + getHarness: () => harness as never, + stderr, + exit, + cwd: () => '/tmp/work', + getHomeDir: () => '/tmp/kimi-home', + }); + + await expect(parent.parseAsync(['node', 'test', 'plugins', 'list'])).rejects.toThrow('exit:1'); + + expect(mocks.shutdownTelemetry).toHaveBeenCalled(); + expect(getWritten(stderr)).toContain('Failed to list plugins: boom'); + }); + + it('exits 0 after cleanup on success', async () => { + const harness = makeHarness(); + const exit = vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }) as (code: number) => never; + const parent = new Command(); + registerPluginsCommand(parent, { + getHarness: () => harness as never, + exit, + cwd: () => '/tmp/work', + getHomeDir: () => '/tmp/kimi-home', + }); + + await expect(parent.parseAsync(['node', 'test', 'plugins', 'list'])).rejects.toThrow('exit:0'); + + expect(mocks.shutdownTelemetry).toHaveBeenCalled(); }); }); From f0d3213c565bb17af1b749cd9be95e9427c64207 Mon Sep 17 00:00:00 2001 From: Chen Yijun Date: Sun, 5 Jul 2026 22:14:06 +0800 Subject: [PATCH 14/17] docs: document kimi plugins CLI command and custom registries --- docs/en/customization/plugins.md | 33 ++++++++ docs/en/reference/kimi-command.md | 134 +++++++++++++++++++++++++++++- docs/zh/customization/plugins.md | 33 ++++++++ docs/zh/reference/kimi-command.md | 134 +++++++++++++++++++++++++++++- 4 files changed, 332 insertions(+), 2 deletions(-) diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index a3cab97df..bf0d8b11e 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -35,6 +35,39 @@ You can also use slash commands directly: The **Installed** tab lists your installed plugins and shows an update badge when a newer version is available in the marketplace. The **Official** and **Third-party** tabs list marketplace plugins by tier; the **Custom** tab installs from a URL. Marketplace catalogs load automatically when needed. Each install shows a trust badge: `kimi-official` (from an official address), `curated` (from a curated address), or `third-party` (everything else). Installing a third-party plugin (anything not from the official address, including Custom installs) first shows a confirmation prompt that defaults to cancelling, so it is only installed if you choose to trust the source. +### Non-interactive management with `kimi plugins` + +For scripts, CI, or when you prefer the shell, use the `kimi plugins` subcommand. It covers the same operations as the TUI `/plugins` slash command but runs non-interactively and exits when finished. + +```sh +kimi plugins list +kimi plugins info +kimi plugins install +kimi plugins remove +kimi plugins enable +kimi plugins disable +kimi plugins marketplace +kimi plugins registry list +kimi plugins registry add --name +kimi plugins registry remove +``` + +Most list commands support `--json` for programmatic parsing, and `--yes` / `-y` skips confirmation prompts for third-party installs or removals. For example, install a plugin from a GitHub repository in a CI pipeline: + +```sh +kimi plugins install https://github.com/example/kimi-finance --yes +``` + +List installed plugins as JSON and extract their IDs: + +```sh +kimi plugins list --json | jq '.[].id' +``` + +Because `kimi plugins` starts a fresh process, plugin changes take effect the next time you run `kimi` or start a new session — there is no shell equivalent of the TUI `/reload` command. + +See the [`kimi plugins` reference](../reference/kimi-command.md#kimi-plugins) for the full command table. + ### Installing from GitHub Use `/plugins install ` to install directly from a GitHub repository. Four URL forms are supported: diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index b6688148f..fe510871d 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -120,7 +120,7 @@ In `stream-json` mode, regular replies produce an Assistant message; when the mo ## Subcommands -`kimi` provides the following subcommands: `login` (non-interactive login), `acp` (ACP IDE mode), `server` (run and manage the local REST/WebSocket/web service), `web` (alias for `kimi server run --open`), `doctor` (validate configuration files), `export` (export a session), `migrate` (migrate legacy data), `upgrade` (check for updates), and `provider` (manage providers). +`kimi` provides the following subcommands: `login` (non-interactive login), `acp` (ACP IDE mode), `server` (run and manage the local REST/WebSocket/web service), `web` (alias for `kimi server run --open`), `doctor` (validate configuration files), `export` (export a session), `migrate` (migrate legacy data), `upgrade` (check for updates), `vis` (launch the session visualizer), `provider` (manage providers), and `plugins` (manage plugins). ### `kimi login` @@ -392,6 +392,138 @@ kimi provider catalog list anthropic # Browse available models first kimi provider catalog add anthropic --api-key sk-ant-... --default-model claude-opus-4-7 ``` +### `kimi plugins` + +Manage plugins in the shell — the non-interactive equivalent of `/plugins` in the TUI. Suitable for installing a standard set of plugins on a new machine, scripting plugin state in CI, or managing custom registries without opening the terminal UI. + +```sh +kimi plugins [options] +``` + +Available actions: + +| Action | Description | +| --- | --- | +| `list` | List installed plugins | +| `info ` | Show details of an installed plugin | +| `install ` | Install a plugin from a local path, zip URL, or GitHub URL | +| `remove ` | Remove an installed plugin | +| `enable ` | Enable an installed plugin | +| `disable ` | Disable an installed plugin | +| `marketplace` | List plugins from the marketplace and custom registries | +| `registry list` | List custom registries | +| `registry add ` | Add a custom registry | +| `registry remove ` | Remove a custom registry by name or URL | + +#### `kimi plugins list` + +Print installed plugins in a tab-separated table. Add `--json` for structured output. + +| Option | Description | +| --- | --- | +| `--json` | Output as JSON | + +```sh +kimi plugins list +kimi plugins list --json | jq '.[] | {id, enabled}' +``` + +#### `kimi plugins info ` + +Show metadata and diagnostics for an installed plugin, including enabled state, skill count, MCP server count, hook count, and command count. + +| Option | Description | +| --- | --- | +| `--json` | Output as JSON | + +```sh +kimi plugins info kimi-finance +``` + +#### `kimi plugins install ` + +Install a plugin from a local directory, zip URL, or GitHub URL. The source supports the same URL forms as `/plugins install` in the TUI. Third-party sources require confirmation unless `--yes` is passed. + +| Option | Description | +| --- | --- | +| `-y, --yes` | Skip trust confirmation for third-party sources | + +```sh +kimi plugins install ./my-plugin +kimi plugins install https://github.com/example/kimi-finance +kimi plugins install https://github.com/example/kimi-finance --yes +``` + +#### `kimi plugins remove ` + +Remove an installed plugin. This deletes the installation record; the managed copy under `$KIMI_CODE_HOME/plugins/managed//` remains on disk. Add `--yes` to skip the confirmation prompt. + +| Option | Description | +| --- | --- | +| `-y, --yes` | Skip confirmation | + +```sh +kimi plugins remove my-plugin --yes +``` + +#### `kimi plugins enable ` and `kimi plugins disable ` + +Enable or disable an installed plugin. + +```sh +kimi plugins enable my-plugin +kimi plugins disable my-plugin +``` + +#### `kimi plugins marketplace` + +List plugins from the default marketplace and any custom registries registered with `kimi plugins registry add`. Custom registries are stored in `$KIMI_CODE_HOME/plugins/registries.json` and merged with the default catalog; duplicate IDs across registries are deduplicated with the default catalog winning. Add `--json` for structured output. + +| Option | Description | +| --- | --- | +| `--registry ` | Use a specific registry by name or direct URL/path | +| `--json` | Output as JSON | + +```sh +kimi plugins marketplace +kimi plugins marketplace --registry my-team +kimi plugins marketplace --registry https://registry.example.com/marketplace.json +``` + +#### `kimi plugins registry` + +Manage custom marketplace registries. Registries are persisted per-user and merged with the default marketplace when `kimi plugins marketplace` runs without `--registry`. + +##### `kimi plugins registry list` + +| Option | Description | +| --- | --- | +| `--json` | Output as JSON | + +```sh +kimi plugins registry list +``` + +##### `kimi plugins registry add ` + +Add a custom registry. `--name` assigns a short name you can use with `--registry` later. + +| Option | Description | +| --- | --- | +| `--name ` | Optional display name | + +```sh +kimi plugins registry add https://registry.example.com/marketplace.json --name my-team +``` + +##### `kimi plugins registry remove ` + +Remove a custom registry by its name or URL. + +```sh +kimi plugins registry remove my-team +``` + ## Next steps - [Slash Commands](./slash-commands.md) — Quick reference for control commands in the interactive TUI diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index f84749115..8e04ed440 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -35,6 +35,39 @@ Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元——可以 **Installed** tab 列出已安装的 plugin,并在 marketplace 有更新版本时显示更新徽章。**Official** 和 **Third-party** tab 按 tier 列出 marketplace plugin;**Custom** tab 从 URL 安装。marketplace 目录会在需要时自动加载。每个安装会显示信任徽章:`kimi-official`(来自官方地址)、`curated`(来自精选地址)、`third-party`(其他所有情况)。安装第三方 plugin(任何非官方地址的 plugin,包括 Custom 安装)会先显示一个默认「取消」的确认提示,只有在你选择信任该来源后才会继续安装。 +### 使用 `kimi plugins` 进行非交互式管理 + +如果你需要在脚本、CI 或 shell 中管理 plugin,可以使用 `kimi plugins` 子命令。它覆盖了与 TUI `/plugins` 斜杠命令相同的核心操作,但采用非交互方式运行,执行完毕后即退出。 + +```sh +kimi plugins list +kimi plugins info +kimi plugins install +kimi plugins remove +kimi plugins enable +kimi plugins disable +kimi plugins marketplace +kimi plugins registry list +kimi plugins registry add --name +kimi plugins registry remove +``` + +大多数列表命令支持 `--json`,便于程序化解析;`--yes` / `-y` 可以跳过第三方来源安装或移除时的确认提示。例如,在 CI 流水线中从 GitHub 仓库安装 plugin: + +```sh +kimi plugins install https://github.com/example/kimi-finance --yes +``` + +以 JSON 列出已安装 plugins 并提取 id: + +```sh +kimi plugins list --json | jq '.[].id' +``` + +由于 `kimi plugins` 会启动一个新进程,plugin 变更会在下次运行 `kimi` 或开启新会话时生效——TUI `/reload` 命令在 shell 中没有对应命令。 + +完整命令表请参考 [`kimi plugins` 参考](../reference/kimi-command.md#kimi-plugins)。 + ### 从 GitHub 安装 通过 `/plugins install ` 可以直接从 GitHub 仓库安装,支持四种 URL 形式: diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 0234f4bc5..c6b4790d3 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -120,7 +120,7 @@ kimi -p "List changed files" --output-format stream-json ## 子命令 -`kimi` 提供以下子命令:`login`(非交互式登录)、`acp`(ACP IDE 模式)、`server`(运行并管理本地 REST/WebSocket/web 服务)、`web`(`kimi server run --open` 的别名)、`doctor`(校验配置文件)、`export`(导出会话)、`migrate`(迁移旧版数据)、`upgrade`(检查更新)、`provider`(管理供应商)。 +`kimi` 提供以下子命令:`login`(非交互式登录)、`acp`(ACP IDE 模式)、`server`(运行并管理本地 REST/WebSocket/web 服务)、`web`(`kimi server run --open` 的别名)、`doctor`(校验配置文件)、`export`(导出会话)、`migrate`(迁移旧版数据)、`upgrade`(检查更新)、`vis`(启动会话可视化工具)、`provider`(管理供应商)、`plugins`(管理 plugins)。 ### `kimi login` @@ -392,6 +392,138 @@ kimi provider catalog list anthropic # 先看可选的模型 kimi provider catalog add anthropic --api-key sk-ant-... --default-model claude-opus-4-7 ``` +### `kimi plugins` + +在 shell 中管理 plugins——相当于 TUI 中 `/plugins` 的非交互版本。适合在新机器上安装一组标准 plugins、在 CI 中脚本化管理 plugin 状态,或无需打开终端 UI 即可管理自定义 registries。 + +```sh +kimi plugins [options] +``` + +可用动作: + +| 动作 | 说明 | +| --- | --- | +| `list` | 列出已安装 plugins | +| `info ` | 显示已安装 plugin 的详情 | +| `install ` | 从本地路径、zip URL 或 GitHub URL 安装 plugin | +| `remove ` | 移除已安装 plugin | +| `enable ` | 启用已安装 plugin | +| `disable ` | 禁用已安装 plugin | +| `marketplace` | 从 marketplace 和自定义 registries 列出 plugins | +| `registry list` | 列出自定义 registries | +| `registry add ` | 添加自定义 registry | +| `registry remove ` | 按名称或 URL 移除自定义 registry | + +#### `kimi plugins list` + +以制表符分隔的表格输出已安装 plugins。加 `--json` 输出结构化数据。 + +| 选项 | 说明 | +| --- | --- | +| `--json` | 以 JSON 形式输出 | + +```sh +kimi plugins list +kimi plugins list --json | jq '.[] | {id, enabled}' +``` + +#### `kimi plugins info ` + +显示已安装 plugin 的元数据和 diagnostics,包括启用状态、skill 数量、MCP server 数量、hook 数量和 command 数量。 + +| 选项 | 说明 | +| --- | --- | +| `--json` | 以 JSON 形式输出 | + +```sh +kimi plugins info kimi-finance +``` + +#### `kimi plugins install ` + +从本地目录、zip URL 或 GitHub URL 安装 plugin。`source` 支持与 TUI 中 `/plugins install` 相同的 URL 形式。第三方来源需要确认,除非传入 `--yes`。 + +| 选项 | 说明 | +| --- | --- | +| `-y, --yes` | 跳过第三方来源的信任确认 | + +```sh +kimi plugins install ./my-plugin +kimi plugins install https://github.com/example/kimi-finance +kimi plugins install https://github.com/example/kimi-finance --yes +``` + +#### `kimi plugins remove ` + +移除已安装 plugin。这会删除安装记录;`$KIMI_CODE_HOME/plugins/managed//` 下的托管副本仍会保留在磁盘上。加 `--yes` 跳过确认提示。 + +| 选项 | 说明 | +| --- | --- | +| `-y, --yes` | 跳过确认 | + +```sh +kimi plugins remove my-plugin --yes +``` + +#### `kimi plugins enable ` 与 `kimi plugins disable ` + +启用或禁用已安装 plugin。 + +```sh +kimi plugins enable my-plugin +kimi plugins disable my-plugin +``` + +#### `kimi plugins marketplace` + +从默认 marketplace 以及通过 `kimi plugins registry add` 注册的自定义 registries 列出 plugins。自定义 registries 保存在 `$KIMI_CODE_HOME/plugins/registries.json` 中,并与默认目录合并;多个 registry 之间重复的 id 会去重,默认目录优先。加 `--json` 输出结构化数据。 + +| 选项 | 说明 | +| --- | --- | +| `--registry ` | 通过名称或直接 URL/路径指定 registry | +| `--json` | 以 JSON 形式输出 | + +```sh +kimi plugins marketplace +kimi plugins marketplace --registry my-team +kimi plugins marketplace --registry https://registry.example.com/marketplace.json +``` + +#### `kimi plugins registry` + +管理自定义 marketplace registries。Registries 按用户持久化,当 `kimi plugins marketplace` 不带 `--registry` 运行时会与默认 marketplace 合并。 + +##### `kimi plugins registry list` + +| 选项 | 说明 | +| --- | --- | +| `--json` | 以 JSON 形式输出 | + +```sh +kimi plugins registry list +``` + +##### `kimi plugins registry add ` + +添加自定义 registry。`--name` 指定一个短名,之后可与 `--registry` 一起使用。 + +| 选项 | 说明 | +| --- | --- | +| `--name ` | 可选显示名称 | + +```sh +kimi plugins registry add https://registry.example.com/marketplace.json --name my-team +``` + +##### `kimi plugins registry remove ` + +按名称或 URL 移除自定义 registry。 + +```sh +kimi plugins registry remove my-team +``` + ## 下一步 - [斜杠命令](./slash-commands.md) — 交互式 TUI 内的控制命令速查 From b5783eeab36ab892eab21f6697497be546f1f79c Mon Sep 17 00:00:00 2001 From: Chen Yijun Date: Sun, 5 Jul 2026 22:45:27 +0800 Subject: [PATCH 15/17] fix(plugins): preserve source-checkout fallback and avoid force-exit on success --- apps/kimi-code/src/cli/sub/plugins.ts | 4 +++- apps/kimi-code/src/utils/plugin-marketplace.ts | 3 +-- apps/kimi-code/test/cli/sub/plugins.test.ts | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/plugins.ts b/apps/kimi-code/src/cli/sub/plugins.ts index 5ae6395ed..f1d8804dc 100644 --- a/apps/kimi-code/src/cli/sub/plugins.ts +++ b/apps/kimi-code/src/cli/sub/plugins.ts @@ -271,7 +271,9 @@ export function registerPluginsCommand(parent: Command, deps?: Partial { const registries = await readRegistries(options.kimiHomeDir); const configuredSource = process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV]; - const defaultSource = configuredSource ?? KIMI_CODE_PLUGIN_MARKETPLACE_URL; const defaultMarketplace = await loadPluginMarketplace({ workDir: options.workDir, - source: defaultSource, + source: configuredSource, fetchImpl: options.fetchImpl, }); diff --git a/apps/kimi-code/test/cli/sub/plugins.test.ts b/apps/kimi-code/test/cli/sub/plugins.test.ts index a6cc16884..97506a73e 100644 --- a/apps/kimi-code/test/cli/sub/plugins.test.ts +++ b/apps/kimi-code/test/cli/sub/plugins.test.ts @@ -373,7 +373,7 @@ describe('registerPluginsCommand', () => { expect(getWritten(stderr)).toContain('Failed to list plugins: boom'); }); - it('exits 0 after cleanup on success', async () => { + it('cleans up and returns normally on success', async () => { const harness = makeHarness(); const exit = vi.fn((code: number) => { throw new Error(`exit:${code}`); @@ -386,8 +386,9 @@ describe('registerPluginsCommand', () => { getHomeDir: () => '/tmp/kimi-home', }); - await expect(parent.parseAsync(['node', 'test', 'plugins', 'list'])).rejects.toThrow('exit:0'); + await expect(parent.parseAsync(['node', 'test', 'plugins', 'list'])).resolves.not.toThrow(); + expect(exit).not.toHaveBeenCalled(); expect(mocks.shutdownTelemetry).toHaveBeenCalled(); }); }); From 19cbe1d99ac0951cd88396059a24c3afdbbb1c0d Mon Sep 17 00:00:00 2001 From: Chen Yijun Date: Sun, 5 Jul 2026 23:51:51 +0800 Subject: [PATCH 16/17] fix(plugins): reject duplicate registry names --- apps/kimi-code/src/utils/plugin-registries.ts | 5 +++++ apps/kimi-code/test/utils/plugin-registries.test.ts | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/apps/kimi-code/src/utils/plugin-registries.ts b/apps/kimi-code/src/utils/plugin-registries.ts index 37ed68276..3cd794721 100644 --- a/apps/kimi-code/src/utils/plugin-registries.ts +++ b/apps/kimi-code/src/utils/plugin-registries.ts @@ -52,6 +52,11 @@ export async function addRegistry( throw new Error(`Registry URL is already registered: ${trimmedUrl}`); } const trimmedName = registry.name?.trim(); + if (trimmedName !== undefined && trimmedName.length > 0) { + if (file.registries.some((r) => r.name === trimmedName)) { + throw new Error(`Registry name is already registered: ${trimmedName}`); + } + } const next: RegistriesFile = { ...file, registries: [ diff --git a/apps/kimi-code/test/utils/plugin-registries.test.ts b/apps/kimi-code/test/utils/plugin-registries.test.ts index 0fa54f960..58622404b 100644 --- a/apps/kimi-code/test/utils/plugin-registries.test.ts +++ b/apps/kimi-code/test/utils/plugin-registries.test.ts @@ -41,6 +41,14 @@ describe('plugin-registries', () => { ); }); + it('rejects duplicate names', async () => { + const homeDir = await makeTempDir(); + await addRegistry(homeDir, { url: 'https://a.com/m.json', name: 'team' }); + await expect( + addRegistry(homeDir, { url: 'https://b.com/m.json', name: 'team' }), + ).rejects.toThrow(/already registered/); + }); + it('removes by name then by URL fallback', async () => { const homeDir = await makeTempDir(); await addRegistry(homeDir, { url: 'https://a.com/m.json', name: 'a' }); From 15f67614a9429baac1dc51346855a23e54292ec6 Mon Sep 17 00:00:00 2001 From: Chen Yijun Date: Mon, 6 Jul 2026 00:15:15 +0800 Subject: [PATCH 17/17] fix(plugins): accept relative registry paths and use neutral test fixtures --- apps/kimi-code/src/utils/plugin-registries.ts | 2 ++ apps/kimi-code/test/cli/sub/plugins.test.ts | 12 ++++++------ apps/kimi-code/test/utils/plugin-registries.test.ts | 2 ++ 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/kimi-code/src/utils/plugin-registries.ts b/apps/kimi-code/src/utils/plugin-registries.ts index 3cd794721..736d6062d 100644 --- a/apps/kimi-code/src/utils/plugin-registries.ts +++ b/apps/kimi-code/src/utils/plugin-registries.ts @@ -121,6 +121,8 @@ function isUrlLike(value: string): boolean { value.startsWith('http://') || value.startsWith('https://') || value.startsWith('file://') || + value.startsWith('./') || + value.startsWith('../') || value === '~' || value.startsWith('~/') || isAbsolute(value) diff --git a/apps/kimi-code/test/cli/sub/plugins.test.ts b/apps/kimi-code/test/cli/sub/plugins.test.ts index 97506a73e..982253db7 100644 --- a/apps/kimi-code/test/cli/sub/plugins.test.ts +++ b/apps/kimi-code/test/cli/sub/plugins.test.ts @@ -47,7 +47,7 @@ function makeDeps(overrides: Record = {}) { throw new Error(`exit:${code}`); }) as (code: number) => never, getHarness: vi.fn(), - getHomeDir: () => '/home/cyijun/.kimi-code', + getHomeDir: () => '/home/example/.kimi-code', ...overrides, }; } @@ -249,7 +249,7 @@ describe('handlePluginsMarketplace', () => { await handlePluginsMarketplace(deps as never, { json: true }); expect(loadMergedMarketplace).toHaveBeenCalledWith({ - kimiHomeDir: '/home/cyijun/.kimi-code', + kimiHomeDir: '/home/example/.kimi-code', workDir: '/tmp/work', }); expect(getWritten(deps.stdout)).toContain('"id": "market-demo"'); @@ -262,7 +262,7 @@ describe('handlePluginsMarketplace', () => { await handlePluginsMarketplace(deps as never, { registry: 'custom', json: true }); - expect(resolveRegistryUrl).toHaveBeenCalledWith('/home/cyijun/.kimi-code', 'custom'); + expect(resolveRegistryUrl).toHaveBeenCalledWith('/home/example/.kimi-code', 'custom'); expect(loadPluginMarketplace).toHaveBeenCalledWith({ workDir: '/tmp/work', source: 'https://custom.example.com/m.json', @@ -280,7 +280,7 @@ describe('handlePluginsRegistryList', () => { await handlePluginsRegistryList(deps as never, { json: true }); - expect(readRegistries).toHaveBeenCalledWith('/home/cyijun/.kimi-code'); + expect(readRegistries).toHaveBeenCalledWith('/home/example/.kimi-code'); expect(getWritten(deps.stdout)).toContain('https://custom.example.com/m.json'); }); @@ -301,7 +301,7 @@ describe('handlePluginsRegistryAdd', () => { await handlePluginsRegistryAdd(deps as never, { url: 'https://example.com/m.json', name: 'example' }); - expect(addRegistry).toHaveBeenCalledWith('/home/cyijun/.kimi-code', { + expect(addRegistry).toHaveBeenCalledWith('/home/example/.kimi-code', { url: 'https://example.com/m.json', name: 'example', }); @@ -315,7 +315,7 @@ describe('handlePluginsRegistryRemove', () => { await handlePluginsRegistryRemove(deps as never, { nameOrUrl: 'custom' }); - expect(removeRegistry).toHaveBeenCalledWith('/home/cyijun/.kimi-code', 'custom'); + expect(removeRegistry).toHaveBeenCalledWith('/home/example/.kimi-code', 'custom'); expect(getWritten(deps.stdout)).toContain('Removed registry custom.'); }); }); diff --git a/apps/kimi-code/test/utils/plugin-registries.test.ts b/apps/kimi-code/test/utils/plugin-registries.test.ts index 58622404b..08000e6dd 100644 --- a/apps/kimi-code/test/utils/plugin-registries.test.ts +++ b/apps/kimi-code/test/utils/plugin-registries.test.ts @@ -71,6 +71,8 @@ describe('plugin-registries', () => { expect(await resolveRegistryUrl(homeDir, 'https://direct.com/m.json')).toBe( 'https://direct.com/m.json', ); + expect(await resolveRegistryUrl(homeDir, './marketplace.json')).toBe('./marketplace.json'); + expect(await resolveRegistryUrl(homeDir, '../marketplace.json')).toBe('../marketplace.json'); await expect(resolveRegistryUrl(homeDir, 'missing')).rejects.toThrow(/not found/); }); });