diff --git a/src/plugins/agents/builtin/index.ts b/src/plugins/agents/builtin/index.ts index ea5e6cf4..5d32cdef 100644 --- a/src/plugins/agents/builtin/index.ts +++ b/src/plugins/agents/builtin/index.ts @@ -12,6 +12,7 @@ import createCodexAgent from './codex.js'; import createKiroAgent from './kiro.js'; import createCursorAgent from './cursor.js'; import createGithubCopilotAgent from './github-copilot.js'; +import createKimiAgent from './kimi.js'; /** * Register all built-in agent plugins with the registry. @@ -29,6 +30,7 @@ export function registerBuiltinAgents(): void { registry.registerBuiltin(createKiroAgent); registry.registerBuiltin(createCursorAgent); registry.registerBuiltin(createGithubCopilotAgent); + registry.registerBuiltin(createKimiAgent); } // Export the factory functions for direct use @@ -41,6 +43,7 @@ export { createKiroAgent, createCursorAgent, createGithubCopilotAgent, + createKimiAgent, }; // Export Claude JSONL parsing types and utilities @@ -53,3 +56,4 @@ export { CodexAgentPlugin } from './codex.js'; export { KiroAgentPlugin } from './kiro.js'; export { CursorAgentPlugin } from './cursor.js'; export { GithubCopilotAgentPlugin } from './github-copilot.js'; +export { KimiAgentPlugin } from './kimi.js'; diff --git a/src/plugins/agents/builtin/kimi.test.ts b/src/plugins/agents/builtin/kimi.test.ts new file mode 100644 index 00000000..fd483642 --- /dev/null +++ b/src/plugins/agents/builtin/kimi.test.ts @@ -0,0 +1,401 @@ +/** + * ABOUTME: Tests for the Kimi CLI agent plugin. + * Tests configuration, argument building, and JSONL parsing for Moonshot AI's Kimi CLI. + */ + +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { + KimiAgentPlugin, + parseKimiJsonLine, + parseKimiOutputToEvents, +} from './kimi.js'; + +describe('KimiAgentPlugin', () => { + let plugin: KimiAgentPlugin; + + beforeEach(() => { + plugin = new KimiAgentPlugin(); + }); + + afterEach(async () => { + await plugin.dispose(); + }); + + describe('meta', () => { + test('has correct plugin ID', () => { + expect(plugin.meta.id).toBe('kimi'); + }); + + test('has correct name', () => { + expect(plugin.meta.name).toBe('Kimi CLI'); + }); + + test('has correct default command', () => { + expect(plugin.meta.defaultCommand).toBe('kimi'); + }); + + test('supports streaming', () => { + expect(plugin.meta.supportsStreaming).toBe(true); + }); + + test('supports interrupt', () => { + expect(plugin.meta.supportsInterrupt).toBe(true); + }); + + test('does not support file context', () => { + expect(plugin.meta.supportsFileContext).toBe(false); + }); + + test('supports subagent tracing', () => { + expect(plugin.meta.supportsSubagentTracing).toBe(true); + }); + + test('has JSONL structured output format', () => { + expect(plugin.meta.structuredOutputFormat).toBe('jsonl'); + }); + + test('has skills paths configured', () => { + expect(plugin.meta.skillsPaths?.personal).toBe('~/.kimi/skills'); + expect(plugin.meta.skillsPaths?.repo).toBe('.kimi/skills'); + }); + }); + + describe('initialize', () => { + test('initializes with default config', async () => { + await plugin.initialize({}); + expect(await plugin.isReady()).toBe(true); + }); + + test('accepts model configuration', async () => { + await plugin.initialize({ model: 'kimi-k2-0711' }); + expect(await plugin.isReady()).toBe(true); + }); + + test('accepts timeout configuration', async () => { + await plugin.initialize({ timeout: 300000 }); + expect(await plugin.isReady()).toBe(true); + }); + }); + + describe('getSetupQuestions', () => { + test('includes model question', () => { + const questions = plugin.getSetupQuestions(); + const modelQuestion = questions.find((q) => q.id === 'model'); + expect(modelQuestion).toBeDefined(); + expect(modelQuestion?.type).toBe('text'); + expect(modelQuestion?.required).toBe(false); + }); + + test('includes base questions (command, timeout)', () => { + const questions = plugin.getSetupQuestions(); + expect(questions.find((q) => q.id === 'command')).toBeDefined(); + expect(questions.find((q) => q.id === 'timeout')).toBeDefined(); + }); + }); + + describe('validateSetup', () => { + test('accepts valid kimi model', async () => { + const result = await plugin.validateSetup({ model: 'kimi-k2-0711' }); + expect(result).toBeNull(); + }); + + test('accepts empty model', async () => { + const result = await plugin.validateSetup({ model: '' }); + expect(result).toBeNull(); + }); + + test('accepts any model name (no validation)', async () => { + const result = await plugin.validateSetup({ model: 'any-model-name' }); + expect(result).toBeNull(); + }); + }); +}); + +describe('KimiAgentPlugin buildArgs', () => { + let plugin: KimiAgentPlugin; + + // Create a test subclass to access protected method + class TestableKimiPlugin extends KimiAgentPlugin { + testBuildArgs(prompt: string): string[] { + return (this as unknown as { buildArgs: (p: string) => string[] }).buildArgs(prompt); + } + + testGetStdinInput(prompt: string): string | undefined { + return (this as unknown as { getStdinInput: (p: string) => string | undefined }).getStdinInput(prompt); + } + } + + beforeEach(() => { + plugin = new TestableKimiPlugin(); + }); + + afterEach(async () => { + await plugin.dispose(); + }); + + test('includes --print for non-interactive mode', async () => { + await plugin.initialize({}); + const args = (plugin as TestableKimiPlugin).testBuildArgs('test prompt'); + expect(args).toContain('--print'); + }); + + test('includes --input-format text', async () => { + await plugin.initialize({}); + const args = (plugin as TestableKimiPlugin).testBuildArgs('test prompt'); + expect(args).toContain('--input-format'); + expect(args).toContain('text'); + }); + + test('includes --output-format stream-json', async () => { + await plugin.initialize({}); + const args = (plugin as TestableKimiPlugin).testBuildArgs('test prompt'); + expect(args).toContain('--output-format'); + expect(args).toContain('stream-json'); + }); + + test('includes model flag when specified', async () => { + await plugin.initialize({ model: 'kimi-k2-0711' }); + const args = (plugin as TestableKimiPlugin).testBuildArgs('test prompt'); + expect(args).toContain('--model'); + expect(args).toContain('kimi-k2-0711'); + }); + + test('omits model flag when not specified', async () => { + await plugin.initialize({}); + const args = (plugin as TestableKimiPlugin).testBuildArgs('test prompt'); + expect(args).not.toContain('--model'); + }); + + test('returns prompt via stdin', async () => { + await plugin.initialize({}); + const stdinInput = (plugin as TestableKimiPlugin).testGetStdinInput('my test prompt'); + expect(stdinInput).toBe('my test prompt'); + }); +}); + +describe('parseKimiJsonLine', () => { + test('returns empty array for empty input', () => { + expect(parseKimiJsonLine('')).toEqual([]); + expect(parseKimiJsonLine(' ')).toEqual([]); + }); + + test('returns empty array for invalid JSON', () => { + expect(parseKimiJsonLine('not json')).toEqual([]); + expect(parseKimiJsonLine('{ invalid')).toEqual([]); + }); + + test('parses text content from assistant message', () => { + const input = JSON.stringify({ + role: 'assistant', + content: [{ type: 'text', text: 'Hello from Kimi' }], + }); + const events = parseKimiJsonLine(input); + expect(events.length).toBe(1); + expect(events[0]?.type).toBe('text'); + expect((events[0] as { content: string }).content).toBe('Hello from Kimi'); + }); + + test('skips think events', () => { + const input = JSON.stringify({ + role: 'assistant', + content: [{ type: 'think', think: 'internal reasoning...' }], + }); + const events = parseKimiJsonLine(input); + expect(events.length).toBe(0); + }); + + test('parses function tool call', () => { + const input = JSON.stringify({ + role: 'tool', + content: [{ + type: 'function', + function: { + name: 'WriteFile', + arguments: JSON.stringify({ path: '/test.js', content: 'hello' }), + }, + }], + }); + const events = parseKimiJsonLine(input); + expect(events.length).toBe(1); + expect(events[0]?.type).toBe('tool_use'); + expect((events[0] as { name: string }).name).toBe('WriteFile'); + expect((events[0] as { input: Record }).input).toEqual({ + path: '/test.js', + content: 'hello', + }); + }); + + test('handles function call with non-JSON arguments', () => { + const input = JSON.stringify({ + role: 'tool', + content: [{ + type: 'function', + function: { + name: 'Shell', + arguments: 'ls -la', + }, + }], + }); + const events = parseKimiJsonLine(input); + expect(events.length).toBe(1); + expect(events[0]?.type).toBe('tool_use'); + expect((events[0] as { input: Record }).input).toEqual({ + command: 'ls -la', + }); + }); + + test('parses tool_result event', () => { + const input = JSON.stringify({ + role: 'tool', + content: [{ type: 'tool_result', is_error: false }], + }); + const events = parseKimiJsonLine(input); + expect(events.length).toBe(1); + expect(events[0]?.type).toBe('tool_result'); + }); + + test('parses tool_result with error', () => { + const input = JSON.stringify({ + role: 'tool', + content: [{ type: 'tool_result', is_error: true, output: 'File not found' }], + }); + const events = parseKimiJsonLine(input); + expect(events.length).toBe(2); + expect(events[0]?.type).toBe('error'); + expect((events[0] as { message: string }).message).toBe('File not found'); + expect(events[1]?.type).toBe('tool_result'); + }); + + test('parses function_result event', () => { + const input = JSON.stringify({ + role: 'tool', + content: [{ type: 'function_result' }], + }); + const events = parseKimiJsonLine(input); + expect(events.length).toBe(1); + expect(events[0]?.type).toBe('tool_result'); + }); + + test('parses top-level text event', () => { + const input = JSON.stringify({ + type: 'text', + text: 'Direct text output', + }); + const events = parseKimiJsonLine(input); + expect(events.length).toBe(1); + expect(events[0]?.type).toBe('text'); + expect((events[0] as { content: string }).content).toBe('Direct text output'); + }); + + test('parses top-level error event', () => { + const input = JSON.stringify({ + type: 'error', + error: { message: 'API rate limit' }, + }); + const events = parseKimiJsonLine(input); + expect(events.length).toBe(1); + expect(events[0]?.type).toBe('error'); + expect((events[0] as { message: string }).message).toBe('API rate limit'); + }); + + test('parses error with string error field', () => { + const input = JSON.stringify({ + type: 'error', + error: 'Something went wrong', + }); + const events = parseKimiJsonLine(input); + expect(events.length).toBe(1); + expect(events[0]?.type).toBe('error'); + expect((events[0] as { message: string }).message).toBe('Something went wrong'); + }); + + test('parses error with message field', () => { + const input = JSON.stringify({ + type: 'error', + message: 'Error from message field', + }); + const events = parseKimiJsonLine(input); + expect(events.length).toBe(1); + expect(events[0]?.type).toBe('error'); + expect((events[0] as { message: string }).message).toBe('Error from message field'); + }); + + test('handles mixed content array', () => { + const input = JSON.stringify({ + role: 'assistant', + content: [ + { type: 'think', think: 'internal thought' }, + { type: 'text', text: 'Hello' }, + { type: 'text', text: 'World' }, + ], + }); + const events = parseKimiJsonLine(input); + expect(events.length).toBe(2); + expect((events[0] as { content: string }).content).toBe('Hello'); + expect((events[1] as { content: string }).content).toBe('World'); + }); + + test('handles function call without arguments', () => { + const input = JSON.stringify({ + role: 'tool', + content: [{ + type: 'function', + function: { name: 'ReadFile' }, + }], + }); + const events = parseKimiJsonLine(input); + expect(events.length).toBe(1); + expect(events[0]?.type).toBe('tool_use'); + expect((events[0] as { name: string }).name).toBe('ReadFile'); + }); + + test('handles function call without name', () => { + const input = JSON.stringify({ + role: 'tool', + content: [{ + type: 'function', + function: { arguments: '{}' }, + }], + }); + const events = parseKimiJsonLine(input); + expect(events.length).toBe(1); + expect((events[0] as { name: string }).name).toBe('unknown'); + }); +}); + +describe('parseKimiOutputToEvents', () => { + test('parses multiple JSONL lines', () => { + const lines = [ + JSON.stringify({ role: 'assistant', content: [{ type: 'text', text: 'Line 1' }] }), + JSON.stringify({ role: 'assistant', content: [{ type: 'text', text: 'Line 2' }] }), + ].join('\n'); + const events = parseKimiOutputToEvents(lines); + expect(events.length).toBe(2); + expect((events[0] as { content: string }).content).toBe('Line 1'); + expect((events[1] as { content: string }).content).toBe('Line 2'); + }); + + test('handles empty lines', () => { + const lines = '\n\n' + JSON.stringify({ + role: 'assistant', + content: [{ type: 'text', text: 'Hello' }], + }) + '\n\n'; + const events = parseKimiOutputToEvents(lines); + expect(events.length).toBe(1); + }); + + test('handles mixed valid and invalid lines', () => { + const lines = [ + 'some status text', + JSON.stringify({ role: 'assistant', content: [{ type: 'text', text: 'Valid' }] }), + 'another status line', + ].join('\n'); + const events = parseKimiOutputToEvents(lines); + expect(events.length).toBe(1); + expect((events[0] as { content: string }).content).toBe('Valid'); + }); + + test('returns empty array for empty input', () => { + expect(parseKimiOutputToEvents('')).toEqual([]); + }); +}); diff --git a/src/plugins/agents/builtin/kimi.ts b/src/plugins/agents/builtin/kimi.ts new file mode 100644 index 00000000..be75ff58 --- /dev/null +++ b/src/plugins/agents/builtin/kimi.ts @@ -0,0 +1,412 @@ +/** + * ABOUTME: Kimi CLI agent plugin for Moonshot AI's kimi command. + * Integrates with Kimi Code CLI for AI-assisted coding. + * Supports: non-interactive (print) mode, stream-json output, stdin prompt, model selection. + */ + +import { spawn } from 'node:child_process'; +import { BaseAgentPlugin, findCommandPath, quoteForWindowsShell } from '../base.js'; +import { processAgentEvents, processAgentEventsToSegments, type AgentDisplayEvent } from '../output-formatting.js'; +import { extractErrorMessage } from '../utils.js'; +import type { + AgentPluginMeta, + AgentPluginFactory, + AgentFileContext, + AgentExecuteOptions, + AgentSetupQuestion, + AgentDetectResult, + AgentExecutionHandle, +} from '../types.js'; + +/** + * Parse a Kimi stream-json line into standardized display events. + * + * Kimi CLI stream-json format: + * - {"role":"assistant","content":[{"type":"think","think":"..."}, {"type":"text","text":"..."}]} + * - {"role":"tool","content":[{"type":"function","id":"...","function":{"name":"...","arguments":"..."}}]} + * - Tool results, status updates, etc. + * + * @internal Exported for testing only. + */ +export function parseKimiJsonLine(jsonLine: string): AgentDisplayEvent[] { + if (!jsonLine) return []; + + try { + const event = JSON.parse(jsonLine); + const events: AgentDisplayEvent[] = []; + + // Handle content array format (most common) + if (event.content && Array.isArray(event.content)) { + for (const item of event.content) { + if (item.type === 'text' && item.text) { + events.push({ type: 'text', content: item.text }); + } else if (item.type === 'function' && item.function) { + // Tool call + const toolName = item.function.name || 'unknown'; + let toolInput: Record | undefined; + if (item.function.arguments) { + try { + toolInput = JSON.parse(item.function.arguments); + } catch { + toolInput = { command: item.function.arguments }; + } + } + events.push({ type: 'tool_use', name: toolName, input: toolInput }); + } else if (item.type === 'tool_result' || item.type === 'function_result') { + const isError = item.is_error === true; + if (isError && item.output) { + events.push({ type: 'error', message: String(item.output).slice(0, 200) }); + } + events.push({ type: 'tool_result' }); + } + // Skip: think (internal reasoning), status updates, etc. + } + } else if (event.type === 'text' && event.text) { + // Handle top-level text content (only when no content array) + events.push({ type: 'text', content: event.text }); + } else if (event.type === 'error' || event.error) { + // Handle error events (only when no content array) + const msg = extractErrorMessage(event.error) || extractErrorMessage(event.message) || 'Unknown error'; + events.push({ type: 'error', message: msg }); + } + + return events; + } catch { + // Not valid JSON - skip + return []; + } +} + +/** + * Parse Kimi stream-json output into display events. + * @internal Exported for testing only. + */ +export function parseKimiOutputToEvents(data: string): AgentDisplayEvent[] { + const allEvents: AgentDisplayEvent[] = []; + for (const line of data.split('\n')) { + const events = parseKimiJsonLine(line.trim()); + allEvents.push(...events); + } + return allEvents; +} + +/** + * Kimi CLI agent plugin implementation. + * Uses the `kimi` CLI with `--print` mode for non-interactive AI coding tasks. + * Parses stream-json output into structured display events for the TUI. + */ +export class KimiAgentPlugin extends BaseAgentPlugin { + readonly meta: AgentPluginMeta = { + id: 'kimi', + name: 'Kimi CLI', + description: 'Moonshot AI Kimi Code CLI for AI-assisted coding', + version: '1.0.0', + author: 'Moonshot AI', + defaultCommand: 'kimi', + supportsStreaming: true, + supportsInterrupt: true, + supportsFileContext: false, + supportsSubagentTracing: true, + structuredOutputFormat: 'jsonl', + skillsPaths: { + personal: '~/.kimi/skills', + repo: '.kimi/skills', + }, + }; + + private model?: string; + protected override defaultTimeout = 0; + + override async initialize(config: Record): Promise { + await super.initialize(config); + + if (typeof config.model === 'string' && config.model.length > 0) { + this.model = config.model; + } + + if (typeof config.timeout === 'number' && config.timeout > 0) { + this.defaultTimeout = config.timeout; + } + } + + override async detect(): Promise { + const command = this.commandPath ?? this.meta.defaultCommand; + const findResult = await findCommandPath(command); + + if (!findResult.found) { + return { + available: false, + error: `Kimi CLI not found in PATH. Install: curl -LsSf https://code.kimi.com/install.sh | bash (see https://moonshotai.github.io/kimi-cli/en/guides/getting-started.html)`, + }; + } + + const versionResult = await this.runVersion(findResult.path); + + if (!versionResult.success) { + return { + available: false, + executablePath: findResult.path, + error: versionResult.error, + }; + } + + // Store the detected path for use in execute() + this.commandPath = findResult.path; + + return { + available: true, + version: versionResult.version, + executablePath: findResult.path, + }; + } + + private runVersion( + command: string + ): Promise<{ success: boolean; version?: string; error?: string }> { + return new Promise((resolve) => { + const useShell = process.platform === 'win32'; + const proc = spawn(useShell ? quoteForWindowsShell(command) : command, ['--version'], { + stdio: ['ignore', 'pipe', 'pipe'], + shell: useShell, + }); + + let stdout = ''; + let stderr = ''; + let settled = false; + + const safeResolve = (result: { success: boolean; version?: string; error?: string }) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + }; + + proc.stdout?.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + proc.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + proc.on('error', (error) => { + safeResolve({ success: false, error: `Failed to execute: ${error.message}` }); + }); + + proc.on('close', (code) => { + if (code === 0) { + const versionMatch = stdout.match(/(\d+\.\d+\.\d+)/); + if (!versionMatch?.[1]) { + safeResolve({ + success: false, + error: `Unable to parse kimi version output: ${stdout}`, + }); + return; + } + safeResolve({ success: true, version: versionMatch[1] }); + } else { + safeResolve({ success: false, error: stderr || `Exited with code ${code}` }); + } + }); + + const timer = setTimeout(() => { + proc.kill(); + safeResolve({ success: false, error: 'Timeout waiting for --version' }); + }, 15000); + }); + } + + override getSetupQuestions(): AgentSetupQuestion[] { + return [ + ...super.getSetupQuestions(), + { + id: 'model', + prompt: 'Model to use:', + type: 'text', + default: '', + required: false, + help: 'Kimi model to use (e.g., kimi-k2-0711). Leave empty for default.', + }, + ]; + } + + protected buildArgs( + _prompt: string, + _files?: AgentFileContext[], + _options?: AgentExecuteOptions + ): string[] { + const args: string[] = []; + + // Use --print for non-interactive mode (implicitly adds --yolo) + args.push('--print'); + + // Use stdin for prompt delivery via --input-format text + // This avoids Windows shell word-splitting issues with --prompt + args.push('--input-format', 'text'); + + // Use stream-json for structured output that we can parse into display events + args.push('--output-format', 'stream-json'); + + // Model selection + if (this.model) { + args.push('--model', this.model); + } + + return args; + } + + /** + * Provide the prompt via stdin. + * Kimi CLI with --print --input-format text reads from stdin, + * which avoids Windows shell word-splitting issues with --prompt. + */ + protected override getStdinInput( + prompt: string, + _files?: AgentFileContext[], + _options?: AgentExecuteOptions + ): string { + return prompt; + } + + /** + * Override execute to parse Kimi stream-json output into structured display events. + * Wraps onStdout/onStdoutSegments callbacks to parse JSON lines and extract + * displayable content (text, tool calls, errors), matching Gemini's approach. + * Also injects Python UTF-8 env vars for Windows compatibility. + */ + override execute( + prompt: string, + files?: AgentFileContext[], + options?: AgentExecuteOptions + ): AgentExecutionHandle { + // Buffer for incomplete JSON lines split across chunks + let jsonlBuffer = ''; + + // Helper to flush remaining buffer content + const flushBuffer = () => { + if (!jsonlBuffer) return; + const trimmed = jsonlBuffer.trim(); + if (!trimmed) return; + + // Forward to onJsonlMessage if valid JSON + if (options?.onJsonlMessage && trimmed.startsWith('{')) { + try { + const parsed = JSON.parse(trimmed); + options.onJsonlMessage(parsed); + } catch { + // Not valid JSON, skip + } + } + + // Process for display events + const events = parseKimiOutputToEvents(trimmed); + if (events.length > 0) { + if (options?.onStdoutSegments) { + const segments = processAgentEventsToSegments(events); + if (segments.length > 0) { + options.onStdoutSegments(segments); + } + } + if (options?.onStdout) { + const formatted = processAgentEvents(events); + if (formatted.length > 0) { + options.onStdout(formatted); + } + } + } + + jsonlBuffer = ''; + }; + + // Wrap callbacks to parse JSON events + const parsedOptions: AgentExecuteOptions = { + ...options, + // Inject Python UTF-8 env vars for Windows charmap compatibility + env: { + ...options?.env, + PYTHONUTF8: '1', + PYTHONIOENCODING: 'utf-8', + }, + onStdout: (options?.onStdout || options?.onStdoutSegments || options?.onJsonlMessage) + ? (data: string) => { + // Prepend any buffered partial line from previous chunk + const combined = jsonlBuffer + data; + + // Split into lines - last element may be incomplete + const lines = combined.split('\n'); + + // If data doesn't end with newline, last line is incomplete - buffer it + if (!data.endsWith('\n')) { + jsonlBuffer = lines.pop() || ''; + } else { + jsonlBuffer = ''; + } + + // Process complete lines + const completeData = lines.join('\n'); + + // Parse raw JSON lines and forward to onJsonlMessage for subagent tracing + if (options?.onJsonlMessage) { + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed && trimmed.startsWith('{')) { + try { + const parsed = JSON.parse(trimmed); + options.onJsonlMessage(parsed); + } catch { + // Not valid JSON, skip + } + } + } + } + + // Process for display events + const events = parseKimiOutputToEvents(completeData); + if (events.length > 0) { + // Call TUI-native segments callback if provided + if (options?.onStdoutSegments) { + const segments = processAgentEventsToSegments(events); + if (segments.length > 0) { + options.onStdoutSegments(segments); + } + } + // Also call legacy string callback if provided + if (options?.onStdout) { + const formatted = processAgentEvents(events); + if (formatted.length > 0) { + options.onStdout(formatted); + } + } + } + } + : undefined, + // Wrap onEnd to flush buffer before calling original callback + onEnd: (result) => { + flushBuffer(); + options?.onEnd?.(result); + }, + }; + + return super.execute(prompt, files, parsedOptions); + } + + override async validateSetup(answers: Record): Promise { + const model = answers.model; + if (model !== undefined && model !== '' && typeof model === 'string') { + const err = this.validateModel(model); + if (err) return err; + } + return null; + } + + override validateModel(model: string): string | null { + if (model === '' || model.trim().length === 0) { + return null; + } + return null; + } +} + +const createKimiAgent: AgentPluginFactory = () => new KimiAgentPlugin(); + +export default createKimiAgent; diff --git a/src/setup/skill-installer.ts b/src/setup/skill-installer.ts index 19d97229..5960ab9d 100644 --- a/src/setup/skill-installer.ts +++ b/src/setup/skill-installer.ts @@ -18,6 +18,7 @@ export const AGENT_ID_MAP: Record = { opencode: 'opencode', codex: 'codex', gemini: 'gemini', + kimi: 'kimi-cli', kiro: 'kiro', cursor: 'cursor', 'github-copilot': 'github-copilot', diff --git a/src/tui/output-parser.ts b/src/tui/output-parser.ts index 6fffd86e..7d2a82e5 100644 --- a/src/tui/output-parser.ts +++ b/src/tui/output-parser.ts @@ -69,6 +69,126 @@ function isCodexAgent(agentPlugin?: string): boolean { return agentPlugin?.toLowerCase() === 'codex'; } +function isKimiAgent(agentPlugin?: string): boolean { + return agentPlugin?.toLowerCase() === 'kimi'; +} + +/** + * Structure of a Kimi CLI stream-json event. + * Kimi CLI uses --output-format stream-json which emits events like: + * - {"role":"assistant","content":[{"type":"think","think":"..."},{"type":"text","text":"..."}]} + * - {"role":"tool","content":[{"type":"function","function":{"name":"...","arguments":"..."}}]} + * - Tool results, status updates, etc. + */ +interface KimiEvent { + role?: string; + content?: Array<{ + type: string; + text?: string; + think?: string; + function?: { + name: string; + arguments?: string; + }; + is_error?: boolean; + output?: string; + return_value?: { + is_error?: boolean; + output?: string; + message?: string; + }; + }>; + type?: string; + text?: string; + error?: unknown; + message?: string; +} + +/** + * Parse a Kimi CLI stream-json line and return the parsed event if valid. + */ +function parseKimiJsonlLine(line: string): { success: boolean; event?: KimiEvent } { + if (!line.trim() || !line.startsWith('{')) { + return { success: false }; + } + + try { + const parsed = JSON.parse(line) as KimiEvent; + // Kimi events have a role field (assistant, tool) with a content array + if (parsed.role && Array.isArray(parsed.content)) { + return { success: true, event: parsed }; + } + // Handle top-level text events + if (parsed.type === 'text') { + return { success: true, event: parsed }; + } + // Also handle error events + if (parsed.type === 'error' || parsed.error) { + return { success: true, event: parsed }; + } + return { success: false }; + } catch { + return { success: false }; + } +} + +/** + * Format a Kimi event for display. + * Returns undefined for events that shouldn't be displayed (like think, status). + */ +function formatKimiEventForDisplay(event: KimiEvent): string | undefined { + if (!event.content || !Array.isArray(event.content)) { + // Handle top-level text events + if (event.type === 'text' && event.text) { + return event.text; + } + // Handle error events + if (event.type === 'error' || event.error) { + const msg = typeof event.error === 'string' ? event.error : + typeof event.error === 'object' && event.error !== null && 'message' in event.error + ? String((event.error as { message?: unknown }).message) + : event.message || 'Unknown error'; + return `Error: ${msg}`; + } + return undefined; + } + + const parts: string[] = []; + + for (const item of event.content) { + if (item.type === 'text' && item.text) { + parts.push(item.text); + } else if (item.type === 'function' && item.function) { + // Tool call + const toolName = item.function.name || 'unknown'; + let detail = ''; + if (item.function.arguments) { + try { + const args = JSON.parse(item.function.arguments) as Record; + const argStr = Object.entries(args) + .slice(0, 2) + .map(([k, v]) => `${k}=${typeof v === 'string' ? v.slice(0, 50) : '...'}`) + .join(', '); + detail = ` ${argStr}`; + } catch { + detail = ` ${item.function.arguments.slice(0, 50)}`; + } + } + parts.push(`[Tool: ${toolName}]${detail}`); + } else if (item.type === 'tool_result' || item.type === 'function_result') { + // Only show errors from tool results + const isError = item.is_error === true || item.return_value?.is_error === true; + if (isError) { + const errorMsg = item.output || item.return_value?.output || item.return_value?.message || 'tool execution failed'; + parts.push(`[Tool Error] ${String(errorMsg).slice(0, 200)}`); + } + } + // Skip: think (internal reasoning), status updates + } + + return parts.length > 0 ? parts.join('\n') : undefined; +} + /** * Structure of an OpenCode JSONL event. */ @@ -178,9 +298,9 @@ function formatGeminiEventForDisplay(event: GeminiEvent): string | undefined { // Tool completed - only show if error if (event.is_error === true || event.status === 'error') { const errorMsg = typeof event.error === 'string' ? event.error : - typeof event.error === 'object' && event.error !== null && 'message' in event.error - ? String((event.error as { message?: unknown }).message) - : 'tool execution failed'; + typeof event.error === 'object' && event.error !== null && 'message' in event.error + ? String((event.error as { message?: unknown }).message) + : 'tool execution failed'; return `[Tool Error] ${errorMsg}`; } // Don't display successful tool results (too verbose) @@ -190,9 +310,9 @@ function formatGeminiEventForDisplay(event: GeminiEvent): string | undefined { case 'error': { // Error from Gemini const errorMsg = typeof event.error === 'string' ? event.error : - typeof event.error === 'object' && event.error !== null && 'message' in event.error - ? String((event.error as { message?: unknown }).message) - : 'Unknown error'; + typeof event.error === 'object' && event.error !== null && 'message' in event.error + ? String((event.error as { message?: unknown }).message) + : 'Unknown error'; return `Error: ${errorMsg}`; } @@ -298,9 +418,9 @@ function formatCodexEventForDisplay(event: CodexEvent): string | undefined { // Error events if (event.type === 'error' && event.error) { const errorMsg = typeof event.error === 'string' ? event.error : - typeof event.error === 'object' && event.error !== null && 'message' in event.error - ? String((event.error as { message?: unknown }).message) - : 'Unknown error'; + typeof event.error === 'object' && event.error !== null && 'message' in event.error + ? String((event.error as { message?: unknown }).message) + : 'Unknown error'; return `Error: ${errorMsg}`; } @@ -451,6 +571,7 @@ export function parseAgentOutput(rawOutput: string, agentPlugin?: string): strin const useOpenCodeParser = isOpenCodeAgent(agentPlugin); const useGeminiParser = isGeminiAgent(agentPlugin); const useCodexParser = isCodexAgent(agentPlugin); + const useKimiParser = isKimiAgent(agentPlugin); const droidCostAccumulator = useDroidParser ? new DroidCostAccumulator() : null; let hasJsonl = false; @@ -509,6 +630,19 @@ export function parseAgentOutput(rawOutput: string, agentPlugin?: string): strin } } + // Kimi CLI parsing + if (useKimiParser) { + const kimiResult = parseKimiJsonlLine(line); + if (kimiResult.success && kimiResult.event) { + hasJsonl = true; + const kimiDisplay = formatKimiEventForDisplay(kimiResult.event); + if (kimiDisplay !== undefined) { + parsedParts.push(kimiDisplay); + } + continue; // Skip generic parsing for kimi events + } + } + // Try to parse as JSONL const parsed = parseJsonlLine(line); if (parsed !== undefined) { @@ -539,7 +673,7 @@ export function parseAgentOutput(rawOutput: string, agentPlugin?: string): strin // Fallback: return raw output truncated if it looks like unparseable JSON if (rawOutput.startsWith('{') && rawOutput.length > 500) { return '[Agent output could not be parsed - showing raw JSON]\n' + - rawOutput.slice(0, 200) + '...\n[truncated]'; + rawOutput.slice(0, 200) + '...\n[truncated]'; } return stripAnsiCodes(rawOutput); @@ -557,7 +691,7 @@ export function formatOutputForDisplay(output: string, maxLines?: number): strin const lines = formatted.split('\n'); if (lines.length > maxLines) { formatted = lines.slice(0, maxLines).join('\n') + - `\n... (${lines.length - maxLines} more lines)`; + `\n... (${lines.length - maxLines} more lines)`; } } @@ -595,6 +729,7 @@ export class StreamingOutputParser { private isOpenCode: boolean; private isGemini: boolean; private isCodex: boolean; + private isKimi: boolean; private droidCostAccumulator?: DroidCostAccumulator; constructor(options: StreamingOutputParserOptions = {}) { @@ -602,6 +737,7 @@ export class StreamingOutputParser { this.isOpenCode = isOpenCodeAgent(options.agentPlugin); this.isGemini = isGeminiAgent(options.agentPlugin); this.isCodex = isCodexAgent(options.agentPlugin); + this.isKimi = isKimiAgent(options.agentPlugin); if (this.isDroid) { this.droidCostAccumulator = new DroidCostAccumulator(); } @@ -617,6 +753,7 @@ export class StreamingOutputParser { this.isOpenCode = isOpenCodeAgent(agentPlugin); this.isGemini = isGeminiAgent(agentPlugin); this.isCodex = isCodexAgent(agentPlugin); + this.isKimi = isKimiAgent(agentPlugin); if (this.isDroid && !wasDroid) { this.droidCostAccumulator = new DroidCostAccumulator(); } @@ -758,6 +895,16 @@ export class StreamingOutputParser { } } + // Kimi CLI parsing + if (this.isKimi) { + const kimiResult = parseKimiJsonlLine(trimmed); + if (kimiResult.success && kimiResult.event) { + const kimiDisplay = formatKimiEventForDisplay(kimiResult.event); + // Return the display text or undefined (to skip think/status events) + return kimiDisplay; + } + } + // Not JSON - return as plain text if it's not just whitespace if (!trimmed.startsWith('{')) { return trimmed; @@ -901,6 +1048,30 @@ export class StreamingOutputParser { } } + // Kimi CLI segment extraction + if (this.isKimi) { + const kimiResult = parseKimiJsonlLine(trimmed); + if (kimiResult.success && kimiResult.event) { + const displayText = formatKimiEventForDisplay(kimiResult.event); + if (displayText) { + // Color based on event structure, not display string prefixes + const event = kimiResult.event; + const isToolCall = event.role === 'tool' && event.content?.some(item => item.type === 'function'); + const isError = event.type === 'error' || !!event.error || + event.content?.some(item => (item.type === 'tool_result' || item.type === 'function_result') && item.is_error === true); + if (isToolCall) { + return [{ text: displayText, color: 'cyan' }]; + } + if (isError) { + return [{ text: displayText, color: 'yellow' }]; + } + return [{ text: displayText }]; + } + // Kimi event was recognized but nothing to display (think/status events) + return []; + } + } + // Not JSON - return as plain text segment if it's not just whitespace if (!trimmed.startsWith('{')) { return [{ text: stripAnsiCodes(trimmed) }]; diff --git a/website/content/docs/plugins/agents/kimi.mdx b/website/content/docs/plugins/agents/kimi.mdx new file mode 100644 index 00000000..49189fd2 --- /dev/null +++ b/website/content/docs/plugins/agents/kimi.mdx @@ -0,0 +1,210 @@ +--- +title: Kimi Agent +description: Integrate Moonshot AI's Kimi CLI with Ralph TUI for AI-assisted coding. +--- + +## Kimi Agent + +The Kimi agent plugin integrates with Moonshot AI's `kimi` CLI to execute AI coding tasks. It supports streaming JSONL output for subagent tracing and uses `--print` mode for non-interactive operation. + + +Kimi supports **subagent tracing** via stream-json output - Ralph TUI can show tool calls in real-time as Kimi works. + + +## Prerequisites + +Install Kimi CLI following the [official Getting Started guide](https://moonshotai.github.io/kimi-cli/en/guides/getting-started.html): + +```bash +# Linux / macOS +curl -LsSf https://code.kimi.com/install.sh | bash +``` + +```powershell +# Windows (PowerShell) +Invoke-RestMethod https://code.kimi.com/install.ps1 | Invoke-Expression +``` + +Or install manually with [uv](https://docs.astral.sh/uv/): + +```bash +uv tool install --python 3.13 kimi-cli +``` + +Verify installation: + +```bash +kimi --version +``` + + +On first run, you need to configure your API source. Run `kimi` and enter `/login` to complete setup. + + +## Basic Usage + + + + Use the `--agent kimi` flag: + + ```bash + ralph-tui run --prd ./prd.json --agent kimi + ``` + + + + Override the model with `--model`: + + ```bash + ralph-tui run --prd ./prd.json --agent kimi --model kimi-k2-0711 + ``` + + + +## Configuration + +### Shorthand Config + +The simplest configuration: + +```toml +# .ralph-tui/config.toml +agent = "kimi" + +[agentOptions] +model = "kimi-k2-0711" +``` + +### Full Config + +For advanced control: + +```toml +[[agents]] +name = "my-kimi" +plugin = "kimi" +default = true +command = "kimi" +timeout = 300000 + +[agents.options] +model = "kimi-k2-0711" +``` + +### Options Reference + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `model` | string | - | Kimi model (e.g., `kimi-k2-0711`). Leave empty for default. | +| `timeout` | number | `0` | Execution timeout in ms (0 = no timeout) | +| `command` | string | `"kimi"` | Path to Kimi CLI executable | + +## Subagent Tracing + +Kimi emits structured JSONL via `--output-format stream-json` (always enabled). Ralph TUI parses this to display: + +- Text responses from the model +- Tool invocations (file reads, writes, shell commands) +- Error messages from failed operations + +### Enabling Tracing + +```toml +subagentTracingDetail = "full" +``` + +Or toggle in TUI: +- Press `t` to cycle through detail levels +- Press `T` (Shift+T) to toggle the subagent tree panel + +## How It Works + +When Ralph TUI executes a task with Kimi: + +1. **Build command**: Constructs `kimi --print --input-format text --output-format stream-json [options]` +2. **Pass prompt via stdin**: Avoids shell escaping issues with special characters +3. **Stream output**: Captures stdout/stderr in real-time +4. **Parse JSONL**: Extracts structured tool call data +5. **Detect completion**: Watches for process exit +6. **Handle exit**: Reports success, failure, or timeout + +### CLI Arguments + +Ralph TUI builds these arguments: + +```bash +kimi \ + --print \ # Non-interactive mode (implies --yolo) + --input-format text \ # Read prompt from stdin + --output-format stream-json \ # Structured JSONL output for parsing + --model kimi-k2-0711 \ # If model specified + < prompt.txt # Prompt via stdin +``` + + +`--print` mode enables non-interactive operation: Kimi processes the prompt and exits. It also auto-approves operations (equivalent to `--yolo`), which is required for Ralph TUI's autonomous workflow. + + +### Windows Compatibility + +On Windows, Kimi CLI runs with additional environment variables to avoid encoding issues: + +``` +PYTHONUTF8=1 +PYTHONIOENCODING=utf-8 +``` + +These are injected automatically by Ralph TUI. + +## Troubleshooting + +### "Kimi CLI not found" + +Ensure Kimi is installed and in your PATH: + +```bash +kimi --version + +# If not found, install: +curl -LsSf https://code.kimi.com/install.sh | bash +``` + +### "Not logged in" + +Kimi CLI requires authentication on first run: + +```bash +kimi +# Then type: /login +``` + +Or use the subcommand: + +```bash +kimi login +``` + +### "Execution timeout" + +Increase the timeout for complex tasks: + +```toml +[[agents]] +name = "kimi" +plugin = "kimi" +timeout = 600000 # 10 minutes +``` + +### Encoding errors on Windows + +If you see `charmap` codec errors, ensure Python is configured for UTF-8. Ralph TUI sets this automatically, but you can also set it system-wide: + +```powershell +$env:PYTHONUTF8 = "1" +``` + +## Next Steps + +- **[Gemini Agent](/docs/plugins/agents/gemini)** - Google's Gemini CLI +- **[Claude Agent](/docs/plugins/agents/claude)** - Anthropic's Claude Code +- **[Configuration](/docs/configuration/options)** - Full options reference diff --git a/website/lib/navigation.ts b/website/lib/navigation.ts index 1064a90c..e5b59974 100644 --- a/website/lib/navigation.ts +++ b/website/lib/navigation.ts @@ -85,6 +85,7 @@ export const docsNavigation: NavItem[] = [ { title: 'Cursor', href: '/docs/plugins/agents/cursor', label: 'New' }, { title: 'Gemini', href: '/docs/plugins/agents/gemini', label: 'New' }, { title: 'GitHub Copilot', href: '/docs/plugins/agents/github-copilot', label: 'New' }, + { title: 'Kimi', href: '/docs/plugins/agents/kimi', label: 'New' }, { title: 'Kiro', href: '/docs/plugins/agents/kiro', label: 'New' }, ], },