import { cp, mkdir, mkdtemp, realpath, rename, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import type { HookDef } from '#/features/externalHooks/internal/types'; import { discoverFileSkills } from '#/features/skill/catalog/fileSkillDiscovery'; import type { SkillDiscoveryResult } from '#/features/skill/catalog/skillDiscovery'; import type { SkillRoot } from '#/features/skill/catalog/types'; import { BugIndicatingError, Error2, ErrorCodes, PluginErrors } from '#/errors'; import type { McpServerConfig } from '#/mcpCore/config-schema'; import { downloadZip, extractZip } from './archive'; import { loadPluginCommand } from './commands'; import { resolveGithubCommitSha, resolveGithubSource } from './github-resolver'; import { parseManifest, type ParsedManifestResult } from './manifest'; import { resolveInstallSource } from './source'; import { readInstalled, writeInstalled, type InstalledRecord } from './store'; import type { PluginAgentRoot } from './types'; import { normalizePluginId, type EnabledPluginSessionStart, type EnabledPluginSystemPrompt, type PluginCapabilityState, type PluginCommandDef, type PluginGithubMetadata, type PluginInfo, type PluginMcpServerEntry, type PluginMcpServerInfo, type PluginRecord, type PluginSource, type PluginSummary, type PluginUpdateStatus, type ReloadSummary, } from './types'; export interface PluginManagerOptions { readonly kimiHomeDir: string; readonly discoverSkills?: (roots: readonly SkillRoot[]) => Promise; } interface ManagedPluginCopy { readonly root: string; readonly previousRoot?: string; } export class PluginManager { private readonly kimiHomeDir: string; private readonly discoverSkills: (roots: readonly SkillRoot[]) => Promise; private records = new Map(); constructor(options: PluginManagerOptions) { this.kimiHomeDir = options.kimiHomeDir; this.discoverSkills = options.discoverSkills ?? discoverFileSkills; } async load(): Promise { const file = await readInstalled(this.kimiHomeDir); const next = new Map(); for (const entry of file.plugins) { next.set(entry.id, await this.materialize(entry)); } this.records = next; } list(): readonly PluginRecord[] { return [...this.records.values()].toSorted((a, b) => a.id.localeCompare(b.id)); } get(id: string): PluginRecord | undefined { return this.records.get(normalizePluginId(id)); } async install(source: string): Promise { const resolved = resolveInstallSource(source); let sourceRoot: string; let originalSource: string; let sourceType: PluginSource; let zipTmpDir: string | undefined; let managedCopy: ManagedPluginCopy | undefined; let github: PluginGithubMetadata | undefined; try { if (resolved.kind === 'local-path') { sourceRoot = await normalizeInstallRoot(resolved.path); originalSource = resolved.path; sourceType = 'local-path'; } else { originalSource = source.trim(); sourceType = resolved.kind === 'github' ? 'github' : 'zip-url'; const zipUrl = resolved.kind === 'github' ? await (async () => { const resolution = await resolveGithubSource(resolved); const installedSha = await installedGithubSha( resolved.owner, resolved.repo, resolution.ref, ); github = { owner: resolved.owner, repo: resolved.repo, ref: resolution.ref, installedSha, }; if (installedSha !== undefined) { return `https://codeload.github.com/${resolved.owner}/${resolved.repo}/zip/${installedSha}`; } return resolution.tarballUrl; })() : resolved.path; const buffer = await downloadZip(zipUrl); zipTmpDir = await mkdtemp(path.join(tmpdir(), 'kimi-plugin-zip-')); sourceRoot = await extractZip(buffer, zipTmpDir); } const parsed = await parseManifest(sourceRoot); if (parsed.manifest === undefined) { const msg = parsed.diagnostics.find((d) => d.severity === 'error')?.message ?? 'no manifest'; throw new Error2( ErrorCodes.PLUGIN_LOAD_FAILED, sourceType === 'local-path' ? `Cannot install plugin at ${sourceRoot}: ${msg}` : `Cannot install plugin from ${originalSource}: ${msg}`, { details: { sourceType } }, ); } const id = normalizePluginId(parsed.manifest.name); managedCopy = await copyPluginToManagedRoot(this.kimiHomeDir, id, sourceRoot); const normalizedRoot = managedCopy.root; const managedParsed = await parseManifest(normalizedRoot); const existing = this.records.get(id); const now = new Date().toISOString(); const record = await recordFrom({ id, root: normalizedRoot, enabled: existing?.enabled ?? true, installedAt: existing?.installedAt ?? now, updatedAt: now, originalSource, source: sourceType, capabilities: existing?.capabilities, github, parsed: managedParsed, discoverSkills: this.discoverSkills, }); const next = new Map(this.records); next.set(id, record); await this.persist(next); this.records = next; if (managedCopy.previousRoot !== undefined) { await rm(managedCopy.previousRoot, { recursive: true, force: true }).catch(() => undefined); } managedCopy = undefined; return record; } catch (error) { if (managedCopy !== undefined) { try { await rollbackManagedPluginCopy(managedCopy); } catch (rollbackError) { throw new Error2( ErrorCodes.PLUGIN_LOAD_FAILED, 'Plugin installation failed and the previous managed copy could not be restored', { cause: new AggregateError( [error, rollbackError], 'Plugin installation failed and the previous managed copy could not be restored', { cause: error }, ), }, ); } } throw error; } finally { if (zipTmpDir !== undefined) { await rm(zipTmpDir, { recursive: true, force: true }); } } } async setEnabled(id: string, enabled: boolean): Promise { const key = normalizePluginId(id); const current = this.records.get(key); if (current === undefined) throw pluginNotFound(id); if (current.enabled === enabled) return; const next = new Map(this.records); next.set(key, { ...current, enabled, updatedAt: new Date().toISOString() }); await this.persist(next); this.records = next; } async setMcpServerEnabled(id: string, server: string, enabled: boolean): Promise { const key = normalizePluginId(id); const current = this.records.get(key); if (current === undefined) throw pluginNotFound(id); if (current.manifest?.mcpServers?.[server] === undefined) { throw new Error2( ErrorCodes.MCP_SERVER_NOT_FOUND, `Plugin "${id}" does not declare MCP server "${server}"`, { details: { id, server } }, ); } const currentMcpServers = current.capabilities?.mcpServers ?? {}; const nextCapabilities: PluginCapabilityState = { ...current.capabilities, mcpServers: { ...currentMcpServers, [server]: { enabled }, }, }; const next = new Map(this.records); next.set(key, { ...current, capabilities: nextCapabilities, updatedAt: new Date().toISOString(), }); await this.persist(next); this.records = next; } async remove(id: string): Promise { const key = normalizePluginId(id); const next = new Map(this.records); if (!next.delete(key)) { throw pluginNotFound(id); } await this.persist(next); this.records = next; } async checkUpdates(): Promise { const records = [...this.records.values()].filter( (record) => record.source === 'github' && record.github !== undefined, ); const results = await Promise.all( records.map(async (record) => { try { return await checkGithubUpdate(record); } catch { return undefined; } }), ); return results .filter((result): result is PluginUpdateStatus => result !== undefined) .toSorted((a, b) => a.id.localeCompare(b.id)); } async reload(): Promise { const prevIds = new Set(this.records.keys()); const file = await readInstalled(this.kimiHomeDir); const next = new Map(); const errors: Array<{ id: string; message: string }> = []; for (const entry of file.plugins) { try { next.set(entry.id, await this.materialize(entry)); } catch (error) { errors.push({ id: entry.id, message: (error as Error).message }); } } const added: string[] = []; for (const id of next.keys()) if (!prevIds.has(id)) added.push(id); const removed: string[] = []; for (const id of prevIds) if (!next.has(id)) removed.push(id); this.records = next; return { added, removed, errors }; } enabledHooks(): readonly HookDef[] { const out: HookDef[] = []; for (const record of this.records.values()) { if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue; for (const hook of record.manifest.hooks ?? []) { out.push({ ...hook, cwd: record.root, env: { KIMI_CODE_HOME: this.kimiHomeDir, KIMI_PLUGIN_ROOT: record.root, }, }); } } return out; } async enabledCommands(): Promise { const out: PluginCommandDef[] = []; const records = [...this.records.values()]; for (const record of records) { if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue; for (const entry of record.manifest.commands ?? []) { const def = await loadPluginCommand({ commandPath: entry.path, pluginId: record.id, fallbackName: entry.name, }); if (def !== undefined) out.push(def); } } return out; } pluginSkillRoots(): readonly SkillRoot[] { const roots: SkillRoot[] = []; for (const record of this.records.values()) { if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue; for (const dir of record.manifest.skills ?? []) { roots.push({ path: dir, source: 'extra', plugin: { id: record.id, instructions: record.skillInstructions }, scanMode: record.manifest.rootSkillFallback ? 'root-skill-only' : undefined, }); } } return roots; } pluginAgentRoots(): readonly PluginAgentRoot[] { const roots: PluginAgentRoot[] = []; for (const record of this.records.values()) { if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue; for (const dir of record.manifest.agents ?? []) { roots.push({ path: dir, source: 'plugin' }); } } return roots; } enabledSessionStarts(): readonly EnabledPluginSessionStart[] { const out: EnabledPluginSessionStart[] = []; for (const record of this.records.values()) { if (!record.enabled || record.state !== 'ok') continue; const skill = record.manifest?.sessionStart?.skill; if (skill === undefined) continue; out.push({ pluginId: record.id, skillName: skill }); } return out; } enabledSystemPrompts(): readonly EnabledPluginSystemPrompt[] { const out: EnabledPluginSystemPrompt[] = []; for (const record of this.records.values()) { if (!record.enabled || record.state !== 'ok') continue; const content = record.manifest?.systemPrompt; if (content === undefined) continue; out.push({ pluginId: record.id, content }); } return out; } enabledMcpServers(): Record { const out: Record = {}; for (const record of this.records.values()) { if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue; for (const [name, config] of Object.entries(record.manifest.mcpServers ?? {})) { if (!isMcpServerEnabled(record, name, config)) continue; out[pluginMcpRuntimeName(record.id, name)] = withPluginMcpRuntime( withMcpServerEnabled(config, true), record.root, this.kimiHomeDir, ); } } return out; } mcpServerEntries(): readonly PluginMcpServerEntry[] { const out: PluginMcpServerEntry[] = []; for (const record of this.records.values()) { if (record.state !== 'ok' || record.manifest === undefined) continue; for (const [name, config] of Object.entries(record.manifest.mcpServers ?? {})) { const enabled = record.enabled && isMcpServerEnabled(record, name, config); const effective = withPluginMcpRuntime( withMcpServerEnabled(config, enabled), record.root, this.kimiHomeDir, ); out.push({ name: pluginMcpRuntimeName(record.id, name), config: effective, pluginId: record.id, serverName: name, }); } } return out; } summaries(): readonly PluginSummary[] { return this.list().map((record) => recordToSummary(record)); } info(id: string): PluginInfo | undefined { const record = this.get(id); return record === undefined ? undefined : recordToInfo(record); } private async persist(records: ReadonlyMap): Promise { const installed: InstalledRecord[] = [...records.values()].map((record) => ({ id: record.id, root: record.root, source: record.source, enabled: record.enabled, installedAt: record.installedAt, updatedAt: record.updatedAt, originalSource: record.originalSource, capabilities: record.capabilities, github: record.github, })); await writeInstalled(this.kimiHomeDir, { version: 1, plugins: installed }); } private async materialize(entry: InstalledRecord): Promise { const parsed = await parseManifest(entry.root); return recordFrom({ id: entry.id, root: entry.root, enabled: entry.enabled, installedAt: entry.installedAt, updatedAt: entry.updatedAt, originalSource: entry.originalSource, capabilities: entry.capabilities, github: entry.github, source: entry.source, parsed, discoverSkills: this.discoverSkills, }); } } async function installedGithubSha( owner: string, repo: string, ref: PluginGithubMetadata['ref'], ): Promise { if (ref.kind === 'sha' && ref.value.length === 40) return ref.value.toLowerCase(); return resolveGithubCommitSha(owner, repo, ref.value); } async function checkGithubUpdate(record: PluginRecord): Promise { const github = record.github; if (github === undefined) throw new BugIndicatingError(`Plugin "${record.id}" has no GitHub metadata`); const current = github.ref; const pinned = explicitGithubRef(record); if (pinned?.kind === 'tag' || pinned?.kind === 'sha') { return { id: record.id, source: 'github', current, latest: current, displayVersion: current.value, updateAvailable: false, }; } if (pinned?.kind === 'branch') { const latestSha = await resolveGithubCommitSha(github.owner, github.repo, pinned.value); return { id: record.id, source: 'github', current, latest: current, displayVersion: latestSha.slice(0, 12), updateAvailable: github.installedSha === undefined || github.installedSha !== latestSha, }; } const latest = await resolveGithubSource({ kind: 'github', owner: github.owner, repo: github.repo, }); let updateAvailable = current.kind !== latest.ref.kind || current.value !== latest.ref.value; if (!updateAvailable && (latest.ref.kind === 'branch' || latest.ref.kind === 'tag')) { const latestSha = await resolveGithubCommitSha(github.owner, github.repo, latest.ref.value); updateAvailable = github.installedSha === undefined || github.installedSha !== latestSha; } return { id: record.id, source: 'github', current, latest: latest.ref, displayVersion: latest.displayVersion, updateAvailable, }; } function explicitGithubRef(record: PluginRecord): PluginGithubMetadata['ref'] | undefined { const fallback = record.github?.ref.kind === 'sha' || (record.github?.ref.kind === 'branch' && record.github.ref.value !== 'HEAD') ? record.github.ref : undefined; if (record.originalSource === undefined) return fallback; try { const source = resolveInstallSource(record.originalSource); return source.kind === 'github' ? source.ref : fallback; } catch { return fallback; } } function pluginNotFound(id: string): Error2 { return new Error2(PluginErrors.codes.PLUGIN_NOT_FOUND, `Plugin "${id}" is not installed`, { details: { id }, }); } async function normalizeInstallRoot(rootPath: string): Promise { const trimmed = rootPath.trim(); if (!path.isAbsolute(trimmed)) { throw new Error2( ErrorCodes.VALIDATION_FAILED, `Plugin root must be an absolute path (got "${rootPath}")`, { details: { path: rootPath } }, ); } let resolved: string; try { resolved = await realpath(trimmed); } catch (error) { throw new Error2(ErrorCodes.FS_PATH_NOT_FOUND, `Plugin root does not exist: ${trimmed}`, { cause: error, details: { path: trimmed }, }); } if (!(await stat(resolved)).isDirectory()) { throw new Error2(ErrorCodes.VALIDATION_FAILED, `Plugin root is not a directory: ${trimmed}`, { details: { path: trimmed }, }); } return resolved; } async function copyPluginToManagedRoot( kimiHomeDir: string, id: string, sourceRoot: string, ): Promise { const managedRoot = path.join(kimiHomeDir, 'plugins', 'managed', id); const managedDir = path.dirname(managedRoot); await mkdir(managedDir, { recursive: true }); const stagingRoot = await mkdtemp(path.join(managedDir, `${id}-`)); const previousRoot = `${stagingRoot}-previous`; let movedPreviousRoot = false; let published = false; try { await cp(sourceRoot, stagingRoot, { recursive: true }); try { await rename(managedRoot, previousRoot); movedPreviousRoot = true; } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } await rename(stagingRoot, managedRoot); published = true; return { root: await realpath(managedRoot), previousRoot: movedPreviousRoot ? previousRoot : undefined, }; } catch (error) { await rm(published ? managedRoot : stagingRoot, { recursive: true, force: true }); if (movedPreviousRoot) await rename(previousRoot, managedRoot); throw error; } } async function rollbackManagedPluginCopy(copy: ManagedPluginCopy): Promise { await rm(copy.root, { recursive: true, force: true }); if (copy.previousRoot !== undefined) { await rename(copy.previousRoot, copy.root); } } async function recordFrom(input: { id: string; root: string; enabled: boolean; installedAt: string; updatedAt?: string; originalSource?: string; capabilities?: PluginCapabilityState; github?: PluginGithubMetadata; source?: PluginSource; parsed: ParsedManifestResult; discoverSkills: (roots: readonly SkillRoot[]) => Promise; }): Promise { const { parsed } = input; const hasError = parsed.diagnostics.some((d) => d.severity === 'error'); return { id: input.id, root: input.root, source: input.source ?? 'local-path', enabled: input.enabled, state: hasError || parsed.manifest === undefined ? 'error' : 'ok', installedAt: input.installedAt, updatedAt: input.updatedAt, originalSource: input.originalSource, capabilities: input.capabilities, github: input.github, skillCount: await countDiscoveredPluginSkills(input.id, parsed.manifest, input.discoverSkills), manifest: parsed.manifest, manifestKind: parsed.manifestKind, manifestPath: parsed.manifestPath, shadowedManifestPath: parsed.shadowedManifestPath, diagnostics: parsed.diagnostics, skillInstructions: parsed.manifest?.skillInstructions, }; } function recordToSummary(record: PluginRecord): PluginSummary { return { id: record.id, displayName: record.manifest?.interface?.displayName ?? record.id, version: record.manifest?.version, enabled: record.enabled, state: record.state, skillCount: record.skillCount, mcpServerCount: Object.keys(record.manifest?.mcpServers ?? {}).length, enabledMcpServerCount: pluginMcpServersInfo(record).filter((server) => server.enabled).length, hookCount: record.manifest?.hooks?.length ?? 0, commandCount: record.manifest?.commands?.length ?? 0, hasErrors: record.diagnostics.some((d) => d.severity === 'error'), source: record.source, originalSource: record.originalSource, github: record.github, }; } function recordToInfo(record: PluginRecord): PluginInfo { return { ...recordToSummary(record), root: record.root, installedAt: record.installedAt, updatedAt: record.updatedAt, manifestKind: record.manifestKind, manifestPath: record.manifestPath, manifest: record.manifest, mcpServers: pluginMcpServersInfo(record), shadowedManifestPath: record.shadowedManifestPath, diagnostics: record.diagnostics, }; } function isMcpServerEnabled(record: PluginRecord, name: string, config: McpServerConfig): boolean { return record.capabilities?.mcpServers?.[name]?.enabled ?? config.enabled !== false; } function pluginMcpServersInfo(record: PluginRecord): readonly PluginMcpServerInfo[] { return Object.entries(record.manifest?.mcpServers ?? {}) .map(([name, config]) => pluginMcpServerInfo(record, name, config)) .toSorted((a, b) => a.name.localeCompare(b.name)); } function pluginMcpServerInfo( record: PluginRecord, name: string, config: McpServerConfig, ): PluginMcpServerInfo { if (config.transport === 'http' || config.transport === 'sse') { return { name, runtimeName: pluginMcpRuntimeName(record.id, name), enabled: isMcpServerEnabled(record, name, config), transport: config.transport, url: config.url, headerKeys: config.headers === undefined ? undefined : Object.keys(config.headers).toSorted(), }; } return { name, runtimeName: pluginMcpRuntimeName(record.id, name), enabled: isMcpServerEnabled(record, name, config), transport: 'stdio', command: config.command, args: config.args, cwd: config.cwd, envKeys: config.env === undefined ? undefined : Object.keys(config.env).toSorted(), }; } function pluginMcpRuntimeName(pluginId: string, serverName: string): string { return `plugin-${pluginId}:${serverName}`; } const KIMI_NODE_FALLBACK_SUBCOMMAND = '__plugin_run_node'; function withMcpServerEnabled(config: McpServerConfig, enabled: boolean): McpServerConfig { return { ...config, enabled }; } function withPluginMcpRuntime( config: McpServerConfig, pluginRoot: string, kimiHomeDir: string, ): McpServerConfig { if (config.transport === 'http' || config.transport === 'sse') return config; const env = { ...config.env, KIMI_CODE_HOME: kimiHomeDir, KIMI_PLUGIN_ROOT: pluginRoot, }; if (config.command === 'node' && isElectron()) { return { ...config, command: process.execPath, args: config.args ?? [], cwd: config.cwd ?? pluginRoot, env: { ...env, ELECTRON_RUN_AS_NODE: '1' }, }; } if (config.command === 'node' && isKimiNativeBinary()) { return { ...config, command: process.execPath, args: [KIMI_NODE_FALLBACK_SUBCOMMAND, ...(config.args ?? [])], cwd: config.cwd ?? pluginRoot, env, }; } return { ...config, cwd: config.cwd ?? pluginRoot, env }; } function isElectron(): boolean { return typeof process.versions['electron'] === 'string'; } function isKimiNativeBinary(): boolean { return !path.basename(process.execPath).toLowerCase().startsWith('node'); } async function countDiscoveredPluginSkills( pluginId: string, manifest: PluginRecord['manifest'], discoverSkills: (roots: readonly SkillRoot[]) => Promise, ): Promise { const dirs = manifest?.skills ?? []; if (dirs.length === 0) return 0; const roots: SkillRoot[] = dirs.map((dir) => ({ path: dir, source: 'extra', plugin: { id: pluginId, instructions: manifest?.skillInstructions }, scanMode: manifest?.rootSkillFallback ? 'root-skill-only' : undefined, })); const result = await discoverSkills(roots); return result.skills.length; }