From 82026f2bfe9b9c7a7aac90791478af72351e1e25 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 4 Jun 2026 20:14:52 +0800 Subject: [PATCH 1/4] feat(cli): add doctor command for config validation --- .changeset/validate-config-doctor.md | 6 + apps/kimi-code/src/cli/commands.ts | 2 + apps/kimi-code/src/cli/sub/doctor.ts | 226 ++++++++++++++++++++++++ apps/kimi-code/test/cli/doctor.test.ts | 204 +++++++++++++++++++++ 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/index.ts | 2 + 8 files changed, 505 insertions(+), 3 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 diff --git a/.changeset/validate-config-doctor.md b/.changeset/validate-config-doctor.md new file mode 100644 index 0000000000..6649cb4cce --- /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 0d4a558d51..4feb163164 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 0000000000..ddc4bb0b87 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/doctor.ts @@ -0,0 +1,226 @@ +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { isAbsolute, resolve } from 'node:path'; + +import { parseConfigString, resolveConfigPath } from '@moonshot-ai/kimi-code-sdk'; +import type { Command } from 'commander'; + +import { getTuiConfigPath, parseTuiConfig } from '#/tui/config'; + +interface WritableLike { + write(chunk: string): boolean; +} + +export interface DoctorDeps { + readonly cwd: () => string; + readonly defaultConfigPath: () => string; + readonly defaultTuiConfigPath: () => string; + readonly stdout: WritableLike; + readonly stderr: WritableLike; + readonly exit: (code: number) => never; + readonly fileExists?: (path: string) => boolean; + readonly readTextFile?: (path: string) => Promise; +} + +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) => void; +} + +interface CheckResult { + readonly label: CheckSpec['label']; + readonly path: string; + readonly status: 'OK' | 'SKIP' | 'ERROR'; + readonly message?: string; +} + +interface ResolvedDoctorDeps { + readonly cwd: () => string; + readonly defaultConfigPath: () => string; + readonly defaultTuiConfigPath: () => string; + readonly stdout: WritableLike; + readonly stderr: WritableLike; + readonly exit: (code: number) => never; + readonly fileExists: (path: string) => boolean; + readonly readTextFile: (path: string) => Promise; +} + +export async function handleDoctor(deps: DoctorDeps, options: DoctorOptions): Promise { + const resolved = resolveDeps(deps); + const cwd = resolved.cwd(); + const results = await Promise.all(buildCheckSpecs(resolved, options, cwd).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 { + return { + cwd: deps?.cwd ?? (() => process.cwd()), + defaultConfigPath: deps?.defaultConfigPath ?? (() => 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')), + }; +} + +function buildCheckSpecs( + deps: ResolvedDoctorDeps, + options: DoctorOptions, + cwd: string, +): CheckSpec[] { + if (options.target === 'config') { + return [ + makeConfigSpec(resolveInputPath(options.path, deps.defaultConfigPath(), cwd), options.path !== undefined), + ]; + } + + if (options.target === 'tui') { + return [ + makeTuiSpec(resolveInputPath(options.path, deps.defaultTuiConfigPath(), cwd), options.path !== undefined), + ]; + } + + return [ + makeConfigSpec(deps.defaultConfigPath(), false), + makeTuiSpec(deps.defaultTuiConfigPath(), false), + ]; +} + +function makeConfigSpec(path: string, explicit: boolean): CheckSpec { + return { + label: 'config.toml', + path, + explicit, + parse: (text, filePath) => { + parseConfigString(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); + 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), + }; + } +} + +function resolveInputPath(input: string | undefined, defaultPath: string, cwd: string): string { + if (input === undefined) return defaultPath; + 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) { + lines.push(` ${result.message}`); + } + } + return lines; +} + +function formatErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} 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 0000000000..e9afe8067b --- /dev/null +++ b/apps/kimi-code/test/cli/doctor.test.ts @@ -0,0 +1,204 @@ +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('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'); + }); +}); diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 3766e0037a..90fb53ecfe 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 98ee7a1814..b8be01096b 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 b8d4d50e84..36fb20d4c7 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/index.ts b/packages/node-sdk/src/index.ts index deb51ef0d0..97960efcfc 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -30,6 +30,8 @@ export type { } from '#/catalog'; export { + parseConfigString, + resolveConfigPath, ErrorCodes, KimiError, type KimiErrorCode, From 5651d18b6551ab3a552f93327dba65ca78bf09a6 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 4 Jun 2026 20:29:04 +0800 Subject: [PATCH 2/4] fix(cli): format doctor validation errors --- apps/kimi-code/src/cli/sub/doctor.ts | 43 ++++++++++++++++++++++-- apps/kimi-code/test/cli/doctor.test.ts | 46 ++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/doctor.ts b/apps/kimi-code/src/cli/sub/doctor.ts index ddc4bb0b87..1907098f43 100644 --- a/apps/kimi-code/src/cli/sub/doctor.ts +++ b/apps/kimi-code/src/cli/sub/doctor.ts @@ -4,6 +4,7 @@ import { isAbsolute, resolve } from 'node:path'; import { parseConfigString, resolveConfigPath } from '@moonshot-ai/kimi-code-sdk'; import type { Command } from 'commander'; +import { z } from 'zod'; import { getTuiConfigPath, parseTuiConfig } from '#/tui/config'; @@ -180,7 +181,7 @@ async function checkTomlFile(deps: ResolvedDoctorDeps, spec: CheckSpec): Promise label: spec.label, path: spec.path, status: 'ERROR', - message: formatErrorMessage(error), + message: formatErrorMessage(error, spec.path), }; } } @@ -215,12 +216,48 @@ function formatResults(results: readonly CheckResult[]): string[] { for (const result of results) { lines.push(`${result.status} ${result.label.padEnd(12)} ${result.path}`); if (result.message !== undefined) { - lines.push(` ${result.message}`); + for (const line of result.message.split('\n')) { + lines.push(` ${line}`); + } } } return lines; } -function formatErrorMessage(error: unknown): string { +function formatErrorMessage(error: unknown, filePath: string): string { + 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 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 index e9afe8067b..fce91aea14 100644 --- a/apps/kimi-code/test/cli/doctor.test.ts +++ b/apps/kimi-code/test/cli/doctor.test.ts @@ -201,4 +201,50 @@ max_context_size = 0 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:'); + }); }); From 3c840e2f4b9f5e10393d6d936a26dabd77459822 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 4 Jun 2026 20:42:00 +0800 Subject: [PATCH 3/4] fix(cli): validate doctor config through SDK RPC --- .changeset/validate-config-doctor.md | 2 +- apps/kimi-code/src/cli/sub/doctor.ts | 111 ++++++++++++++++++++---- apps/kimi-code/test/cli/doctor.test.ts | 20 +++++ packages/node-sdk/src/config-rpc.ts | 114 +++++++++++++++++++++++++ packages/node-sdk/src/index.ts | 11 ++- packages/node-sdk/test/config.test.ts | 36 +++++++- 6 files changed, 274 insertions(+), 20 deletions(-) create mode 100644 packages/node-sdk/src/config-rpc.ts diff --git a/.changeset/validate-config-doctor.md b/.changeset/validate-config-doctor.md index 6649cb4cce..b89c28addd 100644 --- a/.changeset/validate-config-doctor.md +++ b/.changeset/validate-config-doctor.md @@ -3,4 +3,4 @@ "@moonshot-ai/kimi-code-sdk": minor --- -Add a doctor command for validating Kimi Code configuration files. +Add a doctor command for validating Kimi Code configuration files, with SDK config validation exposed through a narrow RPC wrapper. diff --git a/apps/kimi-code/src/cli/sub/doctor.ts b/apps/kimi-code/src/cli/sub/doctor.ts index 1907098f43..0ccc38d281 100644 --- a/apps/kimi-code/src/cli/sub/doctor.ts +++ b/apps/kimi-code/src/cli/sub/doctor.ts @@ -2,7 +2,11 @@ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { isAbsolute, resolve } from 'node:path'; -import { parseConfigString, resolveConfigPath } from '@moonshot-ai/kimi-code-sdk'; +import { + createKimiConfigRpc, + type KimiConfigRpc, + type KimiConfigValidationIssue, +} from '@moonshot-ai/kimi-code-sdk'; import type { Command } from 'commander'; import { z } from 'zod'; @@ -12,15 +16,19 @@ interface WritableLike { write(chunk: string): boolean; } +type MaybePromise = T | Promise; + export interface DoctorDeps { readonly cwd: () => string; - readonly defaultConfigPath: () => 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 { @@ -32,7 +40,7 @@ interface CheckSpec { readonly label: 'config.toml' | 'tui.toml'; readonly path: string; readonly explicit: boolean; - readonly parse: (text: string, path: string) => void; + readonly parse: (text: string, path: string) => MaybePromise; } interface CheckResult { @@ -44,19 +52,21 @@ interface CheckResult { interface ResolvedDoctorDeps { readonly cwd: () => string; - readonly defaultConfigPath: () => 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 results = await Promise.all(buildCheckSpecs(resolved, options, cwd).map((spec) => checkTomlFile(resolved, spec))); + 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); @@ -103,48 +113,68 @@ async function runDoctorCommand( } 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 ?? (() => resolveConfigPath({})), + 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 })), }; } -function buildCheckSpecs( +async function buildCheckSpecs( deps: ResolvedDoctorDeps, options: DoctorOptions, cwd: string, -): CheckSpec[] { +): Promise { if (options.target === 'config') { return [ - makeConfigSpec(resolveInputPath(options.path, deps.defaultConfigPath(), cwd), options.path !== undefined), + makeConfigSpec( + await resolveConfigTargetPath(deps, options.path, cwd), + options.path !== undefined, + deps, + ), ]; } if (options.target === 'tui') { return [ - makeTuiSpec(resolveInputPath(options.path, deps.defaultTuiConfigPath(), cwd), options.path !== undefined), + makeTuiSpec( + resolveTuiTargetPath(deps, options.path, cwd), + options.path !== undefined, + ), ]; } return [ - makeConfigSpec(deps.defaultConfigPath(), false), + makeConfigSpec(await deps.defaultConfigPath(), false, deps), makeTuiSpec(deps.defaultTuiConfigPath(), false), ]; } -function makeConfigSpec(path: string, explicit: boolean): CheckSpec { +function makeConfigSpec( + path: string, + explicit: boolean, + deps: ResolvedDoctorDeps, +): CheckSpec { return { label: 'config.toml', path, explicit, parse: (text, filePath) => { - parseConfigString(text, filePath); + return deps.validateConfigToml(text, filePath); }, }; } @@ -174,7 +204,7 @@ async function checkTomlFile(deps: ResolvedDoctorDeps, spec: CheckSpec): Promise try { const text = await deps.readTextFile(spec.path); - spec.parse(text, spec.path); + await spec.parse(text, spec.path); return { label: spec.label, path: spec.path, status: 'OK' }; } catch (error) { return { @@ -186,8 +216,23 @@ async function checkTomlFile(deps: ResolvedDoctorDeps, spec: CheckSpec): Promise } } -function resolveInputPath(input: string | undefined, defaultPath: string, cwd: string): string { - if (input === undefined) return defaultPath; +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); } @@ -225,6 +270,15 @@ function formatResults(results: readonly CheckResult[]): string[] { } 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 [ @@ -236,6 +290,31 @@ function formatErrorMessage(error: unknown, filePath: string): string { 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; diff --git a/apps/kimi-code/test/cli/doctor.test.ts b/apps/kimi-code/test/cli/doctor.test.ts index fce91aea14..b6404c97dc 100644 --- a/apps/kimi-code/test/cli/doctor.test.ts +++ b/apps/kimi-code/test/cli/doctor.test.ts @@ -156,6 +156,26 @@ describe('kimi doctor', () => { 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); diff --git a/packages/node-sdk/src/config-rpc.ts b/packages/node-sdk/src/config-rpc.ts new file mode 100644 index 0000000000..8b0bf473f3 --- /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 97960efcfc..840c738bd0 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'; @@ -30,8 +39,6 @@ export type { } from '#/catalog'; export { - parseConfigString, - resolveConfigPath, ErrorCodes, KimiError, type KimiErrorCode, diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index 6dc6e0a55d..20129f1712 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'); From 1625ac5982da40b10fc9426f7abf2a90ff553da5 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 4 Jun 2026 21:11:54 +0800 Subject: [PATCH 4/4] chore(changeset): simplify doctor release note --- .changeset/validate-config-doctor.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/validate-config-doctor.md b/.changeset/validate-config-doctor.md index b89c28addd..6649cb4cce 100644 --- a/.changeset/validate-config-doctor.md +++ b/.changeset/validate-config-doctor.md @@ -3,4 +3,4 @@ "@moonshot-ai/kimi-code-sdk": minor --- -Add a doctor command for validating Kimi Code configuration files, with SDK config validation exposed through a narrow RPC wrapper. +Add a doctor command for validating Kimi Code configuration files.