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.
@@ -0,0 +1,415 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+ import { buildKimiEnv, resolveKimiPaths } from '../environment.js';
4
+ const MAX_ACP_FRAME_BYTES = 8 * 1024 * 1024;
5
+ const MAX_ACP_HEADER_BYTES = 16 * 1024;
6
+ const MAX_ACP_METADATA_BYTES = 100 * 1024;
7
+ const MAX_ACP_UPDATE_CHUNKS = 10_000;
8
+ const DEFAULT_ACP_TIMEOUT_MS = 120_000;
9
+ const CONTENT_LENGTH_PREFIX_BYTES = 'Content-Length:'.length;
10
+ // Coerce an untrusted timeout into a usable positive duration. A zero/negative/NaN
11
+ // value would otherwise make setTimeout fire (effectively) immediately and abort the
12
+ // request before the peer can reply; fall back to a sane default in that case.
13
+ export function clampTimeout(ms, fallback) {
14
+ return typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? ms : fallback;
15
+ }
16
+ // Locate the end of a header block, accepting both the canonical CRLF terminator
17
+ // (`\r\n\r\n`) and a lenient LF-only terminator (`\n\n`). Returns the index of the
18
+ // terminator and its byte length so the body offset can be computed correctly.
19
+ // Without LF support, an LF-only `Content-Length` frame would buffer until the
20
+ // header-size guard throws and tears down the whole client.
21
+ function findHeaderEnd(buffer) {
22
+ const crlf = buffer.indexOf('\r\n\r\n');
23
+ const lf = buffer.indexOf('\n\n');
24
+ if (crlf < 0 && lf < 0)
25
+ return undefined;
26
+ if (crlf < 0)
27
+ return { end: lf, sep: 2 };
28
+ if (lf < 0)
29
+ return { end: crlf, sep: 4 };
30
+ return crlf <= lf ? { end: crlf, sep: 4 } : { end: lf, sep: 2 };
31
+ }
32
+ export function encodeAcpMessage(message) {
33
+ return Buffer.from(`${JSON.stringify(message)}\n`, 'utf-8');
34
+ }
35
+ export class AcpMessageParser {
36
+ buffer = Buffer.alloc(0);
37
+ recovered = [];
38
+ // Messages that were parsed successfully before `push` threw on a malformed frame.
39
+ // The caller drains these so valid frames batched in the same chunk as a bad one
40
+ // are still delivered before the connection is torn down.
41
+ takeRecovered() {
42
+ const recovered = this.recovered;
43
+ this.recovered = [];
44
+ return recovered;
45
+ }
46
+ push(chunk) {
47
+ this.buffer = Buffer.concat([this.buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
48
+ const messages = [];
49
+ try {
50
+ return this.parseFrames(messages);
51
+ }
52
+ catch (error) {
53
+ // Stash whatever parsed cleanly before the malformed frame so the caller can
54
+ // dispatch it; then rethrow to preserve the existing throw-on-malformed contract.
55
+ this.recovered = messages;
56
+ throw error;
57
+ }
58
+ }
59
+ parseFrames(messages) {
60
+ while (this.buffer.length > 0) {
61
+ const headerTerminator = findHeaderEnd(this.buffer);
62
+ if (headerTerminator) {
63
+ const { end: headerEnd, sep } = headerTerminator;
64
+ const header = this.buffer.slice(0, headerEnd).toString('ascii');
65
+ const lengthMatch = header.match(/^Content-Length:\s*(\d+)\s*$/im);
66
+ if (lengthMatch) {
67
+ const bodyLength = Number(lengthMatch[1]);
68
+ if (!Number.isSafeInteger(bodyLength) || bodyLength < 0 || bodyLength > MAX_ACP_FRAME_BYTES || String(bodyLength) !== lengthMatch[1]) {
69
+ throw new Error(`Invalid ACP frame length: ${lengthMatch[1]}`);
70
+ }
71
+ const bodyStart = headerEnd + sep;
72
+ const bodyEnd = bodyStart + bodyLength;
73
+ if (this.buffer.length < bodyEnd)
74
+ break;
75
+ if (bodyLength > 0) {
76
+ messages.push(parseJsonRpcMessage(this.buffer.slice(bodyStart, bodyEnd).toString('utf-8')));
77
+ }
78
+ this.buffer = this.buffer.slice(bodyEnd);
79
+ continue;
80
+ }
81
+ // Not a valid Content-Length header block; fall through to newline handling up to the blank line.
82
+ const lineEnd = this.buffer.indexOf('\n');
83
+ if (lineEnd < 0)
84
+ break;
85
+ const line = this.buffer.slice(0, lineEnd).toString('utf-8').trim();
86
+ this.buffer = this.buffer.slice(lineEnd + 1);
87
+ if (line.startsWith('{'))
88
+ messages.push(parseJsonRpcLine(line));
89
+ continue;
90
+ }
91
+ const firstCrlf = this.buffer.indexOf('\r\n');
92
+ const looksLikeHeader = firstCrlf >= 0 && this.buffer.slice(0, firstCrlf).toString('ascii').match(/^Content-Length:/i);
93
+ // Only inspect the header-name prefix, not the whole (possibly multi-MB) buffer,
94
+ // to avoid O(n^2) re-encoding while a frame streams in many small chunks.
95
+ const partialHeader = this.buffer.slice(0, CONTENT_LENGTH_PREFIX_BYTES).toString('ascii').match(/^Content-Length:/i);
96
+ if (looksLikeHeader || partialHeader) {
97
+ if (this.buffer.length > MAX_ACP_HEADER_BYTES)
98
+ throw new Error('ACP frame header is too large.');
99
+ break;
100
+ }
101
+ if (this.buffer.length > MAX_ACP_FRAME_BYTES) {
102
+ throw new Error('ACP newline frame is too large.');
103
+ }
104
+ const newline = this.buffer.indexOf('\n');
105
+ if (newline < 0)
106
+ break;
107
+ const line = this.buffer.slice(0, newline).toString('utf-8').trim();
108
+ this.buffer = this.buffer.slice(newline + 1);
109
+ if (line.startsWith('{'))
110
+ messages.push(parseJsonRpcLine(line));
111
+ }
112
+ return messages;
113
+ }
114
+ }
115
+ function parseJsonRpcMessage(payload) {
116
+ const message = parseJsonRpcObject(payload);
117
+ if (message.jsonrpc !== '2.0') {
118
+ throw new Error('payload is not a JSON-RPC 2.0 message');
119
+ }
120
+ return message;
121
+ }
122
+ function parseJsonRpcLine(payload) {
123
+ return parseJsonRpcObject(payload);
124
+ }
125
+ function parseJsonRpcObject(payload) {
126
+ try {
127
+ if (payload.length === 0) {
128
+ throw new Error('empty ACP frame body');
129
+ }
130
+ const message = JSON.parse(payload);
131
+ if (!message || typeof message !== 'object' || Array.isArray(message)) {
132
+ throw new Error('payload is not a JSON-RPC object');
133
+ }
134
+ return message;
135
+ }
136
+ catch (error) {
137
+ throw new Error(`Malformed ACP JSON: ${error instanceof Error ? error.message : String(error)}`);
138
+ }
139
+ }
140
+ function extractTextDeep(value) {
141
+ if (typeof value === 'string')
142
+ return [value];
143
+ if (!value || typeof value !== 'object')
144
+ return [];
145
+ if (Array.isArray(value))
146
+ return value.flatMap(extractTextDeep);
147
+ const record = value;
148
+ const direct = ['text', 'delta']
149
+ .map((key) => record[key])
150
+ .filter((item) => typeof item === 'string');
151
+ const nested = ['content', 'message', 'update', 'updates', 'result']
152
+ .flatMap((key) => extractTextDeep(record[key]));
153
+ return [...direct, ...nested];
154
+ }
155
+ export function extractAcpText(value) {
156
+ return extractTextDeep(value).map((part) => part.trim()).filter(Boolean).join('\n');
157
+ }
158
+ // Keep the trailing portion of `text` within a UTF-8 *byte* budget. Slicing by
159
+ // String length (UTF-16 code units) under-counts multibyte characters, so the
160
+ // result could still exceed `maxBytes` and could split a surrogate pair, corrupting
161
+ // the leading character. This trims on a valid UTF-8 character boundary.
162
+ export function truncateUtf8Tail(text, maxBytes) {
163
+ const buf = Buffer.from(text, 'utf-8');
164
+ if (buf.length <= maxBytes)
165
+ return text;
166
+ let start = buf.length - maxBytes;
167
+ // Advance past any UTF-8 continuation byte (0b10xxxxxx) so we start on a char boundary.
168
+ while (start < buf.length && (buf[start] & 0xc0) === 0x80)
169
+ start++;
170
+ return buf.subarray(start).toString('utf-8');
171
+ }
172
+ export class AcpClient extends EventEmitter {
173
+ proc;
174
+ parser = new AcpMessageParser();
175
+ nextId = 1;
176
+ pending = new Map();
177
+ updates = [];
178
+ timeoutMs;
179
+ constructor(timeoutMs = DEFAULT_ACP_TIMEOUT_MS) {
180
+ super();
181
+ this.timeoutMs = clampTimeout(timeoutMs, DEFAULT_ACP_TIMEOUT_MS);
182
+ }
183
+ start() {
184
+ if (this.proc)
185
+ return;
186
+ const binary = resolveKimiPaths().binaryPath;
187
+ if (!binary)
188
+ throw new Error('Kimi CLI binary was not found on PATH or at ~/.kimi-code/bin/kimi.exe.');
189
+ this.proc = spawn(binary, ['acp'], {
190
+ env: buildKimiEnv(),
191
+ stdio: ['pipe', 'pipe', 'pipe'],
192
+ windowsHide: true,
193
+ });
194
+ this.proc.stdout.on('data', (chunk) => this.handleMessages(chunk));
195
+ this.proc.stderr.on('data', (chunk) => this.emit('stderr', chunk.toString('utf-8')));
196
+ this.proc.on('error', (error) => this.rejectAll(error));
197
+ this.proc.on('close', (code) => this.rejectAll(new Error(`kimi acp exited with code ${code}`)));
198
+ }
199
+ async request(method, params, timeoutMs = this.timeoutMs) {
200
+ this.start();
201
+ const id = this.nextId++;
202
+ const effectiveTimeout = clampTimeout(timeoutMs, this.timeoutMs);
203
+ const message = { jsonrpc: '2.0', id, method, params };
204
+ const promise = new Promise((resolve, reject) => {
205
+ const timer = setTimeout(() => {
206
+ this.pending.delete(id);
207
+ reject(new Error(`ACP request timed out: ${method}`));
208
+ }, effectiveTimeout);
209
+ this.pending.set(id, { resolve, reject, timer });
210
+ });
211
+ this.proc.stdin.write(encodeAcpMessage(message), (error) => {
212
+ if (error)
213
+ this.rejectPending(id, error instanceof Error ? error : new Error(String(error)));
214
+ });
215
+ return promise;
216
+ }
217
+ async initialize() {
218
+ return this.request('initialize', {
219
+ protocolVersion: 1,
220
+ clientInfo: { name: 'ladder-mcp', version: '1.0.0' },
221
+ capabilities: {},
222
+ }, 30_000);
223
+ }
224
+ listSessions() {
225
+ return this.request('session/list', {});
226
+ }
227
+ newSession(workDir) {
228
+ return this.request('session/new', { cwd: workDir ?? process.cwd(), mcpServers: [] });
229
+ }
230
+ loadSession(sessionId) {
231
+ return this.request('session/load', { sessionId });
232
+ }
233
+ resumeSession(sessionId) {
234
+ return this.request('session/resume', { sessionId });
235
+ }
236
+ prompt(sessionId, prompt, workDir) {
237
+ return this.request('session/prompt', {
238
+ sessionId,
239
+ cwd: workDir,
240
+ prompt: [{ type: 'text', text: prompt }],
241
+ text: prompt,
242
+ }, this.timeoutMs);
243
+ }
244
+ cancel(sessionId) {
245
+ return this.request('session/cancel', { sessionId }, 30_000);
246
+ }
247
+ getUpdateText() {
248
+ return this.updates.join('').trim();
249
+ }
250
+ close() {
251
+ if (!this.proc || this.proc.killed)
252
+ return;
253
+ this.proc.kill();
254
+ }
255
+ handleMessages(chunk) {
256
+ let messages;
257
+ try {
258
+ messages = this.parser.push(chunk);
259
+ }
260
+ catch (error) {
261
+ // Deliver any valid frames that parsed before the malformed one, then tear down.
262
+ // Without this, a single bad frame would discard legitimate responses batched in
263
+ // the same chunk and reject every pending request.
264
+ this.dispatch(this.parser.takeRecovered());
265
+ this.rejectAll(error instanceof Error ? error : new Error(String(error)));
266
+ this.close();
267
+ return;
268
+ }
269
+ this.dispatch(messages);
270
+ }
271
+ dispatch(messages) {
272
+ for (const message of messages) {
273
+ if (message.id !== undefined) {
274
+ // Look up by the raw id (number or string). Coercing to Number turned every
275
+ // non-numeric id into NaN, so string/UUID response ids never matched their
276
+ // pending request. Our own requests use numeric ids, so this is exact.
277
+ const pending = this.pending.get(message.id);
278
+ if (!pending)
279
+ continue;
280
+ const id = message.id;
281
+ this.pending.delete(id);
282
+ clearTimeout(pending.timer);
283
+ if (message.error) {
284
+ const details = message.error.data ? `: ${JSON.stringify(message.error.data)}` : '';
285
+ pending.reject(new Error(`${message.error.message ?? 'ACP request failed'}${details}`));
286
+ }
287
+ else
288
+ pending.resolve(message.result);
289
+ continue;
290
+ }
291
+ if (message.method === 'session/update') {
292
+ const params = message.params;
293
+ if (params?.update?.sessionUpdate === 'agent_message_chunk') {
294
+ const text = extractAcpText(params.update.content);
295
+ if (text) {
296
+ this.updates.push(text);
297
+ // Bound retained streamed chunks so a very long session cannot grow the
298
+ // array without limit; drop oldest chunks past the cap.
299
+ if (this.updates.length > MAX_ACP_UPDATE_CHUNKS) {
300
+ this.updates.splice(0, this.updates.length - MAX_ACP_UPDATE_CHUNKS);
301
+ }
302
+ }
303
+ }
304
+ }
305
+ this.emit('notification', message);
306
+ }
307
+ }
308
+ rejectAll(error) {
309
+ for (const [id, pending] of this.pending) {
310
+ this.pending.delete(id);
311
+ clearTimeout(pending.timer);
312
+ pending.reject(error);
313
+ }
314
+ }
315
+ rejectPending(id, error) {
316
+ const pending = this.pending.get(id);
317
+ if (!pending)
318
+ return;
319
+ this.pending.delete(id);
320
+ clearTimeout(pending.timer);
321
+ pending.reject(error);
322
+ }
323
+ }
324
+ function extractSessionId(value, fallback) {
325
+ if (!value || typeof value !== 'object')
326
+ return fallback;
327
+ const record = value;
328
+ for (const key of ['sessionId', 'session_id', 'id', 'session']) {
329
+ if (typeof record[key] === 'string')
330
+ return record[key];
331
+ }
332
+ return fallback;
333
+ }
334
+ export async function runAcpPrompt(options) {
335
+ if (options.signal?.aborted)
336
+ return { ok: false, text: '', error: 'ACP prompt was aborted before start.' };
337
+ const client = new AcpClient(options.timeoutMs);
338
+ const abort = () => client.close();
339
+ options.signal?.addEventListener('abort', abort, { once: true });
340
+ // Cover the race where the signal aborts between the pre-check above and listener
341
+ // registration: adding a listener to an already-aborted signal never fires, so the
342
+ // cancellation would otherwise be silently dropped.
343
+ if (options.signal?.aborted) {
344
+ client.close();
345
+ return { ok: false, text: '', error: 'ACP prompt was aborted before start.' };
346
+ }
347
+ try {
348
+ await client.initialize();
349
+ let sessionId = options.sessionId;
350
+ if (sessionId && options.sessionMode === 'load') {
351
+ sessionId = extractSessionId(await client.loadSession(sessionId), sessionId);
352
+ }
353
+ else if (sessionId && options.sessionMode === 'resume') {
354
+ sessionId = extractSessionId(await client.resumeSession(sessionId), sessionId);
355
+ }
356
+ else if (!sessionId) {
357
+ sessionId = extractSessionId(await client.newSession(options.workDir));
358
+ }
359
+ const result = await client.prompt(sessionId, options.prompt, options.workDir);
360
+ let text = extractAcpText(result) || client.getUpdateText();
361
+ if (!text) {
362
+ try {
363
+ text = JSON.stringify(result, null, 2);
364
+ }
365
+ catch {
366
+ text = '(ACP response is not JSON-serializable)';
367
+ }
368
+ }
369
+ text = truncateUtf8Tail(text, MAX_ACP_METADATA_BYTES);
370
+ let metadata;
371
+ try {
372
+ const serialized = JSON.stringify(result);
373
+ metadata = serialized.length <= MAX_ACP_METADATA_BYTES ? { result } : { truncated: true, size: serialized.length };
374
+ }
375
+ catch {
376
+ metadata = { truncated: true, reason: 'result is not JSON-serializable' };
377
+ }
378
+ return { ok: true, text: text || '(empty ACP response from Kimi)', sessionId, metadata };
379
+ }
380
+ catch (error) {
381
+ return { ok: false, text: '', error: error instanceof Error ? error.message : String(error) };
382
+ }
383
+ finally {
384
+ options.signal?.removeEventListener('abort', abort);
385
+ client.close();
386
+ }
387
+ }
388
+ export async function listAcpSessions() {
389
+ const client = new AcpClient(60_000);
390
+ try {
391
+ await client.initialize();
392
+ const result = await client.listSessions();
393
+ return { ok: true, text: JSON.stringify(result, null, 2) };
394
+ }
395
+ catch (error) {
396
+ return { ok: false, text: '', error: error instanceof Error ? error.message : String(error) };
397
+ }
398
+ finally {
399
+ client.close();
400
+ }
401
+ }
402
+ export async function cancelAcpSession(sessionId) {
403
+ const client = new AcpClient(30_000);
404
+ try {
405
+ await client.initialize();
406
+ const result = await client.cancel(sessionId);
407
+ return { ok: true, text: JSON.stringify(result ?? { cancelled: true }, null, 2), sessionId };
408
+ }
409
+ catch (error) {
410
+ return { ok: false, text: '', error: error instanceof Error ? error.message : String(error), sessionId };
411
+ }
412
+ finally {
413
+ client.close();
414
+ }
415
+ }
@@ -0,0 +1,239 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { buildKimiEnv, getKimiStatus, resolveKimiPaths } from '../environment.js';
6
+ const execFileAsync = promisify(execFile);
7
+ const ADMIN_TIMEOUT_MS = 30_000;
8
+ // Quote a single argument for the human-readable preview command. This string is
9
+ // display-only (never executed), but quoting anything outside a conservative safe
10
+ // set — not just spaces — keeps the preview copy-paste-safe and unambiguous when an
11
+ // argument contains shell metacharacters.
12
+ export function quoteForDisplay(arg) {
13
+ return /^[A-Za-z0-9_\-.,:/\\=]+$/.test(arg) ? arg : JSON.stringify(arg);
14
+ }
15
+ function assertLocalVisualizerAddress(host, port) {
16
+ if (!['127.0.0.1', 'localhost', '::1'].includes(host)) {
17
+ throw new Error('kimi_visualize_session only supports localhost hosts.');
18
+ }
19
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
20
+ throw new Error('Visualizer port must be an integer from 1 to 65535.');
21
+ }
22
+ }
23
+ // Canonicalize a path by resolving symlinks in its nearest existing ancestor and
24
+ // re-appending the not-yet-existing tail. This lets the containment check below
25
+ // compare like-for-like (canonical vs canonical) even when the target file does
26
+ // not exist yet, closing the symlink-escape gap.
27
+ function canonicalizePath(target) {
28
+ let current = path.resolve(target);
29
+ const tail = [];
30
+ for (;;) {
31
+ try {
32
+ const real = fs.realpathSync(current);
33
+ return tail.length ? path.join(real, ...tail.reverse()) : real;
34
+ }
35
+ catch (error) {
36
+ // Only a missing path (ENOENT) is recoverable: resolve the nearest existing
37
+ // ancestor and re-append the not-yet-created tail. Any other realpath failure
38
+ // (EACCES, ELOOP, …) means we cannot prove containment, so fail closed rather
39
+ // than return a non-canonical path that would defeat the symlink-escape check.
40
+ if (error.code !== 'ENOENT') {
41
+ throw new Error(`output_path could not be canonicalized: ${error instanceof Error ? error.message : String(error)}`);
42
+ }
43
+ const parent = path.dirname(current);
44
+ if (parent === current)
45
+ return path.resolve(target);
46
+ tail.push(path.basename(current));
47
+ current = parent;
48
+ }
49
+ }
50
+ }
51
+ function assertSafeOutputPath(outputPath) {
52
+ if (outputPath.includes('\0')) {
53
+ throw new Error('output_path contains invalid null bytes.');
54
+ }
55
+ const cwd = canonicalizePath(process.cwd());
56
+ const resolved = canonicalizePath(path.resolve(process.cwd(), outputPath));
57
+ const relative = path.relative(cwd, resolved);
58
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
59
+ throw new Error('output_path must be inside the current working directory.');
60
+ }
61
+ }
62
+ function requireBinary() {
63
+ const binary = resolveKimiPaths().binaryPath;
64
+ if (!binary)
65
+ throw new Error('Kimi CLI binary was not found on PATH or at ~/.kimi-code/bin/kimi.exe.');
66
+ return binary;
67
+ }
68
+ export function buildDoctorArgs(target = 'config', configPath) {
69
+ return ['doctor', target, ...(configPath ? [configPath] : [])];
70
+ }
71
+ export function buildProviderListArgs() {
72
+ return ['provider', 'list', '--json'];
73
+ }
74
+ export function buildExportArgs(options) {
75
+ const args = ['export'];
76
+ if (options.sessionId)
77
+ args.push(options.sessionId);
78
+ args.push('-o', options.outputPath);
79
+ if (options.overwriteExisting === true)
80
+ args.push('-y');
81
+ if (options.includeGlobalLog !== true)
82
+ args.push('--no-include-global-log');
83
+ return args;
84
+ }
85
+ export function buildVisualizeArgs(options) {
86
+ const host = options.host ?? '127.0.0.1';
87
+ const port = options.port ?? 58628;
88
+ assertLocalVisualizerAddress(host, port);
89
+ const args = ['vis'];
90
+ if (options.sessionId)
91
+ args.push(options.sessionId);
92
+ args.push('--host', host, '--port', String(port), '--no-open');
93
+ return args;
94
+ }
95
+ const DEFAULT_MAX_BUFFER = 1024 * 1024 * 8;
96
+ const PROBE_MAX_BUFFER = 1024 * 256;
97
+ async function runKimiCommand(args, timeoutMs = ADMIN_TIMEOUT_MS, maxBuffer = DEFAULT_MAX_BUFFER) {
98
+ try {
99
+ const binary = requireBinary();
100
+ const child = execFileAsync(binary, args, {
101
+ env: buildKimiEnv(),
102
+ timeout: timeoutMs,
103
+ windowsHide: true,
104
+ maxBuffer,
105
+ });
106
+ // None of these admin commands read stdin. Close it so that if a TOCTOU overwrite
107
+ // prompt (or any other prompt) appears, the CLI receives EOF and fails fast instead
108
+ // of blocking on input until the timeout fires.
109
+ child.child.stdin?.end();
110
+ const { stdout, stderr } = await child;
111
+ return { ok: true, stdout: String(stdout), stderr: String(stderr) };
112
+ }
113
+ catch (error) {
114
+ const err = error;
115
+ return {
116
+ ok: false,
117
+ stdout: err.stdout ? String(err.stdout) : '',
118
+ stderr: err.stderr ? String(err.stderr) : '',
119
+ error: err.message,
120
+ };
121
+ }
122
+ }
123
+ export async function getKimiCapabilities() {
124
+ const status = await getKimiStatus();
125
+ const commandNames = ['acp', 'doctor', 'provider', 'export', 'vis', 'server', 'web'];
126
+ const commands = {};
127
+ if (status.installed) {
128
+ // These seven `--help` probes run in parallel. Cap each one's buffer so the
129
+ // worst-case combined allocation stays small (7 * 256 KiB) instead of 7 * 8 MiB;
130
+ // help text is tiny, so the smaller cap never truncates real output.
131
+ await Promise.all(commandNames.map(async (name) => {
132
+ const result = await runKimiCommand([name, '--help'], 5000, PROBE_MAX_BUFFER);
133
+ commands[name] = result.ok || result.stdout.includes('Usage:') || result.stderr.includes('Usage:');
134
+ }));
135
+ }
136
+ else {
137
+ for (const name of commandNames)
138
+ commands[name] = false;
139
+ }
140
+ return {
141
+ cli: {
142
+ installed: status.installed,
143
+ binary: status.binPath,
144
+ version: status.version,
145
+ catalogFound: status.catalogFound,
146
+ authenticated: status.authenticated,
147
+ },
148
+ commands,
149
+ acp: { available: commands.acp === true, command: 'kimi acp' },
150
+ desktop: { experimental: true, readOnly: true },
151
+ };
152
+ }
153
+ export async function runKimiDoctor(target = 'config', configPath) {
154
+ return runKimiCommand(buildDoctorArgs(target, configPath));
155
+ }
156
+ export async function listKimiProviders() {
157
+ const result = await runKimiCommand(buildProviderListArgs());
158
+ if (!result.ok)
159
+ return result;
160
+ try {
161
+ return JSON.parse(result.stdout);
162
+ }
163
+ catch {
164
+ return result;
165
+ }
166
+ }
167
+ export async function exportKimiSession(options) {
168
+ if (!options.outputPath.trim()) {
169
+ return { ok: false, stdout: '', stderr: '', error: 'output_path is required for kimi_export_session.' };
170
+ }
171
+ try {
172
+ assertSafeOutputPath(options.outputPath);
173
+ }
174
+ catch (error) {
175
+ return {
176
+ ok: false,
177
+ stdout: '',
178
+ stderr: '',
179
+ error: error instanceof Error ? error.message : String(error),
180
+ };
181
+ }
182
+ const outputPath = path.resolve(options.outputPath);
183
+ let existingStat;
184
+ try {
185
+ existingStat = fs.statSync(outputPath);
186
+ }
187
+ catch (error) {
188
+ if (error.code !== 'ENOENT') {
189
+ return {
190
+ ok: false,
191
+ stdout: '',
192
+ stderr: '',
193
+ error: `output_path could not be inspected: ${error instanceof Error ? error.message : String(error)}`,
194
+ };
195
+ }
196
+ }
197
+ if (existingStat) {
198
+ if (existingStat.isDirectory()) {
199
+ return { ok: false, stdout: '', stderr: '', error: 'output_path must be a file, not a directory.' };
200
+ }
201
+ if (options.overwriteExisting !== true) {
202
+ return {
203
+ ok: false,
204
+ stdout: '',
205
+ stderr: '',
206
+ error: 'output_path already exists. Pass overwrite_existing=true to replace it explicitly.',
207
+ };
208
+ }
209
+ }
210
+ return runKimiCommand(buildExportArgs({ ...options, outputPath }), 120_000);
211
+ }
212
+ export function visualizeSession(options) {
213
+ const binary = requireBinary();
214
+ const host = options.host ?? '127.0.0.1';
215
+ const port = options.port ?? 58628;
216
+ assertLocalVisualizerAddress(host, port);
217
+ const args = buildVisualizeArgs({ ...options, host, port });
218
+ const command = [binary, ...args].map(quoteForDisplay).join(' ');
219
+ const sessionPart = options.sessionId ? `?session=${encodeURIComponent(options.sessionId)}` : '';
220
+ const urlHost = host.includes(':') ? `[${host}]` : host;
221
+ const url = `http://${urlHost}:${port}/${sessionPart}`;
222
+ if (!options.launch)
223
+ return { command, url, launched: false };
224
+ const proc = spawn(binary, args, {
225
+ env: buildKimiEnv(),
226
+ stdio: 'ignore',
227
+ detached: true,
228
+ windowsHide: true,
229
+ });
230
+ // Detached fire-and-forget: spawn errors surface asynchronously and cannot be
231
+ // observed before this function returns, so `launched` reflects only whether a
232
+ // pid was assigned. Keep an 'error' listener so an async spawn failure surfaces
233
+ // on our stderr instead of crashing the host as an unhandled 'error' event.
234
+ proc.on('error', (error) => {
235
+ process.stderr.write(`Visualizer process failed to start: ${error instanceof Error ? error.message : String(error)}\n`);
236
+ });
237
+ proc.unref();
238
+ return { command, url, launched: proc.pid !== undefined, pid: proc.pid ?? undefined };
239
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "ladder-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Windows-first MCP bridge for Kimi Code CLI v24",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "bin": {
9
+ "ladder-mcp": "dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "CHANGELOG.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "dev": "tsx src/index.ts",
19
+ "build": "tsc -p tsconfig.build.json",
20
+ "start": "node dist/index.js",
21
+ "test": "vitest run",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "engines": {
25
+ "node": ">=18.0.0"
26
+ },
27
+ "dependencies": {
28
+ "@modelcontextprotocol/sdk": "^1.29.0",
29
+ "zod": "^3.25.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^22.10.5",
33
+ "tsx": "^4.19.2",
34
+ "typescript": "^5.7.3",
35
+ "vitest": "^4.0.18"
36
+ }
37
+ }