From 6a4e4c75d4bf6db3fefbb5c115d7a7c324bcae16 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 4 Jun 2026 21:15:18 +0800 Subject: [PATCH] feat(cli): add doctor command for config validation (#431) * feat(cli): add doctor command for config validation * fix(cli): format doctor validation errors * fix(cli): validate doctor config through SDK RPC * chore(changeset): simplify doctor release note --- .changeset/validate-config-doctor.md | 6 + apps/kimi-code/src/cli/commands.ts | 2 + apps/kimi-code/src/cli/sub/doctor.ts | 342 ++++++++++++++++++++++++ apps/kimi-code/test/cli/doctor.test.ts | 270 +++++++++++++++++++ apps/kimi-code/test/cli/options.test.ts | 10 +- docs/en/reference/kimi-command.md | 29 +- docs/zh/reference/kimi-command.md | 29 +- packages/node-sdk/src/config-rpc.ts | 114 ++++++++ packages/node-sdk/src/index.ts | 9 + packages/node-sdk/test/config.test.ts | 36 ++- 10 files changed, 843 insertions(+), 4 deletions(-) create mode 100644 .changeset/validate-config-doctor.md create mode 100644 apps/kimi-code/src/cli/sub/doctor.ts create mode 100644 apps/kimi-code/test/cli/doctor.test.ts create mode 100644 packages/node-sdk/src/config-rpc.ts diff --git a/.changeset/validate-config-doctor.md b/.changeset/validate-config-doctor.md new file mode 100644 index 000000000..6649cb4cc --- /dev/null +++ b/.changeset/validate-config-doctor.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": minor +"@moonshot-ai/kimi-code-sdk": minor +--- + +Add a doctor command for validating Kimi Code configuration files. diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 0d4a558d5..4feb16316 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -6,6 +6,7 @@ import { registerMigrateCommand } from '#/migration/index'; import type { CLIOptions } from './options'; import { registerAcpCommand } from './sub/acp'; +import { registerDoctorCommand } from './sub/doctor'; import { registerExportCommand } from './sub/export'; import { registerLoginCommand } from './sub/login'; import { registerProviderCommand } from './sub/provider'; @@ -82,6 +83,7 @@ export function createProgram( registerProviderCommand(program); registerAcpCommand(program); registerLoginCommand(program); + registerDoctorCommand(program); registerMigrateCommand(program, onMigrate); program .command('upgrade') diff --git a/apps/kimi-code/src/cli/sub/doctor.ts b/apps/kimi-code/src/cli/sub/doctor.ts new file mode 100644 index 000000000..0ccc38d28 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/doctor.ts @@ -0,0 +1,342 @@ +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { isAbsolute, resolve } from 'node:path'; + +import { + createKimiConfigRpc, + type KimiConfigRpc, + type KimiConfigValidationIssue, +} from '@moonshot-ai/kimi-code-sdk'; +import type { Command } from 'commander'; +import { z } from 'zod'; + +import { getTuiConfigPath, parseTuiConfig } from '#/tui/config'; + +interface WritableLike { + write(chunk: string): boolean; +} + +type MaybePromise = T | Promise; + +export interface DoctorDeps { + readonly cwd: () => string; + readonly defaultConfigPath: () => MaybePromise; + readonly defaultTuiConfigPath: () => string; + readonly stdout: WritableLike; + readonly stderr: WritableLike; + readonly exit: (code: number) => never; + readonly configRpc?: KimiConfigRpc; + readonly fileExists?: (path: string) => boolean; + readonly readTextFile?: (path: string) => Promise; + readonly validateConfigToml?: (text: string, path: string) => MaybePromise; +} + +export interface DoctorOptions { + readonly target?: 'config' | 'tui'; + readonly path?: string; +} + +interface CheckSpec { + readonly label: 'config.toml' | 'tui.toml'; + readonly path: string; + readonly explicit: boolean; + readonly parse: (text: string, path: string) => MaybePromise; +} + +interface CheckResult { + readonly label: CheckSpec['label']; + readonly path: string; + readonly status: 'OK' | 'SKIP' | 'ERROR'; + readonly message?: string; +} + +interface ResolvedDoctorDeps { + readonly cwd: () => string; + readonly defaultConfigPath: () => MaybePromise; + readonly defaultTuiConfigPath: () => string; + readonly stdout: WritableLike; + readonly stderr: WritableLike; + readonly exit: (code: number) => never; + readonly fileExists: (path: string) => boolean; + readonly readTextFile: (path: string) => Promise; + readonly validateConfigToml: (text: string, path: string) => MaybePromise; +} + +export async function handleDoctor(deps: DoctorDeps, options: DoctorOptions): Promise { + const resolved = resolveDeps(deps); + const cwd = resolved.cwd(); + const specs = await buildCheckSpecs(resolved, options, cwd); + const results = await Promise.all(specs.map((spec) => checkTomlFile(resolved, spec))); + + const issueCount = results.filter((result) => result.status === 'ERROR').length; + const text = issueCount === 0 ? formatSuccess(results) : formatFailure(results, issueCount); + if (issueCount === 0) { + resolved.stdout.write(text); + } else { + resolved.stderr.write(text); + } + return issueCount === 0 ? 0 : 1; +} + +export function registerDoctorCommand(parent: Command, deps?: Partial): void { + const doctor = parent + .command('doctor') + .description('Validate Kimi Code configuration files.') + .action(async () => { + await runDoctorCommand(deps, {}); + }); + + doctor + .command('config') + .description('Validate config.toml.') + .argument('[path]', 'Validate this file as config.toml instead of the default path.') + .action(async (path: string | undefined) => { + await runDoctorCommand(deps, { target: 'config', path }); + }); + + doctor + .command('tui') + .description('Validate tui.toml.') + .argument('[path]', 'Validate this file as tui.toml instead of the default path.') + .action(async (path: string | undefined) => { + await runDoctorCommand(deps, { target: 'tui', path }); + }); +} + +async function runDoctorCommand( + deps: Partial | undefined, + options: DoctorOptions, +): Promise { + const resolved = resolveDeps(deps); + const code = await handleDoctor(resolved, options); + if (code !== 0) resolved.exit(code); +} + +function resolveDeps(deps: Partial | DoctorDeps | undefined): ResolvedDoctorDeps { + let configRpc = deps?.configRpc; + const getConfigRpc = (): KimiConfigRpc => { + configRpc ??= createKimiConfigRpc(); + return configRpc; + }; + + return { + cwd: deps?.cwd ?? (() => process.cwd()), + defaultConfigPath: deps?.defaultConfigPath ?? (() => getConfigRpc().resolveConfigPath()), + defaultTuiConfigPath: deps?.defaultTuiConfigPath ?? getTuiConfigPath, + stdout: deps?.stdout ?? process.stdout, + stderr: deps?.stderr ?? process.stderr, + exit: deps?.exit ?? ((code) => process.exit(code)), + fileExists: deps?.fileExists ?? existsSync, + readTextFile: deps?.readTextFile ?? ((path) => readFile(path, 'utf-8')), + validateConfigToml: + deps?.validateConfigToml ?? + ((text, filePath) => getConfigRpc().validateConfigToml({ text, filePath })), + }; +} + +async function buildCheckSpecs( + deps: ResolvedDoctorDeps, + options: DoctorOptions, + cwd: string, +): Promise { + if (options.target === 'config') { + return [ + makeConfigSpec( + await resolveConfigTargetPath(deps, options.path, cwd), + options.path !== undefined, + deps, + ), + ]; + } + + if (options.target === 'tui') { + return [ + makeTuiSpec( + resolveTuiTargetPath(deps, options.path, cwd), + options.path !== undefined, + ), + ]; + } + + return [ + makeConfigSpec(await deps.defaultConfigPath(), false, deps), + makeTuiSpec(deps.defaultTuiConfigPath(), false), + ]; +} + +function makeConfigSpec( + path: string, + explicit: boolean, + deps: ResolvedDoctorDeps, +): CheckSpec { + return { + label: 'config.toml', + path, + explicit, + parse: (text, filePath) => { + return deps.validateConfigToml(text, filePath); + }, + }; +} + +function makeTuiSpec(path: string, explicit: boolean): CheckSpec { + return { + label: 'tui.toml', + path, + explicit, + parse: (text) => { + parseTuiConfig(text); + }, + }; +} + +async function checkTomlFile(deps: ResolvedDoctorDeps, spec: CheckSpec): Promise { + if (!deps.fileExists(spec.path)) { + return { + label: spec.label, + path: spec.path, + status: spec.explicit ? 'ERROR' : 'SKIP', + message: spec.explicit + ? 'File does not exist.' + : 'File does not exist; built-in defaults will apply.', + }; + } + + try { + const text = await deps.readTextFile(spec.path); + await spec.parse(text, spec.path); + return { label: spec.label, path: spec.path, status: 'OK' }; + } catch (error) { + return { + label: spec.label, + path: spec.path, + status: 'ERROR', + message: formatErrorMessage(error, spec.path), + }; + } +} + +async function resolveConfigTargetPath( + deps: ResolvedDoctorDeps, + input: string | undefined, + cwd: string, +): Promise { + return input === undefined ? deps.defaultConfigPath() : resolveInputPath(input, cwd); +} + +function resolveTuiTargetPath( + deps: ResolvedDoctorDeps, + input: string | undefined, + cwd: string, +): string { + return input === undefined ? deps.defaultTuiConfigPath() : resolveInputPath(input, cwd); +} + +function resolveInputPath(input: string, cwd: string): string { + return isAbsolute(input) ? input : resolve(cwd, input); +} + +function formatSuccess(results: readonly CheckResult[]): string { + return [ + 'Kimi doctor', + '', + ...formatResults(results), + '', + 'All checked config files are valid.', + '', + ].join('\n'); +} + +function formatFailure(results: readonly CheckResult[], issueCount: number): string { + return [ + `Kimi doctor found ${String(issueCount)} ${issueCount === 1 ? 'issue' : 'issues'}.`, + '', + ...formatResults(results), + '', + ].join('\n'); +} + +function formatResults(results: readonly CheckResult[]): string[] { + const lines: string[] = []; + for (const result of results) { + lines.push(`${result.status} ${result.label.padEnd(12)} ${result.path}`); + if (result.message !== undefined) { + for (const line of result.message.split('\n')) { + lines.push(` ${line}`); + } + } + } + return lines; +} + +function formatErrorMessage(error: unknown, filePath: string): string { + const validationIssues = findValidationIssues(error); + if (validationIssues !== undefined) { + return [ + `Invalid configuration in ${filePath}.`, + 'Validation issues:', + ...validationIssues.map((issue) => ` ${formatIssuePath(issue.path)}: ${issue.message}`), + ].join('\n'); + } + + const zodError = findZodError(error); + if (zodError !== undefined) { + return [ + `Invalid configuration in ${filePath}.`, + 'Validation issues:', + ...zodError.issues.map((issue) => ` ${formatIssuePath(issue.path)}: ${issue.message}`), + ].join('\n'); + } + return error instanceof Error ? error.message : String(error); +} + +function findValidationIssues(error: unknown): readonly KimiConfigValidationIssue[] | undefined { + if (!(error instanceof Error)) return undefined; + const details = 'details' in error ? error.details : undefined; + if (!isRecord(details)) return undefined; + const validationIssues = details['validationIssues']; + return isValidationIssueArray(validationIssues) ? validationIssues : undefined; +} + +function isValidationIssueArray(value: unknown): value is readonly KimiConfigValidationIssue[] { + return Array.isArray(value) && value.every(isValidationIssue); +} + +function isValidationIssue(value: unknown): value is KimiConfigValidationIssue { + if (!isRecord(value) || typeof value['message'] !== 'string') return false; + const path = value['path']; + return ( + Array.isArray(path) && + path.every((segment) => typeof segment === 'string' || typeof segment === 'number') + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function findZodError(error: unknown): z.ZodError | undefined { + if (error instanceof z.ZodError) return error; + if (error instanceof Error && error.cause instanceof z.ZodError) return error.cause; + return undefined; +} + +function formatIssuePath(path: readonly PropertyKey[]): string { + if (path.length === 0) return ''; + + let out = ''; + for (const segment of path) { + if (typeof segment === 'number') { + out += `[${String(segment)}]`; + } else if (out.length === 0) { + out = camelToSnake(String(segment)); + } else { + out += `.${camelToSnake(String(segment))}`; + } + } + return out; +} + +function camelToSnake(value: string): string { + return value.replaceAll(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`); +} diff --git a/apps/kimi-code/test/cli/doctor.test.ts b/apps/kimi-code/test/cli/doctor.test.ts new file mode 100644 index 000000000..b6404c97d --- /dev/null +++ b/apps/kimi-code/test/cli/doctor.test.ts @@ -0,0 +1,270 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + handleDoctor, + registerDoctorCommand, + type DoctorDeps, +} from '#/cli/sub/doctor'; + +let dir: string; + +beforeEach(async () => { + dir = join(tmpdir(), `kimi-doctor-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(dir, { recursive: true }); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +function makeDeps(): { + deps: DoctorDeps; + stdout: string[]; + stderr: string[]; + exitCodes: number[]; +} { + const stdout: string[] = []; + const stderr: string[] = []; + const exitCodes: number[] = []; + return { + deps: { + cwd: () => dir, + defaultConfigPath: () => join(dir, 'config.toml'), + defaultTuiConfigPath: () => join(dir, 'tui.toml'), + stdout: { write: (chunk) => stdout.push(chunk) > 0 }, + stderr: { write: (chunk) => stderr.push(chunk) > 0 }, + exit: (code) => { + exitCodes.push(code); + throw new Error(`exit ${String(code)}`); + }, + }, + stdout, + stderr, + exitCodes, + }; +} + +async function writeValidConfig(path = join(dir, 'config.toml')): Promise { + await writeFile( + path, + ` +[providers.kimi] +type = "kimi" +base_url = "https://api.example.com/v1" +api_key = "YOUR_API_KEY" + +[models.kimi] +provider = "kimi" +model = "kimi" +max_context_size = 262144 +`, + 'utf-8', + ); +} + +async function writeValidTuiConfig(path = join(dir, 'tui.toml')): Promise { + await writeFile( + path, + ` +theme = "dark" + +[editor] +command = "code --wait" + +[notifications] +enabled = true +notification_condition = "unfocused" + +[upgrade] +auto_install = true +`, + 'utf-8', + ); +} + +describe('kimi doctor', () => { + it('skips missing default config files without failing', async () => { + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, {}); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + const out = stdout.join(''); + expect(out).toContain('SKIP config.toml'); + expect(out).toContain('SKIP tui.toml'); + expect(out).toContain('built-in defaults will apply'); + }); + + it('checks only config.toml when the config target is selected', async () => { + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + const out = stdout.join(''); + expect(out).toContain('SKIP config.toml'); + expect(out).not.toContain('tui.toml'); + }); + + it('checks only tui.toml when the tui target is selected', async () => { + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'tui' }); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + const out = stdout.join(''); + expect(out).toContain('SKIP tui.toml'); + expect(out).not.toContain('config.toml'); + }); + + it('treats a missing explicit target path as an error', async () => { + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config', path: './missing.toml' }); + + expect(code).toBe(1); + expect(stdout.join('')).toBe(''); + const err = stderr.join(''); + expect(err).toContain('Kimi doctor found 1 issue.'); + expect(err).toContain(`ERROR config.toml ${resolve(dir, 'missing.toml')}`); + expect(err).toContain('File does not exist.'); + expect(err).not.toContain('tui.toml'); + }); + + it('checks a valid explicit config path routed through commander', async () => { + const configPath = join(dir, 'candidate-config.toml'); + await writeValidConfig(configPath); + const { deps, stdout, stderr, exitCodes } = makeDeps(); + const program = new Command('kimi'); + registerDoctorCommand(program, deps); + + await program.parseAsync(['node', 'kimi', 'doctor', 'config', './candidate-config.toml']); + + expect(exitCodes).toEqual([]); + expect(stderr.join('')).toBe(''); + const out = stdout.join(''); + expect(out).toContain(`OK config.toml ${configPath}`); + expect(out).not.toContain('tui.toml'); + expect(out).toContain('All checked config files are valid.'); + }); + + it('does not resolve the default config path when an explicit config path is provided', async () => { + const configPath = join(dir, 'candidate-config.toml'); + await writeValidConfig(configPath); + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor( + { + ...deps, + defaultConfigPath: () => { + throw new Error('default config path should not be resolved'); + }, + }, + { target: 'config', path: './candidate-config.toml' }, + ); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + expect(stdout.join('')).toContain(`OK config.toml ${configPath}`); + }); + + it('checks a valid explicit tui path routed through commander', async () => { + const tuiConfigPath = join(dir, 'candidate-tui.toml'); + await writeValidTuiConfig(tuiConfigPath); + const { deps, stdout, stderr, exitCodes } = makeDeps(); + const program = new Command('kimi'); + registerDoctorCommand(program, deps); + + await program.parseAsync(['node', 'kimi', 'doctor', 'tui', './candidate-tui.toml']); + + expect(exitCodes).toEqual([]); + expect(stderr.join('')).toBe(''); + const out = stdout.join(''); + expect(out).toContain(`OK tui.toml ${tuiConfigPath}`); + expect(out).not.toContain('config.toml'); + expect(out).toContain('All checked config files are valid.'); + }); + + it('aggregates config.toml and tui.toml parse errors', async () => { + await writeFile( + join(dir, 'config.toml'), + ` +[providers.kimi] +type = "kimi" + +[models.kimi] +provider = "kimi" +model = "kimi" +max_context_size = 0 +`, + 'utf-8', + ); + await writeFile(join(dir, 'tui.toml'), 'theme = "blue"\n', 'utf-8'); + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, {}); + + expect(code).toBe(1); + expect(stdout.join('')).toBe(''); + const err = stderr.join(''); + expect(err).toContain('Kimi doctor found 2 issues.'); + expect(err).toContain(`ERROR config.toml ${join(dir, 'config.toml')}`); + expect(err).toContain('max_context_size'); + expect(err).toContain(`ERROR tui.toml ${join(dir, 'tui.toml')}`); + expect(err).toContain('theme'); + }); + + it('formats Zod validation issues with field paths for tui.toml', async () => { + await writeFile( + join(dir, 'tui.toml'), + ` +theme = "blue" + +[notifications] +enabled = "yes" +`, + 'utf-8', + ); + const { deps, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'tui' }); + + expect(code).toBe(1); + const err = stderr.join(''); + expect(err).toContain('Validation issues:'); + expect(err).toContain('theme:'); + expect(err).toContain('notifications.enabled:'); + }); + + it('formats wrapped Zod validation issues with TOML-style field paths for config.toml', async () => { + await writeFile( + join(dir, 'config.toml'), + ` +[providers.kimi] +type = "kimi" + +[models.kimi] +provider = "kimi" +model = "kimi" +max_context_size = "large" +`, + 'utf-8', + ); + const { deps, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(1); + const err = stderr.join(''); + expect(err).toContain('Validation issues:'); + expect(err).toContain('models.kimi.max_context_size:'); + }); +}); diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 3766e0037..90fb53ecf 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -285,7 +285,15 @@ describe('CLI options parsing', () => { const commandNames: string[] = program.commands .filter((command) => !command.name().startsWith('__')) .map((command) => command.name()); - expect(commandNames).toEqual(['export', 'provider', 'acp', 'login', 'migrate', 'upgrade']); + expect(commandNames).toEqual([ + 'export', + 'provider', + 'acp', + 'login', + 'doctor', + 'migrate', + 'upgrade', + ]); }); }); diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 98ee7a181..b8be01096 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -121,7 +121,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), `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), `doctor` (validate configuration files), `export` (export a session), `migrate` (migrate legacy data), `upgrade` (check for updates), and `provider` (manage providers). ### `kimi login` @@ -141,6 +141,33 @@ Switch Kimi Code CLI to ACP (Agent Client Protocol) mode, communicating with an kimi acp ``` +### `kimi doctor` + +Validate `config.toml` and `tui.toml` without starting the TUI or modifying either file. By default, the command checks the files under `KIMI_CODE_HOME` (or `~/.kimi-code` when the environment variable is unset). Missing default files are reported as skipped because built-in defaults can apply. + +```sh +kimi doctor +``` + +| Command | Description | +| --- | --- | +| `kimi doctor` | Validate the default `config.toml` and `tui.toml` | +| `kimi doctor config [path]` | Validate only `config.toml`, using `path` instead of the default file when provided | +| `kimi doctor tui [path]` | Validate only `tui.toml`, using `path` instead of the default file when provided | + +When an explicit path is passed, the file must exist. The command exits with `0` when all checked files are valid or skipped, and `1` when any requested file is missing or invalid. + +```sh +# Check the default config files +kimi doctor + +# Check only the default runtime config +kimi doctor config + +# Check a candidate TUI config before replacing the live config +kimi doctor tui ./tui.toml +``` + ### `kimi export` Package a session into a ZIP file for sharing, archiving, or submitting bug reports. diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index b8d4d50e8..36fb20d4c 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -121,7 +121,7 @@ kimi -p "List changed files" --output-format stream-json ## 子命令 -`kimi` 提供以下子命令:`login`(非交互式登录)、`acp`(ACP IDE 模式)、`export`(导出会话)、`migrate`(迁移旧版数据)、`upgrade`(检查更新)、`provider`(管理供应商)。 +`kimi` 提供以下子命令:`login`(非交互式登录)、`acp`(ACP IDE 模式)、`doctor`(校验配置文件)、`export`(导出会话)、`migrate`(迁移旧版数据)、`upgrade`(检查更新)、`provider`(管理供应商)。 ### `kimi login` @@ -141,6 +141,33 @@ kimi login kimi acp ``` +### `kimi doctor` + +校验 `config.toml` 和 `tui.toml`,不会启动 TUI,也不会修改任一文件。默认检查 `KIMI_CODE_HOME` 下的文件;未设置该环境变量时检查 `~/.kimi-code`。默认路径缺失时会显示为跳过,因为内置默认值仍可生效。 + +```sh +kimi doctor +``` + +| 命令 | 说明 | +| --- | --- | +| `kimi doctor` | 校验默认 `config.toml` 和 `tui.toml` | +| `kimi doctor config [path]` | 只校验 `config.toml`;传入 `path` 时使用该文件而不是默认文件 | +| `kimi doctor tui [path]` | 只校验 `tui.toml`;传入 `path` 时使用该文件而不是默认文件 | + +显式传入路径时,文件必须存在。所有被检查的文件都有效或被跳过时,退出码为 `0`;任何指定文件缺失或配置无效时,退出码为 `1`。 + +```sh +# 检查默认配置文件 +kimi doctor + +# 只检查默认运行时配置 +kimi doctor config + +# 替换正式 TUI 配置前,先检查候选文件 +kimi doctor tui ./tui.toml +``` + ### `kimi export` 把一个会话打包成 ZIP 文件,便于分享、归档或提交问题反馈。 diff --git a/packages/node-sdk/src/config-rpc.ts b/packages/node-sdk/src/config-rpc.ts new file mode 100644 index 000000000..8b0bf473f --- /dev/null +++ b/packages/node-sdk/src/config-rpc.ts @@ -0,0 +1,114 @@ +import { + createRPC, + ErrorCodes, + KimiError, + parseConfigString, + resolveConfigPath, + type RPCMethods, +} from '@moonshot-ai/agent-core'; +import { z } from 'zod'; + +export type KimiConfigValidationPathSegment = string | number; + +export interface KimiConfigValidationIssue { + readonly path: readonly KimiConfigValidationPathSegment[]; + readonly message: string; +} + +export interface ResolveKimiConfigPathInput { + readonly homeDir?: string | undefined; + readonly configPath?: string | undefined; +} + +export interface ValidateKimiConfigTomlInput { + readonly text: string; + readonly filePath?: string | undefined; +} + +export interface KimiConfigRpc { + resolveConfigPath(input?: ResolveKimiConfigPathInput): Promise; + validateConfigToml(input: ValidateKimiConfigTomlInput): Promise; +} + +interface KimiConfigCoreRpc { + resolveConfigPath(input: ResolveKimiConfigPathInput): string; + validateConfigToml(input: ValidateKimiConfigTomlInput): void; +} + +interface KimiConfigClientRpc {} + +class KimiConfigCoreRpcImpl implements KimiConfigCoreRpc { + resolveConfigPath(input: ResolveKimiConfigPathInput): string { + return resolveConfigPath(input); + } + + validateConfigToml(input: ValidateKimiConfigTomlInput): void { + try { + parseConfigString(input.text, input.filePath); + } catch (error) { + const validationIssues = extractValidationIssues(error); + if (validationIssues !== undefined) { + throw toConfigValidationError(error, validationIssues); + } + throw error; + } + } +} + +export class KimiConfigRpcClient implements KimiConfigRpc { + private readonly ready: Promise>; + + constructor() { + const [coreRpc, clientRpc] = createRPC(); + void coreRpc(new KimiConfigCoreRpcImpl()); + this.ready = clientRpc({}); + } + + async resolveConfigPath(input: ResolveKimiConfigPathInput = {}): Promise { + const rpc = await this.ready; + return rpc.resolveConfigPath(input); + } + + async validateConfigToml(input: ValidateKimiConfigTomlInput): Promise { + const rpc = await this.ready; + await rpc.validateConfigToml(input); + } +} + +export function createKimiConfigRpc(): KimiConfigRpc { + return new KimiConfigRpcClient(); +} + +function toConfigValidationError( + error: unknown, + validationIssues: readonly KimiConfigValidationIssue[], +): KimiError { + const details = + error instanceof KimiError && error.details !== undefined + ? { ...error.details, validationIssues } + : { validationIssues }; + + if (error instanceof KimiError) { + return new KimiError(error.code, error.message, { details }); + } + + const message = error instanceof Error ? error.message : String(error); + return new KimiError(ErrorCodes.CONFIG_INVALID, message, { details }); +} + +function extractValidationIssues(error: unknown): readonly KimiConfigValidationIssue[] | undefined { + const zodError = findZodError(error); + if (zodError === undefined) return undefined; + return zodError.issues.map((issue) => ({ + path: issue.path.map((segment) => + typeof segment === 'number' ? segment : String(segment), + ), + message: issue.message, + })); +} + +function findZodError(error: unknown): z.ZodError | undefined { + if (error instanceof z.ZodError) return error; + if (error instanceof Error && error.cause instanceof z.ZodError) return error.cause; + return undefined; +} diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index deb51ef0d..840c738bd 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -7,6 +7,15 @@ export { SDKRpcClient, type SDKRpcClientOptions, } from '#/sdk-rpc-client'; +export { + createKimiConfigRpc, + KimiConfigRpcClient, + type KimiConfigRpc, + type KimiConfigValidationIssue, + type KimiConfigValidationPathSegment, + type ResolveKimiConfigPathInput, + type ValidateKimiConfigTomlInput, +} from '#/config-rpc'; export { SDKRpcClientBase } from '#/rpc'; export { KimiForCodingProvider } from '#/kimi-code-model-provider'; export type { KimiForCodingProviderOptions } from '#/kimi-code-model-provider'; diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index 6dc6e0a55..20129f171 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createKimiHarness, KimiError } from '#/index'; +import { createKimiConfigRpc, createKimiHarness, KimiError } from '#/index'; import { parseConfigString, @@ -83,6 +83,40 @@ claim_stale_after_ms = 15000 `; describe('SDK config TOML', () => { + it('resolves config paths through the config RPC wrapper', async () => { + const dir = await makeTempDir(); + const rpc = createKimiConfigRpc(); + + await expect(rpc.resolveConfigPath({ homeDir: dir })).resolves.toBe(join(dir, 'config.toml')); + }); + + it('returns structured validation issues through the config RPC wrapper', async () => { + const rpc = createKimiConfigRpc(); + + await expect( + rpc.validateConfigToml({ + text: ` +[providers.kimi] +type = "kimi" + +[models.kimi] +provider = "kimi" +model = "kimi" +max_context_size = "large" +`, + filePath: 'broken.toml', + }), + ).rejects.toMatchObject({ + details: { + validationIssues: [ + { + path: ['models', 'kimi', 'maxContextSize'], + }, + ], + }, + }); + }); + it('parses the documented config shape and keeps TUI-only fields in raw', () => { const config = parseConfigString(COMPLETE_TOML, 'complete.toml');