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. 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`. 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') 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..f1d8804dc --- /dev/null +++ b/apps/kimi-code/src/cli/sub/plugins.ts @@ -0,0 +1,503 @@ +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, + shutdownTelemetry, + track, + withTelemetryContext, +} from '@moonshot-ai/kimi-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, + 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; +} + +class PluginsCommandError extends Error {} + +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) { + throw new PluginsCommandError(`Failed to list plugins: ${errorMessage(error)}`); + } +} + +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) { + throw new PluginsCommandError(`Failed to get plugin info: ${errorMessage(error)}`); + } +} + +export async function handlePluginsInstall( + deps: PluginsDeps, + options: { source: string } & InstallOptions, +): Promise { + 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] `, + ); + 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) { + throw new PluginsCommandError(`Failed to install plugin: ${errorMessage(error)}`); + } +} + +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) { + throw new PluginsCommandError(`Failed to remove plugin: ${errorMessage(error)}`); + } +} + +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) { + throw new PluginsCommandError(`Failed to ${options.enabled ? 'enable' : 'disable'} plugin: ${errorMessage(error)}`); + } +} + +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) { + throw new PluginsCommandError(`Failed to load marketplace: ${errorMessage(error)}`); + } +} + +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) { + throw new PluginsCommandError(`Failed to list registries: ${errorMessage(error)}`); + } +} + +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) { + throw new PluginsCommandError(`Failed to add registry: ${errorMessage(error)}`); + } +} + +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) { + 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(); + } + if (failed) { + d.exit(1); + } + }; + + 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 runWithLifecycle((d) => handlePluginsList(d, options)); + }); + + program + .command('info ') + .description('Show details of an installed plugin.') + .option('--json', 'Output as JSON.') + .action(async (id: string, options: { json?: boolean }) => { + await runWithLifecycle((d) => handlePluginsInfo(d, 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 runWithLifecycle((d) => handlePluginsInstall(d, { 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 runWithLifecycle((d) => handlePluginsRemove(d, { id, yes: options.yes })); + }); + + program + .command('enable ') + .description('Enable an installed plugin.') + .action(async (id: string) => { + await runWithLifecycle((d) => handlePluginsEnable(d, { id, enabled: true })); + }); + + program + .command('disable ') + .description('Disable an installed plugin.') + .action(async (id: string) => { + await runWithLifecycle((d) => handlePluginsEnable(d, { 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 runWithLifecycle((d) => handlePluginsMarketplace(d, 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 runWithLifecycle((d) => handlePluginsRegistryList(d, options)); + }); + + registry + .command('add ') + .description('Add a custom registry.') + .option('--name ', 'Optional display name.') + .action(async (url: string, options: { name?: string }) => { + 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 runWithLifecycle((d) => handlePluginsRegistryRemove(d, { nameOrUrl })); + }); +} + +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, + 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 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, + 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, + initializeDefaultTelemetry, + shutdownDefaultTelemetry, + closeDefaultHarness, + }; +} + +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/src/utils/plugin-marketplace.ts b/apps/kimi-code/src/utils/plugin-marketplace.ts index be55e798e..44b319e1c 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,52 @@ 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 defaultMarketplace = await loadPluginMarketplace({ + workDir: options.workDir, + source: configuredSource, + 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/src/utils/plugin-registries.ts b/apps/kimi-code/src/utils/plugin-registries.ts new file mode 100644 index 000000000..736d6062d --- /dev/null +++ b/apps/kimi-code/src/utils/plugin-registries.ts @@ -0,0 +1,130 @@ +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'); + +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 { version: 1, registries: [] }; + 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(); + 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: [ + ...file.registries, + { + url: trimmedUrl, + name: trimmedName || undefined, + }, + ], + }; + 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.startsWith('./') || + value.startsWith('../') || + value === '~' || + value.startsWith('~/') || + isAbsolute(value) + ); +} 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', ]); }); 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..982253db7 --- /dev/null +++ b/apps/kimi-code/test/cli/sub/plugins.test.ts @@ -0,0 +1,394 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Command } from 'commander'; + +import { + handlePluginsEnable, + handlePluginsInfo, + handlePluginsInstall, + handlePluginsList, + handlePluginsMarketplace, + handlePluginsRegistryAdd, + 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', + 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/example/.kimi-code', + ...overrides, + }; +} + +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 = makePluginSummary(); + const deps = makeDeps({ + getHarness: vi.fn(() => ({ + listPlugins: vi.fn(async () => [summary]), + })), + }); + + await handlePluginsList(deps as never, { json: true }); + + 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 = makePluginSummary(); + 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')); + }); + + 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/example/.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/example/.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/example/.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', () => { + 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/example/.kimi-code', { + url: 'https://example.com/m.json', + name: 'example', + }); + }); +}); + +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/example/.kimi-code', 'custom'); + expect(getWritten(deps.stdout)).toContain('Removed registry custom.'); + }); +}); + +describe('handler errors', () => { + it('throws 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( + '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('cleans up and returns normally 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'])).resolves.not.toThrow(); + + expect(exit).not.toHaveBeenCalled(); + expect(mocks.shutdownTelemetry).toHaveBeenCalled(); + }); +}); 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; + } + } + }); +}); 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..08000e6dd --- /dev/null +++ b/apps/kimi-code/test/utils/plugin-registries.test.ts @@ -0,0 +1,78 @@ +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('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' }); + 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', + ); + 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/); + }); +}); 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 内的控制命令速查 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..431b7c9f3 --- /dev/null +++ b/packages/node-sdk/test/kimi-harness-plugins.test.ts @@ -0,0 +1,67 @@ +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 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(); + }); +});