import { IDuckProvider, ChatOptions, ChatResponse, ModelInfo } from '../types.js'; import { ResolvedCLIConfig } from './presets.js'; import { runCLIProcess } from './process-runner.js'; import { parseTextOutput, parseJsonOutput, parseJsonlOutput } from './output-parsers.js'; import { getTextContent } from '../../config/types.js'; import { logger } from '../../utils/logger.js'; export class CLIDuckProvider implements IDuckProvider { public name: string; public nickname: string; private config: ResolvedCLIConfig; constructor(name: string, config: ResolvedCLIConfig) { this.name = name; this.nickname = config.nickname; this.config = config; } async chat(options: ChatOptions): Promise { const prompt = this.extractPrompt(options); const { args, stdin } = this.buildCommandArgs(prompt, options); const modelToUse = options.model || this.config.default_model || 'cli'; try { const result = await runCLIProcess({ command: this.config.cli_command, args, stdin, timeout: this.config.process_timeout, cwd: this.config.working_directory, env: this.config.env_vars, }); const parsed = this.parseOutput(result.stdout); return { content: parsed.content, usage: parsed.usage, model: modelToUse, finishReason: 'stop', }; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`CLI provider ${this.name} error:`, error); throw new Error(`Duck ${this.nickname} couldn't respond: ${errorMessage}`); } } async healthCheck(): Promise { try { const { args, stdin } = this.buildCommandArgs('Say hello', { messages: [{ role: 'user', content: 'Say hello', timestamp: new Date() }], }); await runCLIProcess({ command: this.config.cli_command, args, stdin, timeout: Math.min(this.config.process_timeout, 30000), cwd: this.config.working_directory, env: this.config.env_vars, }); return true; } catch (error) { logger.warn(`Health check failed for CLI provider ${this.name}:`, error); return false; } } listModels(): Promise { if (this.config.models && this.config.models.length > 0) { return Promise.resolve( this.config.models.map((id) => ({ id, description: `Configured CLI model`, })) ); } return Promise.resolve([ { id: this.config.default_model || 'default', description: `Default CLI model for ${this.config.cli_type}`, }, ]); } getInfo() { return { name: this.name, nickname: this.nickname, model: this.config.default_model || 'cli', type: 'cli' as const, availableModels: this.config.models, cliCommand: this.config.cli_command, cliType: this.config.cli_type, }; } private extractPrompt(options: ChatOptions): string { // Use the last user message as the prompt const messages = options.messages; for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === 'user') { return getTextContent(messages[i].content); } } return getTextContent(messages[messages.length - 1]?.content ?? ''); } private buildCommandArgs( prompt: string, options: ChatOptions ): { args: string[]; stdin?: string } { const args = [...this.config.cli_args]; let stdin: string | undefined; // Add system prompt if configured const systemPrompt = options.systemPrompt || this.config.system_prompt; let systemPromptHandled = false; if (systemPrompt) { // For CLI tools that support system prompts via flags if (this.config.cli_type === 'claude') { this.removeFlag(args, '--system-prompt'); args.push('--system-prompt', systemPrompt); systemPromptHandled = true; } // For other tools with non-stdin delivery, prepend to the prompt else if (this.config.prompt_delivery !== 'stdin') { prompt = `${systemPrompt}\n\n${prompt}`; systemPromptHandled = true; } } // Add model override if specified if (options.model) { const modelFlag = this.getModelFlag(); if (modelFlag) { // Remove any existing model flag from cli_args to avoid duplicates // (e.g. grok preset includes '-m grok-code-fast-1' in cli_args) this.removeFlag(args, modelFlag); args.push(modelFlag, options.model); } } // Deliver the prompt switch (this.config.prompt_delivery) { case 'flag': if (this.config.prompt_flag) { args.push(this.config.prompt_flag, prompt); } break; case 'positional': args.push(prompt); break; case 'stdin': stdin = systemPrompt && !systemPromptHandled ? `${systemPrompt}\n\n${prompt}` : prompt; break; } return { args, stdin }; } private getModelFlag(): string | undefined { switch (this.config.cli_type) { case 'claude': case 'codex': case 'gemini': case 'aider': return '--model'; case 'grok': return '-m'; default: return undefined; } } /** * Remove a flag and its value from an args array in-place. * Handles both `--flag value` and `-f value` patterns. */ private removeFlag(args: string[], flag: string): void { let i = 0; while (i < args.length) { if (args[i] === flag) { // Remove the flag and its value (next element) args.splice(i, 2); } else { i++; } } } private parseOutput(stdout: string) { switch (this.config.output_format) { case 'json': return parseJsonOutput(stdout, this.config.response_json_path, this.config.usage_json_path); case 'jsonl': return parseJsonlOutput(stdout, this.config.response_json_path); case 'text': default: return parseTextOutput(stdout); } } }