ladder-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { z } from 'zod';
5
+ import { getKimiStatus } from './environment.js';
6
+ import { runKimiApi, isApiConfigured } from './kimi-api.js';
7
+ import { runKimi } from './kimi-runner.js';
8
+ import { listSessions } from './session-store.js';
9
+ import { cancelAcpSession, listAcpSessions, runAcpPrompt } from './transports/acp.js';
10
+ import { exportKimiSession, getKimiCapabilities, listKimiProviders, runKimiDoctor, visualizeSession, } from './transports/cli-admin.js';
11
+ import { taskStore } from './task-store.js';
12
+ import { generateMcpConfig } from './kimi-mcp-config.js';
13
+ import { buildBudgetProbeGuide, getDesktopStatus } from './desktop-work.js';
14
+ const server = new McpServer({
15
+ name: 'kimi-code',
16
+ version: '1.0.0',
17
+ });
18
+ const DEFAULT_MAX_OUTPUT_CHARS = 60_000;
19
+ const FORMAT_INSTRUCTIONS = {
20
+ summary: `
21
+ OUTPUT FORMAT CONSTRAINTS:
22
+ - Maximum ~2000 words. Be extremely concise.
23
+ - Use bullet points, not paragraphs.
24
+ - List file paths and one-line descriptions only.
25
+ - No code snippets.
26
+ - Structure: ## Overview -> ## Key Findings -> ## File Index`,
27
+ normal: `
28
+ OUTPUT FORMAT CONSTRAINTS:
29
+ - Maximum ~5000 words. Be concise but thorough.
30
+ - Use structured sections with markdown headers.
31
+ - Include function/class signatures, not full implementations.
32
+ - Reference file paths and line numbers when useful.
33
+ - Structure: ## Overview -> ## Architecture -> ## Key Findings -> ## File Details`,
34
+ detailed: `
35
+ OUTPUT FORMAT CONSTRAINTS:
36
+ - Maximum ~15000 words.
37
+ - Include relevant snippets only when they materially help.
38
+ - Explain dependency relationships and data flow.`,
39
+ };
40
+ const AI_CONSUMER_NOTICE = `
41
+ IMPORTANT: Your response will be consumed by another AI model with limited context. Prioritize density, concrete file references, and structured markdown.`;
42
+ function wrapPrompt(prompt, detailLevel) {
43
+ return `${prompt}\n${FORMAT_INSTRUCTIONS[detailLevel]}\n${AI_CONSUMER_NOTICE}`;
44
+ }
45
+ function maxChars(maxOutputTokens) {
46
+ return maxOutputTokens ? maxOutputTokens * 4 : DEFAULT_MAX_OUTPUT_CHARS;
47
+ }
48
+ function textResponse(text, isError = false) {
49
+ return { content: [{ type: 'text', text }], isError };
50
+ }
51
+ function buildResponse(text, thinking, includeThinking) {
52
+ return thinking && includeThinking ? `<kimi-thinking>\n${thinking}\n</kimi-thinking>\n\n${text}` : text;
53
+ }
54
+ server.tool('kimi_analyze', 'Send a prompt to Kimi Code for codebase analysis. Defaults to native continuation for the working directory; set new_session to true for a fresh session.', {
55
+ prompt: z.string().describe('The analysis prompt for Kimi.'),
56
+ work_dir: z.string().describe('Absolute path to the codebase root directory.'),
57
+ session_id: z.string().optional().describe('Explicit Kimi session id to resume via -S.'),
58
+ new_session: z.boolean().optional().describe('Start fresh without -C or -S. Default: false.'),
59
+ detail_level: z.enum(['summary', 'normal', 'detailed']).optional(),
60
+ max_output_tokens: z.number().optional(),
61
+ include_thinking: z.boolean().optional(),
62
+ }, async ({ prompt, work_dir, session_id, new_session, detail_level, max_output_tokens, include_thinking }) => {
63
+ const result = await runKimi({
64
+ prompt: wrapPrompt(prompt, detail_level ?? 'normal'),
65
+ workDir: work_dir,
66
+ sessionId: session_id,
67
+ continueLast: !session_id && new_session !== true,
68
+ timeoutMs: 600_000,
69
+ maxOutputChars: maxChars(max_output_tokens),
70
+ includeThinking: include_thinking ?? false,
71
+ });
72
+ if (!result.ok)
73
+ return textResponse(`Error: ${result.error}`, true);
74
+ const sessionLine = result.sessionId ? `\n\nSession: ${result.sessionId}` : '';
75
+ return textResponse(`${buildResponse(result.text, result.thinking, include_thinking ?? false)}${sessionLine}`);
76
+ });
77
+ server.tool('kimi_query', 'Ask Kimi Code a contextless programming question through the Kimi Code API.', {
78
+ prompt: z.string().describe('The question to ask Kimi.'),
79
+ max_output_tokens: z.number().optional(),
80
+ include_thinking: z.boolean().optional(),
81
+ }, async ({ prompt, max_output_tokens, include_thinking }) => {
82
+ const result = await runKimiApi({ prompt, timeoutMs: 120_000, maxOutputChars: maxChars(max_output_tokens) });
83
+ if (!result.ok)
84
+ return textResponse(`Error: ${result.error}`, true);
85
+ return textResponse(buildResponse(result.text, result.thinking, include_thinking ?? false));
86
+ });
87
+ server.tool('kimi_verify', 'Get an independent second opinion from Kimi Code through the Kimi Code API.', {
88
+ context: z.string().describe('Self-contained material for Kimi to examine.'),
89
+ question: z.string().optional().describe('What to verify or focus on.'),
90
+ role: z.string().optional().describe('Override the reviewer persona.'),
91
+ max_output_tokens: z.number().optional(),
92
+ include_thinking: z.boolean().optional(),
93
+ }, async ({ context, question, role, max_output_tokens, include_thinking }) => {
94
+ const focus = question?.trim() || 'Independently verify correctness. Surface bugs, edge cases, security issues, and concrete improvements.';
95
+ const system = role?.trim() || 'You are a meticulous, independent senior engineer. Be skeptical, specific, and actionable.';
96
+ const prompt = `## Your task\n${focus}\n\n## Material to verify\n${context}\n${AI_CONSUMER_NOTICE}`;
97
+ const result = await runKimiApi({ prompt, system, timeoutMs: 300_000, maxOutputChars: maxChars(max_output_tokens) });
98
+ if (!result.ok)
99
+ return textResponse(`Error: ${result.error}`, true);
100
+ return textResponse(buildResponse(result.text, result.thinking, include_thinking ?? false));
101
+ });
102
+ server.tool('kimi_resume', 'Resume an explicit Kimi Code session with a new prompt via -S.', {
103
+ session_id: z.string().describe('Session ID to resume.'),
104
+ prompt: z.string().describe('New prompt to send.'),
105
+ work_dir: z.string().describe('Working directory for the session.'),
106
+ detail_level: z.enum(['summary', 'normal', 'detailed']).optional(),
107
+ max_output_tokens: z.number().optional(),
108
+ include_thinking: z.boolean().optional(),
109
+ }, async ({ session_id, prompt, work_dir, detail_level, max_output_tokens, include_thinking }) => {
110
+ const result = await runKimi({
111
+ prompt: wrapPrompt(prompt, detail_level ?? 'normal'),
112
+ workDir: work_dir,
113
+ sessionId: session_id,
114
+ timeoutMs: 600_000,
115
+ maxOutputChars: maxChars(max_output_tokens),
116
+ includeThinking: include_thinking ?? false,
117
+ });
118
+ if (!result.ok)
119
+ return textResponse(`Error: ${result.error}`, true);
120
+ return textResponse(buildResponse(result.text, result.thinking, include_thinking ?? false));
121
+ });
122
+ server.tool('kimi_list_sessions', 'List existing Kimi Code sessions with ids, titles, working directories, and timestamps.', {
123
+ work_dir: z.string().optional().describe('Filter sessions by working directory path.'),
124
+ limit: z.number().optional().describe('Max sessions to return. Default: 20.'),
125
+ }, async ({ work_dir, limit }) => {
126
+ const sessions = listSessions({ workDir: work_dir, limit: limit ?? 20 });
127
+ return textResponse(sessions.length ? JSON.stringify(sessions, null, 2) : 'No Kimi sessions found.');
128
+ });
129
+ server.tool('kimi_status', 'Check Kimi CLI installation, version, authentication, catalog, and API availability.', {}, async () => {
130
+ const status = await getKimiStatus();
131
+ const lines = [
132
+ '## Kimi CLI Status',
133
+ `- Installed: ${status.installed ? 'Yes' : 'No'}`,
134
+ `- Binary: ${status.binPath ? `\`${status.binPath}\`` : 'Not found'}`,
135
+ `- Version: ${status.version ?? '(unable to detect)'}`,
136
+ `- Authenticated: ${status.authenticated ? 'Yes' : 'No'}`,
137
+ `- Catalog Found: ${status.catalogFound ? 'Yes' : 'No'}`,
138
+ `- Catalog: \`${status.catalogPath}\``,
139
+ `- Credentials Found: ${status.credentialsFound ? 'Yes' : 'No'}`,
140
+ `- Config Found: ${status.configFound ? 'Yes' : 'No'}`,
141
+ '',
142
+ '## Kimi Code API',
143
+ `- Configured: ${isApiConfigured() ? 'Yes' : 'No'}`,
144
+ ];
145
+ if (status.error)
146
+ lines.push('', `Action required: ${status.error}`);
147
+ return textResponse(lines.join('\n'), !status.installed && !status.apiConfigured);
148
+ });
149
+ server.tool('kimi_capabilities', 'Report Kimi CLI, admin command, ACP, and experimental desktop-read-only capabilities.', {}, async () => textResponse(JSON.stringify(await getKimiCapabilities(), null, 2)));
150
+ server.tool('kimi_doctor', 'Run Kimi Code configuration doctor for config.toml or tui.toml.', {
151
+ target: z.enum(['config', 'tui']).optional(),
152
+ path: z.string().optional().describe('Optional config/tui path to validate.'),
153
+ }, async ({ target, path }) => {
154
+ const result = await runKimiDoctor(target ?? 'config', path);
155
+ return textResponse(JSON.stringify(result, null, 2), !result.ok);
156
+ });
157
+ server.tool('kimi_provider_list', 'List configured Kimi providers and models using `kimi provider list --json`.', {}, async () => textResponse(JSON.stringify(await listKimiProviders(), null, 2)));
158
+ server.tool('kimi_export_session', 'Export a Kimi session ZIP. Requires explicit output_path and excludes the global diagnostic log by default.', {
159
+ output_path: z.string().describe('Explicit ZIP output path.'),
160
+ session_id: z.string().optional().describe('Session id to export; defaults to most recent Kimi session.'),
161
+ include_global_log: z.boolean().optional().describe('Include active global diagnostic log. Default: false.'),
162
+ overwrite_existing: z.boolean().optional().describe('Overwrite output_path if it already exists. Default: false.'),
163
+ }, async ({ output_path, session_id, include_global_log, overwrite_existing }) => {
164
+ const result = await exportKimiSession({
165
+ outputPath: output_path,
166
+ sessionId: session_id,
167
+ includeGlobalLog: include_global_log ?? false,
168
+ overwriteExisting: overwrite_existing ?? false,
169
+ });
170
+ return textResponse(JSON.stringify(result, null, 2), !result.ok);
171
+ });
172
+ server.tool('kimi_visualize_session', 'Preview or launch Kimi session visualizer on localhost using `kimi vis --no-open`.', {
173
+ session_id: z.string().optional(),
174
+ host: z.enum(['127.0.0.1', 'localhost', '::1']).optional().describe('Localhost host to bind. Default: 127.0.0.1.'),
175
+ port: z.number().int().min(1).max(65535).optional().describe('Port to bind. Default: 58628.'),
176
+ launch: z.boolean().optional().describe('Actually start the visualizer process. Default: false.'),
177
+ }, async ({ session_id, host, port, launch }) => {
178
+ try {
179
+ return textResponse(JSON.stringify(visualizeSession({ sessionId: session_id, host, port, launch }), null, 2));
180
+ }
181
+ catch (error) {
182
+ return textResponse(`Error: ${error instanceof Error ? error.message : String(error)}`, true);
183
+ }
184
+ });
185
+ server.tool('kimi_chat', 'Send a prompt through Kimi ACP over stdio. Set background=true for long-running work tracked by task tools.', {
186
+ prompt: z.string(),
187
+ work_dir: z.string().optional(),
188
+ session_id: z.string().optional(),
189
+ session_mode: z.enum(['new', 'load', 'resume']).optional(),
190
+ background: z.boolean().optional(),
191
+ timeout_ms: z.number().int().positive().optional(),
192
+ }, async ({ prompt, work_dir, session_id, session_mode, background, timeout_ms }) => {
193
+ if (background) {
194
+ const task = taskStore.create('acp-chat', async (signal) => {
195
+ const result = await runAcpPrompt({
196
+ prompt,
197
+ workDir: work_dir,
198
+ sessionId: session_id,
199
+ sessionMode: session_mode ?? (session_id ? 'resume' : 'new'),
200
+ timeoutMs: timeout_ms,
201
+ signal,
202
+ });
203
+ if (!result.ok)
204
+ throw new Error(result.error ?? 'ACP chat failed');
205
+ const metadata = { ...(result.metadata ?? {}) };
206
+ if (!('sessionId' in metadata)) {
207
+ metadata.sessionId = result.sessionId;
208
+ }
209
+ return { output: result.text, metadata };
210
+ });
211
+ return textResponse(JSON.stringify(task, null, 2));
212
+ }
213
+ const result = await runAcpPrompt({
214
+ prompt,
215
+ workDir: work_dir,
216
+ sessionId: session_id,
217
+ sessionMode: session_mode ?? (session_id ? 'resume' : 'new'),
218
+ timeoutMs: timeout_ms,
219
+ });
220
+ return textResponse(JSON.stringify(result, null, 2), !result.ok);
221
+ });
222
+ server.tool('kimi_acp_sessions', 'List Kimi ACP sessions through `session/list`.', {}, async () => {
223
+ const result = await listAcpSessions();
224
+ return textResponse(result.ok ? result.text : `Error: ${result.error}`, !result.ok);
225
+ });
226
+ server.tool('kimi_cancel', 'Cancel an active Ladder task by task_id or a Kimi ACP session by session_id.', {
227
+ task_id: z.string().optional(),
228
+ session_id: z.string().optional(),
229
+ }, async ({ task_id, session_id }) => {
230
+ // Require exactly one target. Previously, passing both silently cancelled the task
231
+ // and ignored session_id, hiding the ambiguity from the caller.
232
+ if (task_id && session_id) {
233
+ return textResponse('Provide either task_id or session_id, not both.', true);
234
+ }
235
+ if (task_id) {
236
+ const task = await taskStore.cancel(task_id);
237
+ return textResponse(task ? JSON.stringify(task, null, 2) : `Task not found: ${task_id}`, !task);
238
+ }
239
+ if (session_id) {
240
+ const result = await cancelAcpSession(session_id);
241
+ return textResponse(JSON.stringify(result, null, 2), !result.ok);
242
+ }
243
+ return textResponse('Provide task_id or session_id.', true);
244
+ });
245
+ server.tool('kimi_task_status', 'Return status for one background task or all recent in-process tasks.', {
246
+ task_id: z.string().optional(),
247
+ }, async ({ task_id }) => {
248
+ if (task_id) {
249
+ const task = taskStore.get(task_id);
250
+ return textResponse(task ? JSON.stringify(task, null, 2) : `Task not found: ${task_id}`, !task);
251
+ }
252
+ return textResponse(JSON.stringify(taskStore.list(), null, 2));
253
+ });
254
+ server.tool('kimi_task_output', 'Return accumulated output and error for a background task.', {
255
+ task_id: z.string(),
256
+ }, async ({ task_id }) => {
257
+ const task = taskStore.get(task_id);
258
+ return textResponse(task ? JSON.stringify({ id: task.id, status: task.status, output: task.output, outputChunks: task.outputChunks, error: task.error }, null, 2) : `Task not found: ${task_id}`, !task);
259
+ });
260
+ server.tool('kimi_task_cancel', 'Cancel a pending or running background task.', {
261
+ task_id: z.string(),
262
+ }, async ({ task_id }) => {
263
+ const task = await taskStore.cancel(task_id);
264
+ return textResponse(task ? JSON.stringify(task, null, 2) : `Task not found: ${task_id}`, !task);
265
+ });
266
+ server.tool('kimi_generate_mcp_config', 'Generate or write a Kimi Code .kimi-code/mcp.json entry that lets Kimi host Ladder_mcp as an MCP server.', {
267
+ scope: z.enum(['project', 'user']).optional(),
268
+ project_dir: z.string().optional(),
269
+ server_name: z.string().optional(),
270
+ write: z.boolean().optional().describe('When false/omitted, only preview the merged config.'),
271
+ command: z.string().optional(),
272
+ args: z.array(z.string()).optional(),
273
+ }, async ({ scope, project_dir, server_name, write, command, args }) => {
274
+ const result = generateMcpConfig({
275
+ scope,
276
+ projectDir: project_dir,
277
+ serverName: server_name,
278
+ write: write ?? false,
279
+ command,
280
+ args,
281
+ });
282
+ return textResponse(JSON.stringify(result, null, 2));
283
+ });
284
+ server.tool('kimi_desktop_status', 'Experimental read-only Kimi Desktop Work status probe. Does not read token stores or replay web auth.', {}, async () => textResponse(JSON.stringify(await getDesktopStatus(), null, 2)));
285
+ server.tool('kimi_budget_probe', 'Experimental guided budget-separation evidence workflow. Does not submit desktop Work tasks.', {
286
+ include_cli_probe_note: z.boolean().optional(),
287
+ }, async ({ include_cli_probe_note }) => textResponse(buildBudgetProbeGuide(include_cli_probe_note ?? false)));
288
+ const transport = new StdioServerTransport();
289
+ await server.connect(transport);
@@ -0,0 +1,72 @@
1
+ import { loadApiAuth } from './environment.js';
2
+ import { truncateAtBoundary } from './kimi-runner.js';
3
+ const KIMI_USER_AGENT = 'KimiCLI/1.0';
4
+ const DEFAULT_MODEL = 'kimi-for-coding';
5
+ export function isApiConfigured() {
6
+ return loadApiAuth() !== null;
7
+ }
8
+ export async function runKimiApi(config) {
9
+ const auth = loadApiAuth();
10
+ if (!auth) {
11
+ return {
12
+ ok: false,
13
+ text: '',
14
+ error: 'Kimi Code API key not found. Set KIMICODE_API_KEY or add api_key to ~/.kimi-code/config.toml.',
15
+ };
16
+ }
17
+ const controller = new AbortController();
18
+ const timeoutMs = config.timeoutMs ?? 300_000;
19
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
20
+ const maxOutputChars = config.maxOutputChars ?? 60_000;
21
+ const maxTokens = Math.ceil(maxOutputChars / 4) + 4000;
22
+ const messages = [];
23
+ if (config.system)
24
+ messages.push({ role: 'system', content: config.system });
25
+ messages.push({ role: 'user', content: config.prompt });
26
+ try {
27
+ const response = await fetch(`${auth.baseUrl}/chat/completions`, {
28
+ method: 'POST',
29
+ signal: controller.signal,
30
+ headers: {
31
+ Authorization: `Bearer ${auth.apiKey}`,
32
+ 'Content-Type': 'application/json',
33
+ 'User-Agent': KIMI_USER_AGENT,
34
+ },
35
+ body: JSON.stringify({
36
+ model: config.model ?? DEFAULT_MODEL,
37
+ messages,
38
+ temperature: 1,
39
+ max_tokens: maxTokens,
40
+ }),
41
+ });
42
+ const bodyText = await response.text();
43
+ if (!response.ok) {
44
+ let message = bodyText.slice(0, 400);
45
+ try {
46
+ const parsed = JSON.parse(bodyText);
47
+ message = parsed.error?.message ?? message;
48
+ }
49
+ catch {
50
+ // Keep raw body slice.
51
+ }
52
+ return { ok: false, text: '', error: `Kimi API HTTP ${response.status}: ${message}` };
53
+ }
54
+ const data = JSON.parse(bodyText);
55
+ const message = data.choices?.[0]?.message;
56
+ const text = message?.content?.trim() ?? '';
57
+ const thinking = message?.reasoning_content;
58
+ if (!text) {
59
+ return { ok: false, text: '', thinking, error: 'Kimi API returned an empty answer.' };
60
+ }
61
+ return { ok: true, text: truncateAtBoundary(text, maxOutputChars), thinking };
62
+ }
63
+ catch (error) {
64
+ if (error instanceof Error && error.name === 'AbortError') {
65
+ return { ok: false, text: '', error: `Kimi API timed out after ${Math.round(timeoutMs / 1000)}s` };
66
+ }
67
+ return { ok: false, text: '', error: `Kimi API network error: ${error instanceof Error ? error.message : String(error)}` };
68
+ }
69
+ finally {
70
+ clearTimeout(timer);
71
+ }
72
+ }
@@ -0,0 +1,85 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { getWindowsHome } from './environment.js';
5
+ function defaultServerCommand() {
6
+ const distIndex = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'index.js');
7
+ return { command: 'node', args: [distIndex] };
8
+ }
9
+ export function resolveMcpConfigPath(options = {}) {
10
+ if (options.scope === 'user') {
11
+ return path.join(getWindowsHome(), '.kimi-code', 'mcp.json');
12
+ }
13
+ const projectDir = options.projectDir ? path.resolve(options.projectDir) : process.cwd();
14
+ return path.join(projectDir, '.kimi-code', 'mcp.json');
15
+ }
16
+ function readExistingConfig(configPath) {
17
+ let raw;
18
+ try {
19
+ raw = fs.readFileSync(configPath, 'utf-8');
20
+ }
21
+ catch (error) {
22
+ if (error.code === 'ENOENT') {
23
+ return {};
24
+ }
25
+ throw new Error(`Existing MCP config could not be read and was not modified: ${error instanceof Error ? error.message : String(error)}`);
26
+ }
27
+ try {
28
+ return JSON.parse(raw);
29
+ }
30
+ catch (error) {
31
+ throw new Error(`Existing MCP config is not valid JSON and was not modified: ${error instanceof Error ? error.message : String(error)}`);
32
+ }
33
+ }
34
+ function assertWritableProjectTarget(projectDir) {
35
+ let resolved;
36
+ try {
37
+ resolved = fs.realpathSync(projectDir);
38
+ }
39
+ catch {
40
+ resolved = path.resolve(projectDir);
41
+ }
42
+ const parts = resolved.split(path.sep).map((part) => part.toLowerCase());
43
+ if (parts.includes('kimi-code-mcp')) {
44
+ throw new Error('Refusing to write MCP config under read-only kimi-code-mcp reference tree.');
45
+ }
46
+ }
47
+ function readMcpServers(existing) {
48
+ const value = existing.mcpServers;
49
+ if (value === undefined || value === null)
50
+ return {};
51
+ if (typeof value === 'object' && !Array.isArray(value)) {
52
+ return value;
53
+ }
54
+ throw new Error('Existing mcpServers entry is not a valid object and was not modified.');
55
+ }
56
+ export function generateMcpConfig(options = {}) {
57
+ const configPath = resolveMcpConfigPath(options);
58
+ // Only enforce the read-only-tree guard when we are actually going to write. A
59
+ // dry-run/preview (`write` falsy) produces no file, so it must not throw merely
60
+ // because the cwd happens to sit under a `kimi-code-mcp` reference tree.
61
+ if (options.write && options.scope !== 'user') {
62
+ assertWritableProjectTarget(options.projectDir ?? process.cwd());
63
+ }
64
+ const serverName = options.serverName?.trim() || 'ladder_mcp';
65
+ if (!/^[a-zA-Z0-9_-]+$/.test(serverName)) {
66
+ throw new Error('server_name must contain only letters, digits, underscores, and hyphens.');
67
+ }
68
+ const defaults = defaultServerCommand();
69
+ const serverConfig = {
70
+ command: options.command ?? defaults.command,
71
+ args: options.args ?? defaults.args,
72
+ env: {},
73
+ };
74
+ const existing = readExistingConfig(configPath);
75
+ const mcpServers = {
76
+ ...readMcpServers(existing),
77
+ [serverName]: serverConfig,
78
+ };
79
+ const config = { ...existing, mcpServers };
80
+ if (options.write) {
81
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
82
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
83
+ }
84
+ return { path: configPath, serverName, config, wrote: options.write === true };
85
+ }
@@ -0,0 +1,142 @@
1
+ import { spawn } from 'node:child_process';
2
+ import * as path from 'node:path';
3
+ import { buildKimiEnv, resolveKimiPaths } from './environment.js';
4
+ export function buildKimiArgs(config) {
5
+ const args = ['-p', config.prompt, '--output-format', 'stream-json'];
6
+ if (config.sessionId) {
7
+ args.push('-S', config.sessionId);
8
+ }
9
+ else if (config.continueLast) {
10
+ args.push('-C');
11
+ }
12
+ return args;
13
+ }
14
+ function contentToText(content) {
15
+ if (typeof content === 'string')
16
+ return content;
17
+ if (!Array.isArray(content))
18
+ return '';
19
+ return content.map((part) => {
20
+ if (typeof part === 'string')
21
+ return part;
22
+ if (part && typeof part === 'object') {
23
+ const value = part;
24
+ if (typeof value.text === 'string')
25
+ return value.text;
26
+ if (typeof value.content === 'string')
27
+ return value.content;
28
+ }
29
+ return '';
30
+ }).join('');
31
+ }
32
+ function extractResumeHint(record) {
33
+ if (record.role !== 'meta' || record.type !== 'session.resume_hint')
34
+ return undefined;
35
+ for (const key of ['session_id', 'sessionId', 'id', 'session']) {
36
+ const value = record[key];
37
+ if (typeof value === 'string' && value.trim())
38
+ return value.trim();
39
+ }
40
+ if (record.content && typeof record.content === 'object') {
41
+ const content = record.content;
42
+ for (const key of ['session_id', 'sessionId', 'id', 'session']) {
43
+ const value = content[key];
44
+ if (typeof value === 'string' && value.trim())
45
+ return value.trim();
46
+ }
47
+ }
48
+ return undefined;
49
+ }
50
+ export function parseKimiStreamJson(stdout) {
51
+ let lastAssistant = '';
52
+ let sessionId;
53
+ for (const rawLine of stdout.split(/\r?\n/)) {
54
+ const line = rawLine.trim();
55
+ if (!line.startsWith('{'))
56
+ continue;
57
+ try {
58
+ const record = JSON.parse(line);
59
+ if (record.role === 'assistant') {
60
+ const text = contentToText(record.content);
61
+ if (text.trim())
62
+ lastAssistant = text;
63
+ }
64
+ sessionId = extractResumeHint(record) ?? sessionId;
65
+ }
66
+ catch {
67
+ // Ignore non-JSON status lines.
68
+ }
69
+ }
70
+ return {
71
+ text: lastAssistant.trim() || '(empty response from Kimi)',
72
+ sessionId,
73
+ };
74
+ }
75
+ export function truncateAtBoundary(text, maxChars) {
76
+ if (text.length <= maxChars)
77
+ return text;
78
+ const slice = text.slice(0, maxChars);
79
+ const cutPoint = Math.max(slice.lastIndexOf('\n## '), slice.lastIndexOf('\n\n'), Math.floor(maxChars * 0.8));
80
+ return `${slice.slice(0, cutPoint).trimEnd()}\n\n---\nOutput truncated (${text.length.toLocaleString()} chars exceeded ${maxChars.toLocaleString()} char budget). Use kimi_resume with the same session for follow-up questions.`;
81
+ }
82
+ function killProcessTree(pid) {
83
+ if (!pid)
84
+ return;
85
+ const killer = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], {
86
+ stdio: 'ignore',
87
+ windowsHide: true,
88
+ });
89
+ killer.on('error', () => undefined);
90
+ }
91
+ export function runKimi(config) {
92
+ const timeoutMs = config.timeoutMs ?? 300_000;
93
+ const maxOutputChars = config.maxOutputChars ?? 60_000;
94
+ const paths = resolveKimiPaths();
95
+ if (!paths.binaryPath) {
96
+ return Promise.resolve({
97
+ ok: false,
98
+ text: '',
99
+ error: 'Kimi CLI binary was not found on PATH or at ~/.kimi-code/bin/kimi.exe.',
100
+ });
101
+ }
102
+ return new Promise((resolve) => {
103
+ let settled = false;
104
+ const proc = spawn(paths.binaryPath, buildKimiArgs(config), {
105
+ env: buildKimiEnv(),
106
+ stdio: ['ignore', 'pipe', 'pipe'],
107
+ windowsHide: true,
108
+ cwd: config.workDir ? path.resolve(config.workDir) : undefined,
109
+ });
110
+ let stdout = '';
111
+ let stderr = '';
112
+ const finish = (result) => {
113
+ if (settled)
114
+ return;
115
+ settled = true;
116
+ clearTimeout(timer);
117
+ resolve(result);
118
+ };
119
+ const timer = setTimeout(() => {
120
+ killProcessTree(proc.pid);
121
+ finish({ ok: false, text: '', error: `Kimi timed out after ${Math.round(timeoutMs / 1000)}s` });
122
+ }, timeoutMs);
123
+ proc.stdout?.on('data', (chunk) => { stdout += chunk.toString('utf-8'); });
124
+ proc.stderr?.on('data', (chunk) => { stderr += chunk.toString('utf-8'); });
125
+ proc.on('error', (err) => {
126
+ finish({ ok: false, text: '', error: err instanceof Error ? err.message : String(err) });
127
+ });
128
+ proc.on('close', (code) => {
129
+ if (settled)
130
+ return;
131
+ if (code !== 0) {
132
+ const message = stderr.trim() || stdout.trim() || `kimi exited with code ${code}`;
133
+ finish({ ok: false, text: '', error: message });
134
+ return;
135
+ }
136
+ const parsed = parseKimiStreamJson(stdout);
137
+ const text = truncateAtBoundary(parsed.text, maxOutputChars);
138
+ const thinking = config.includeThinking ? stderr.trim() || undefined : undefined;
139
+ finish({ ok: true, text, thinking, sessionId: parsed.sessionId });
140
+ });
141
+ });
142
+ }