import { readdir, readFile, realpath, stat } from 'node:fs/promises'; import path from 'node:path'; import { HookDefSchema, type HookDefConfig } from '#/features/externalHooks/configSection'; import { McpServerConfigSchema, type McpServerConfig } from '#/mcpCore/config-schema'; import { PLUGIN_NAME_REGEX, type PluginCommandEntry, type PluginDiagnostic, type PluginInterface, type PluginManifest, type PluginManifestKind, } from './types'; const KIMI_PLUGIN_ROOT_PATH = 'kimi.plugin.json'; const KIMI_PLUGIN_DIR_PATH = '.kimi-plugin/plugin.json'; export const PLUGIN_SYSTEM_PROMPT_MAX_BYTES = 32 * 1024; const UNSUPPORTED_RUNTIME_FIELDS = [ 'tools', 'apps', 'inject', 'configFile', 'config_file', 'bootstrap', ] as const; export interface ParsedManifestResult { readonly manifest?: PluginManifest; readonly manifestKind?: PluginManifestKind; readonly manifestPath?: string; readonly shadowedManifestPath?: string; readonly diagnostics: readonly PluginDiagnostic[]; } export async function parseManifest(pluginRoot: string): Promise { const rootJsonPath = path.join(pluginRoot, KIMI_PLUGIN_ROOT_PATH); const dirJsonPath = path.join(pluginRoot, KIMI_PLUGIN_DIR_PATH); const rootJsonExists = await isFile(rootJsonPath); const dirJsonExists = await isFile(dirJsonPath); if (!rootJsonExists && !dirJsonExists) { return { diagnostics: [ { severity: 'error', message: `No manifest at ${KIMI_PLUGIN_ROOT_PATH} or ${KIMI_PLUGIN_DIR_PATH}`, }, ], }; } const manifestPath = rootJsonExists ? rootJsonPath : dirJsonPath; const manifestKind: PluginManifestKind = rootJsonExists ? 'kimi-plugin-root' : 'kimi-plugin-dir'; const shadowedManifestPath = rootJsonExists && dirJsonExists ? dirJsonPath : undefined; let raw: unknown; try { raw = JSON.parse(await readFile(manifestPath, 'utf8')); } catch (error) { return { manifestKind, manifestPath, shadowedManifestPath, diagnostics: [ { severity: 'error', message: `Failed to parse ${path.relative(pluginRoot, manifestPath)}: ${(error as Error).message}`, }, ], }; } if (!isObject(raw)) { return { manifestKind, manifestPath, shadowedManifestPath, diagnostics: [{ severity: 'error', message: 'manifest must be a JSON object' }], }; } const diagnostics: PluginDiagnostic[] = []; const name = typeof raw['name'] === 'string' ? raw['name'].trim() : ''; if (name.length === 0) { diagnostics.push({ severity: 'error', message: '"name" is required' }); return { manifestKind, manifestPath, shadowedManifestPath, diagnostics }; } if (!PLUGIN_NAME_REGEX.test(name)) { diagnostics.push({ severity: 'error', message: `"name" must match ${PLUGIN_NAME_REGEX} (got "${name}")`, }); return { manifestKind, manifestPath, shadowedManifestPath, diagnostics }; } let skills = await resolveDirListField(pluginRoot, 'skills', raw['skills'], diagnostics); let rootSkillFallback: boolean | undefined; if (raw['skills'] === undefined) { const rootSkillMd = path.join(pluginRoot, 'SKILL.md'); if (await isFile(rootSkillMd)) { skills = [pluginRoot]; rootSkillFallback = true; } } let agents = await resolveDirListField(pluginRoot, 'agents', raw['agents'], diagnostics); if (raw['agents'] === undefined) { const agentsDir = path.join(pluginRoot, 'agents'); if (await isDir(agentsDir)) { agents = [agentsDir]; } } const skillInstructions = typeof raw['skillInstructions'] === 'string' ? raw['skillInstructions'] : undefined; const systemPrompt = await readSystemPrompt(pluginRoot, raw, diagnostics); recordUnsupportedRuntimeFields(raw, diagnostics); const manifest: PluginManifest = { name, version: stringField(raw, 'version'), description: stringField(raw, 'description'), keywords: stringArrayField(raw, 'keywords'), homepage: stringField(raw, 'homepage'), license: stringField(raw, 'license'), author: readAuthor(raw['author']), skills, rootSkillFallback, agents, sessionStart: readSessionStart(raw['sessionStart'], diagnostics), mcpServers: await readMcpServers(pluginRoot, raw['mcpServers'], diagnostics), hooks: readHooks(raw['hooks'], diagnostics), commands: await readCommands(pluginRoot, raw['commands'], diagnostics), interface: readInterface(raw['interface']), skillInstructions, systemPrompt, }; return { manifest, manifestKind, manifestPath, shadowedManifestPath, diagnostics }; } function recordUnsupportedRuntimeFields( raw: Record, diagnostics: PluginDiagnostic[], ): void { for (const field of UNSUPPORTED_RUNTIME_FIELDS) { if (raw[field] === undefined) continue; diagnostics.push({ severity: 'info', message: `"${field}" is present but not supported by Kimi plugins`, }); } } async function resolveDirListField( pluginRoot: string, field: string, raw: unknown, diagnostics: PluginDiagnostic[], ): Promise { if (raw === undefined) return []; const entries: string[] = []; if (typeof raw === 'string') { entries.push(raw); } else if (Array.isArray(raw) && raw.every((entry) => typeof entry === 'string')) { entries.push(...raw); } else { diagnostics.push({ severity: 'error', message: `"${field}" must be a string or string[]` }); return []; } const resolved: string[] = []; for (const entry of entries) { if (!entry.startsWith('./')) { diagnostics.push({ severity: 'error', message: `"${field}" path must start with "./" (got "${entry}")`, }); continue; } const absolute = path.resolve(pluginRoot, entry); let real: string; try { real = await realpath(absolute); } catch { real = absolute; } const rootReal = await realpath(pluginRoot).catch(() => pluginRoot); if (!isWithin(real, rootReal)) { diagnostics.push({ severity: 'error', message: `"${field}" path resolves outside the plugin (${entry})`, }); continue; } if (!(await isDir(real))) { diagnostics.push({ severity: 'warn', message: `"${field}" path is not a directory (${entry})`, }); continue; } resolved.push(real); } return resolved; } async function resolvePluginPathField(input: { readonly pluginRoot: string; readonly field: string; readonly value: string; readonly diagnostics: PluginDiagnostic[]; }): Promise { if (!input.value.startsWith('./')) { input.diagnostics.push({ severity: 'warn', message: `"${input.field}" path must start with "./" (got "${input.value}")`, }); return undefined; } const absolute = path.resolve(input.pluginRoot, input.value); let real: string; try { real = await realpath(absolute); } catch { real = absolute; } const rootReal = await realpath(input.pluginRoot).catch(() => input.pluginRoot); if (!isWithin(real, rootReal)) { input.diagnostics.push({ severity: 'warn', message: `"${input.field}" path resolves outside the plugin (${input.value})`, }); return undefined; } return real; } function readSessionStart( raw: unknown, diagnostics: PluginDiagnostic[], ): PluginManifest['sessionStart'] { if (raw === undefined) return undefined; if (!isObject(raw)) { diagnostics.push({ severity: 'warn', message: '"sessionStart" must be an object' }); return undefined; } const skill = typeof raw['skill'] === 'string' ? raw['skill'].trim() : ''; if (skill.length === 0) { diagnostics.push({ severity: 'warn', message: '"sessionStart.skill" is required when sessionStart is present', }); return undefined; } return { skill }; } async function readSystemPrompt( pluginRoot: string, raw: Record, diagnostics: PluginDiagnostic[], ): Promise { const parts: string[] = []; if (raw['systemPrompt'] !== undefined && typeof raw['systemPrompt'] !== 'string') { diagnostics.push({ severity: 'warn', message: '"systemPrompt" must be a string' }); } const inline = stringField(raw, 'systemPrompt'); if (inline !== undefined) { const inlineBytes = Buffer.byteLength(inline, 'utf8'); if (inlineBytes > PLUGIN_SYSTEM_PROMPT_MAX_BYTES) { diagnostics.push({ severity: 'warn', message: `"systemPrompt" is ${inlineBytes} bytes, exceeding the ` + `${PLUGIN_SYSTEM_PROMPT_MAX_BYTES / 1024} KB limit; the field is ignored`, }); } else { parts.push(inline); } } const pathValue = raw['systemPromptPath']; if (pathValue !== undefined) { if (typeof pathValue !== 'string') { diagnostics.push({ severity: 'warn', message: '"systemPromptPath" must be a string' }); } else if (pathValue.trim().length === 0) { diagnostics.push({ severity: 'warn', message: '"systemPromptPath" must not be blank' }); } else { const resolved = await resolvePluginPathField({ pluginRoot, field: 'systemPromptPath', value: pathValue.trim(), diagnostics, }); if (resolved !== undefined) { const fileStat = await stat(resolved).catch(() => undefined); if (fileStat === undefined || !fileStat.isFile()) { diagnostics.push({ severity: 'warn', message: `"systemPromptPath" is not a file (${pathValue})`, }); } else if (fileStat.size > PLUGIN_SYSTEM_PROMPT_MAX_BYTES) { diagnostics.push({ severity: 'warn', message: `"systemPromptPath" is ${fileStat.size} bytes, exceeding the ` + `${PLUGIN_SYSTEM_PROMPT_MAX_BYTES / 1024} KB limit; the file is ignored (${pathValue})`, }); } else { try { const content = (await readFile(resolved, 'utf8')).replace(/^\uFEFF/, '').trim(); if (content.length > 0) parts.push(content); } catch (error) { diagnostics.push({ severity: 'warn', message: `Failed to read "systemPromptPath" (${pathValue}): ${(error as Error).message}`, }); } } } } } return parts.length === 0 ? undefined : parts.join('\n\n'); } async function readMcpServers( pluginRoot: string, raw: unknown, diagnostics: PluginDiagnostic[], ): Promise { if (raw === undefined) return undefined; if (!isObject(raw)) { diagnostics.push({ severity: 'warn', message: '"mcpServers" must be an object' }); return undefined; } const out: Record = {}; for (const [name, value] of Object.entries(raw)) { const trimmedName = name.trim(); if (trimmedName.length === 0) { diagnostics.push({ severity: 'warn', message: '"mcpServers" entries must have a non-empty name', }); continue; } const parsed = McpServerConfigSchema.safeParse(value); if (!parsed.success) { diagnostics.push({ severity: 'warn', message: `Invalid MCP server "${trimmedName}": ${parsed.error.message}`, }); continue; } const normalized = await normalizePluginMcpServer({ pluginRoot, name: trimmedName, config: parsed.data, diagnostics, }); if (normalized !== undefined) out[trimmedName] = normalized; } return Object.keys(out).length === 0 ? undefined : out; } function readHooks( raw: unknown, diagnostics: PluginDiagnostic[], ): readonly HookDefConfig[] | undefined { if (raw === undefined) return undefined; if (!Array.isArray(raw)) { diagnostics.push({ severity: 'warn', message: '"hooks" must be an array' }); return undefined; } const out: HookDefConfig[] = []; raw.forEach((entry, i) => { const parsed = HookDefSchema.safeParse(entry); if (!parsed.success) { diagnostics.push({ severity: 'warn', message: `Invalid hook at index ${i}: ${parsed.error.message}`, }); } else { out.push(parsed.data); } }); return out.length === 0 ? undefined : out; } async function readCommands( pluginRoot: string, raw: unknown, diagnostics: PluginDiagnostic[], ): Promise { if (raw === undefined) return undefined; const entries: string[] = []; if (typeof raw === 'string') { entries.push(raw); } else if (Array.isArray(raw) && raw.every((entry) => typeof entry === 'string')) { entries.push(...raw); } else { diagnostics.push({ severity: 'warn', message: '"commands" must be a string or string[]' }); return undefined; } const files: PluginCommandEntry[] = []; for (const entry of entries) { const resolved = await resolvePluginPathField({ pluginRoot, field: 'commands', value: entry, diagnostics, }); if (resolved === undefined) continue; if (await isDir(resolved)) { files.push(...(await listMarkdownFilesRecursive(resolved))); } else if ((await isFile(resolved)) && resolved.endsWith('.md')) { files.push({ path: resolved, name: commandNameFromFile(resolved, path.dirname(resolved)) }); } else { diagnostics.push({ severity: 'warn', message: `"commands" entry must be a directory or .md file (${entry})`, }); } } return files.length === 0 ? undefined : files.toSorted((a, b) => a.name.localeCompare(b.name)); } async function listMarkdownFilesRecursive(root: string): Promise { const out: PluginCommandEntry[] = []; await walkMarkdown(root, root, out); return out; } async function walkMarkdown( root: string, dir: string, out: PluginCommandEntry[], ): Promise { let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { await walkMarkdown(root, full, out); } else if (entry.isFile() && entry.name.endsWith('.md')) { out.push({ path: full, name: commandNameFromFile(full, root) }); } } } function commandNameFromFile(file: string, root: string): string { const relative = path.relative(root, file).replace(/\.md$/i, ''); return relative.split(path.sep).join('/'); } async function normalizePluginMcpServer(input: { readonly pluginRoot: string; readonly name: string; readonly config: McpServerConfig; readonly diagnostics: PluginDiagnostic[]; }): Promise { const { config } = input; if (config.transport === 'http' || config.transport === 'sse') return config; let command = config.command; if (command.startsWith('./')) { const resolvedCommand = await resolvePluginPathField({ pluginRoot: input.pluginRoot, field: `mcpServers.${input.name}.command`, value: command, diagnostics: input.diagnostics, }); if (resolvedCommand === undefined) return undefined; command = resolvedCommand; } else if (command.includes('/') || path.isAbsolute(command)) { input.diagnostics.push({ severity: 'warn', message: `"mcpServers.${input.name}.command" must be a PATH command or start with "./"`, }); return undefined; } let cwd = config.cwd; if (cwd !== undefined) { const resolvedCwd = await resolvePluginPathField({ pluginRoot: input.pluginRoot, field: `mcpServers.${input.name}.cwd`, value: cwd, diagnostics: input.diagnostics, }); if (resolvedCwd === undefined) return undefined; cwd = resolvedCwd; } return { ...config, command, cwd }; } function readAuthor(raw: unknown): PluginManifest['author'] { if (typeof raw === 'string') return { name: raw }; if (!isObject(raw)) return undefined; const name = stringField(raw, 'name'); const email = stringField(raw, 'email'); if (name === undefined && email === undefined) return undefined; return { name, email }; } function readInterface(raw: unknown): PluginInterface | undefined { if (!isObject(raw)) return undefined; const out: PluginInterface = { displayName: stringField(raw, 'displayName'), shortDescription: stringField(raw, 'shortDescription'), longDescription: stringField(raw, 'longDescription'), developerName: stringField(raw, 'developerName'), websiteURL: stringField(raw, 'websiteURL'), }; const hasAny = Object.values(out).some((value) => value !== undefined); return hasAny ? out : undefined; } function stringField(raw: Record, key: string): string | undefined { const value = raw[key]; if (typeof value !== 'string') return undefined; const trimmed = value.trim(); return trimmed.length === 0 ? undefined : trimmed; } function stringArrayField(raw: Record, key: string): readonly string[] | undefined { const value = raw[key]; if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) { return undefined; } return value as readonly string[]; } function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function isWithin(child: string, parent: string): boolean { const relative = path.relative(parent, child); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); } async function isFile(p: string): Promise { try { return (await stat(p)).isFile(); } catch { return false; } } async function isDir(p: string): Promise { try { return (await stat(p)).isDirectory(); } catch { return false; } }