mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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:
parent
beb12ac021
commit
6a4e4c75d4
10 changed files with 843 additions and 4 deletions
6
.changeset/validate-config-doctor.md
Normal file
6
.changeset/validate-config-doctor.md
Normal 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.
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
342
apps/kimi-code/src/cli/sub/doctor.ts
Normal file
342
apps/kimi-code/src/cli/sub/doctor.ts
Normal 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()}`);
|
||||
}
|
||||
270
apps/kimi-code/test/cli/doctor.test.ts
Normal file
270
apps/kimi-code/test/cli/doctor.test.ts
Normal 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:');
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 文件,便于分享、归档或提交问题反馈。
|
||||
|
|
|
|||
114
packages/node-sdk/src/config-rpc.ts
Normal file
114
packages/node-sdk/src/config-rpc.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue