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
This commit is contained in:
liruifengv 2026-06-04 21:15:18 +08:00 committed by GitHub
parent beb12ac021
commit 6a4e4c75d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 843 additions and 4 deletions

View file

@ -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.

View file

@ -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')

View file

@ -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> = T | Promise<T>;
export interface DoctorDeps {
readonly cwd: () => string;
readonly defaultConfigPath: () => MaybePromise<string>;
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<string>;
readonly validateConfigToml?: (text: string, path: string) => MaybePromise<void>;
}
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<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: () => MaybePromise<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<string>;
readonly validateConfigToml: (text: string, path: string) => MaybePromise<void>;
}
export async function handleDoctor(deps: DoctorDeps, options: DoctorOptions): Promise<number> {
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<DoctorDeps>): 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<DoctorDeps> | undefined,
options: DoctorOptions,
): Promise<void> {
const resolved = resolveDeps(deps);
const code = await handleDoctor(resolved, options);
if (code !== 0) resolved.exit(code);
}
function resolveDeps(deps: Partial<DoctorDeps> | 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<CheckSpec[]> {
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<CheckResult> {
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<string> {
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<string, unknown> {
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 '<root>';
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()}`);
}

View file

@ -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<void> {
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<void> {
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:');
});
});

View file

@ -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',
]);
});
});

View file

@ -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.

View file

@ -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 文件,便于分享、归档或提交问题反馈。

View file

@ -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<string>;
validateConfigToml(input: ValidateKimiConfigTomlInput): Promise<void>;
}
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<RPCMethods<KimiConfigCoreRpc>>;
constructor() {
const [coreRpc, clientRpc] = createRPC<KimiConfigCoreRpc, KimiConfigClientRpc>();
void coreRpc(new KimiConfigCoreRpcImpl());
this.ready = clientRpc({});
}
async resolveConfigPath(input: ResolveKimiConfigPathInput = {}): Promise<string> {
const rpc = await this.ready;
return rpc.resolveConfigPath(input);
}
async validateConfigToml(input: ValidateKimiConfigTomlInput): Promise<void> {
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;
}

View file

@ -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';

View file

@ -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');