chatmcp/lib/llm/claude_code_client.dart

216 lines
7.4 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:logging/logging.dart';
import 'base_llm_client.dart';
import 'model.dart';
/// Claude Code client backed by the `claude` CLI.
///
/// This client shells out to the Claude Code SDK CLI, so it works without
/// directly using Anthropic HTTP APIs. It expects the `claude` binary to be
/// available on PATH (or via a configured absolute path), and that the user
/// has already authenticated per Claude Code SDK docs.
///
/// Non-goals:
/// - Tool calling is not wired, as Claude Code has its own tool system
/// (Read/Write/Bash). We ignore `tools` in requests.
class ClaudeCodeClient extends BaseLLMClient {
/// Path or name of the Claude Code CLI executable. Defaults to `claude`.
final String executable;
ClaudeCodeClient({String? executablePath}) : executable = (executablePath == null || executablePath.isEmpty) ? 'claude' : executablePath;
@override
Future<LLMResponse> chatCompletion(CompletionRequest request) async {
final prompt = _buildPromptFromMessages(request.messages);
// Use JSON output for reliable parsing
final args = <String>['-p', prompt, '--output-format', 'json'];
try {
final result = await Process.run(executable, args, runInShell: true);
if (result.exitCode != 0) {
throw Exception(
result.stderr is String && (result.stderr as String).isNotEmpty ? result.stderr : 'Claude Code CLI exited with ${result.exitCode}',
);
}
final output = result.stdout is String ? result.stdout as String : utf8.decode((result.stdout as List<int>));
final content = _parseClaudeCodeJsonOutput(output);
return LLMResponse(content: content);
} catch (e) {
// Surface a structured error via BaseLLMClient.handleError contract
throw await handleError(e, 'Claude Code', executable, jsonEncode({'args': args}));
}
}
@override
Stream<LLMResponse> chatStreamCompletion(CompletionRequest request) async* {
final prompt = _buildPromptFromMessages(request.messages);
// Stream JSON lines so we can emit partial updates when possible.
final args = <String>['-p', prompt, '--output-format', 'stream-json'];
Process? process;
try {
process = await Process.start(executable, args, runInShell: true);
final stdoutLines = process.stdout.transform(utf8.decoder).transform(const LineSplitter());
final stderrBuffer = StringBuffer();
// Drain stderr to help debugging on failure
unawaited(process.stderr.transform(utf8.decoder).forEach(stderrBuffer.write));
String aggregated = '';
await for (final line in stdoutLines) {
// Expecting JSONL objects, one per line
try {
final obj = jsonDecode(line);
final type = obj['type'];
if (type == 'assistant') {
final contentPiece = _extractTextFromAssistantMessage(obj['message']);
if (contentPiece.isNotEmpty) {
aggregated += contentPiece;
yield LLMResponse(content: contentPiece);
}
} else if (type == 'result') {
// Final result line; emit any missing content if stream lacked assistant pieces
final resultText = (obj['result'] ?? '').toString();
if (aggregated.isEmpty && resultText.isNotEmpty) {
yield LLMResponse(content: resultText);
}
}
} catch (_) {
// If a line isn't valid JSON, ignore it; CLI may print logs in verbose modes
continue;
}
}
final exitCode = await process.exitCode;
if (exitCode != 0) {
throw Exception(stderrBuffer.isNotEmpty ? stderrBuffer.toString() : 'Claude Code CLI exited with $exitCode');
}
} catch (e) {
throw await handleError(e, 'Claude Code', executable, jsonEncode({'args': args}));
} finally {
// Ensure process is killed if still alive
if (process != null) {
try {
process.kill(ProcessSignal.sigterm);
} catch (_) {}
}
}
}
@override
Future<List<String>> models() async {
// Claude Code CLI does not expose a model listing endpoint.
// Return a common set; users can customize in settings.
return <String>['claude-3-7-sonnet', 'claude-3-opus', 'claude-3-5-sonnet', 'claude-3-5-haiku'];
}
String _buildPromptFromMessages(List<ChatMessage> messages) {
// Keep recent context small to avoid overly long shell args.
const int maxMessages = 12;
final recent = messages.length <= maxMessages ? messages : messages.sublist(messages.length - maxMessages);
final buffer = StringBuffer();
for (final m in recent) {
final content = (m.content ?? '').trim();
if (content.isEmpty) continue;
switch (m.role) {
case MessageRole.system:
buffer.writeln('System: $content');
break;
case MessageRole.user:
buffer.writeln('User: $content');
break;
case MessageRole.assistant:
buffer.writeln('Assistant: $content');
break;
default:
// Ignore tool/function/error/loading in prompt for Claude Code
break;
}
}
return buffer.toString().trim();
}
/// Parse JSON output from `claude -p --output-format json`.
/// The CLI typically prints a single JSON object. If multiple JSONL lines
/// are present, prefer the final `result` type; otherwise accumulate
/// assistant messages.
String _parseClaudeCodeJsonOutput(String output) {
output = output.trim();
if (output.isEmpty) return '';
// Try strict JSON first
try {
final obj = jsonDecode(output);
final type = obj is Map<String, dynamic> ? obj['type'] : null;
if (type == 'result') {
return (obj['result'] ?? '').toString();
}
if (type == 'assistant') {
return _extractTextFromAssistantMessage(obj['message']);
}
// Some versions might output a flat object with `result`
if (obj is Map<String, dynamic> && obj.containsKey('result')) {
return (obj['result'] ?? '').toString();
}
} catch (_) {
// Fall through to JSONL mode
}
// Fallback: parse line-by-line as JSONL
String aggregated = '';
for (final line in const LineSplitter().convert(output)) {
final trimmed = line.trim();
if (trimmed.isEmpty) continue;
try {
final obj = jsonDecode(trimmed);
final type = obj['type'];
if (type == 'assistant') {
aggregated += _extractTextFromAssistantMessage(obj['message']);
} else if (type == 'result') {
if (aggregated.isEmpty) {
aggregated = (obj['result'] ?? '').toString();
}
}
} catch (_) {
continue;
}
}
return aggregated;
}
/// Extract plain text from an Anthropic SDK `Message` object
/// produced by Claude Code CLI JSON messages.
String _extractTextFromAssistantMessage(dynamic messageObj) {
if (messageObj == null) return '';
try {
final content = messageObj['content'];
if (content is List) {
final texts = <String>[];
for (final part in content) {
if (part is Map<String, dynamic>) {
final type = part['type'];
if (type == 'text' && part['text'] is String) {
texts.add(part['text'] as String);
}
}
}
return texts.join('');
}
} catch (e) {
Logger.root.fine('Failed to parse assistant message content: $e');
}
return '';
}
}