Files
CodePilot/src/lib/claude-client.ts
T
7418andClaude Opus 4.6 81125a233b fix: auto-detect git-bash path on Windows (closes #23)
Add findGitBash() to platform.ts that checks env var, common install
paths, and derives path from `where git`. Used in claude-client.ts
to set CLAUDE_CODE_GIT_BASH_PATH when not already configured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 20:54:03 +08:00

487 lines
17 KiB
TypeScript

import { query } from '@anthropic-ai/claude-agent-sdk';
import type {
SDKMessage,
SDKAssistantMessage,
SDKUserMessage,
SDKResultMessage,
SDKPartialAssistantMessage,
SDKSystemMessage,
SDKToolProgressMessage,
Options,
McpStdioServerConfig,
McpSSEServerConfig,
McpHttpServerConfig,
McpServerConfig,
NotificationHookInput,
PostToolUseHookInput,
} from '@anthropic-ai/claude-agent-sdk';
import type { ClaudeStreamOptions, SSEEvent, TokenUsage, MCPServerConfig, PermissionRequestEvent } from '@/types';
import { registerPendingPermission } from './permission-registry';
import { getSetting, getActiveProvider } from './db';
import { findClaudeBinary, findGitBash, getExpandedPath } from './platform';
import os from 'os';
let cachedClaudePath: string | null | undefined;
function findClaudePath(): string | undefined {
if (cachedClaudePath !== undefined) return cachedClaudePath || undefined;
const found = findClaudeBinary();
cachedClaudePath = found ?? null;
return found;
}
/**
* Convert our MCPServerConfig to the SDK's McpServerConfig format.
* Supports stdio, sse, and http transport types.
*/
function toSdkMcpConfig(
servers: Record<string, MCPServerConfig>
): Record<string, McpServerConfig> {
const result: Record<string, McpServerConfig> = {};
for (const [name, config] of Object.entries(servers)) {
const transport = config.type || 'stdio';
switch (transport) {
case 'sse': {
if (!config.url) {
console.warn(`[mcp] SSE server "${name}" is missing url, skipping`);
continue;
}
const sseConfig: McpSSEServerConfig = {
type: 'sse',
url: config.url,
};
if (config.headers && Object.keys(config.headers).length > 0) {
sseConfig.headers = config.headers;
}
result[name] = sseConfig;
break;
}
case 'http': {
if (!config.url) {
console.warn(`[mcp] HTTP server "${name}" is missing url, skipping`);
continue;
}
const httpConfig: McpHttpServerConfig = {
type: 'http',
url: config.url,
};
if (config.headers && Object.keys(config.headers).length > 0) {
httpConfig.headers = config.headers;
}
result[name] = httpConfig;
break;
}
case 'stdio':
default: {
if (!config.command) {
console.warn(`[mcp] stdio server "${name}" is missing command, skipping`);
continue;
}
const stdioConfig: McpStdioServerConfig = {
command: config.command,
args: config.args,
env: config.env,
};
result[name] = stdioConfig;
break;
}
}
}
return result;
}
/**
* Format an SSE line from an event object
*/
function formatSSE(event: SSEEvent): string {
return `data: ${JSON.stringify(event)}\n\n`;
}
/**
* Extract text content from an SDK assistant message
*/
function extractTextFromMessage(msg: SDKAssistantMessage): string {
const parts: string[] = [];
for (const block of msg.message.content) {
if (block.type === 'text') {
parts.push(block.text);
}
}
return parts.join('');
}
/**
* Extract token usage from an SDK result message
*/
function extractTokenUsage(msg: SDKResultMessage): TokenUsage | null {
if (!msg.usage) return null;
return {
input_tokens: msg.usage.input_tokens,
output_tokens: msg.usage.output_tokens,
cache_read_input_tokens: msg.usage.cache_read_input_tokens ?? 0,
cache_creation_input_tokens: msg.usage.cache_creation_input_tokens ?? 0,
cost_usd: 'total_cost_usd' in msg ? msg.total_cost_usd : undefined,
};
}
/**
* Stream Claude responses using the Agent SDK.
* Returns a ReadableStream of SSE-formatted strings.
*/
export function streamClaude(options: ClaudeStreamOptions): ReadableStream<string> {
const {
prompt,
sdkSessionId,
model,
systemPrompt,
workingDirectory,
mcpServers,
abortController,
permissionMode,
} = options;
return new ReadableStream<string>({
async start(controller) {
try {
// Build env for the Claude Code subprocess.
// Start with process.env (includes user shell env from Electron's loadUserShellEnv).
// Then overlay any API config the user set in CodePilot settings (optional).
const sdkEnv: Record<string, string> = { ...process.env as Record<string, string> };
// Ensure HOME/USERPROFILE are set so Claude Code can find ~/.claude/commands/
if (!sdkEnv.HOME) sdkEnv.HOME = os.homedir();
if (!sdkEnv.USERPROFILE) sdkEnv.USERPROFILE = os.homedir();
// Ensure SDK subprocess has expanded PATH (consistent with Electron mode)
sdkEnv.PATH = getExpandedPath();
// On Windows, auto-detect Git Bash if not already configured
if (process.platform === 'win32' && !process.env.CLAUDE_CODE_GIT_BASH_PATH) {
const gitBashPath = findGitBash();
if (gitBashPath) {
sdkEnv.CLAUDE_CODE_GIT_BASH_PATH = gitBashPath;
}
}
// Try to get config from active provider first
const activeProvider = getActiveProvider();
if (activeProvider && activeProvider.api_key) {
// Clear all existing ANTHROPIC_* variables to prevent conflicts
for (const key of Object.keys(sdkEnv)) {
if (key.startsWith('ANTHROPIC_')) {
delete sdkEnv[key];
}
}
// Inject provider config — set both token variants so extra_env can clear the unwanted one
sdkEnv.ANTHROPIC_AUTH_TOKEN = activeProvider.api_key;
sdkEnv.ANTHROPIC_API_KEY = activeProvider.api_key;
if (activeProvider.base_url) {
sdkEnv.ANTHROPIC_BASE_URL = activeProvider.base_url;
}
// Inject extra environment variables
// Empty string values mean "delete this variable" (e.g. clear ANTHROPIC_API_KEY for AUTH_TOKEN-only providers)
try {
const extraEnv = JSON.parse(activeProvider.extra_env || '{}');
for (const [key, value] of Object.entries(extraEnv)) {
if (typeof value === 'string') {
if (value === '') {
delete sdkEnv[key];
} else {
sdkEnv[key] = value;
}
}
}
} catch {
// ignore malformed extra_env
}
} else {
// No active provider — check legacy DB settings first, then fall back to
// environment variables already present in process.env (copied into sdkEnv above).
// This allows users who set ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_BASE_URL
// in their shell environment to use them without configuring a provider in the UI.
const appToken = getSetting('anthropic_auth_token');
const appBaseUrl = getSetting('anthropic_base_url');
if (appToken) {
sdkEnv.ANTHROPIC_AUTH_TOKEN = appToken;
}
if (appBaseUrl) {
sdkEnv.ANTHROPIC_BASE_URL = appBaseUrl;
}
// If neither legacy settings nor env vars provide a key, log a warning
if (!appToken && !sdkEnv.ANTHROPIC_API_KEY && !sdkEnv.ANTHROPIC_AUTH_TOKEN) {
console.warn('[claude-client] No API key found: no active provider, no legacy settings, and no ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN in environment');
}
}
const queryOptions: Options = {
cwd: workingDirectory || process.cwd(),
abortController,
includePartialMessages: true,
permissionMode: (permissionMode as Options['permissionMode']) || 'acceptEdits',
env: sdkEnv,
};
// Find claude binary for packaged app where PATH is limited
const claudePath = findClaudePath();
if (claudePath) {
queryOptions.pathToClaudeCodeExecutable = claudePath;
}
if (model) {
queryOptions.model = model;
}
if (systemPrompt) {
// Use preset append mode to keep Claude Code's default system prompt
// (which includes skills, working directory awareness, etc.)
queryOptions.systemPrompt = {
type: 'preset',
preset: 'claude_code',
append: systemPrompt,
};
}
if (mcpServers && Object.keys(mcpServers).length > 0) {
queryOptions.mcpServers = toSdkMcpConfig(mcpServers);
}
// Resume session if we have an SDK session ID from a previous conversation turn
if (sdkSessionId) {
queryOptions.resume = sdkSessionId;
}
// Permission handler: sends SSE event and waits for user response
queryOptions.canUseTool = async (toolName, input, opts) => {
const permissionRequestId = `perm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const permEvent: PermissionRequestEvent = {
permissionRequestId,
toolName,
toolInput: input,
suggestions: opts.suggestions as PermissionRequestEvent['suggestions'],
decisionReason: opts.decisionReason,
blockedPath: opts.blockedPath,
toolUseId: opts.toolUseID,
description: undefined,
};
// Send permission_request SSE event to the client
controller.enqueue(formatSSE({
type: 'permission_request',
data: JSON.stringify(permEvent),
}));
// Wait for user response (resolved by POST /api/chat/permission)
// Store original input so registry can inject updatedInput on allow
return registerPendingPermission(permissionRequestId, input, opts.signal);
};
// Hooks: capture notifications and tool completion events
queryOptions.hooks = {
Notification: [{
hooks: [async (input) => {
const notif = input as NotificationHookInput;
controller.enqueue(formatSSE({
type: 'status',
data: JSON.stringify({
notification: true,
title: notif.title,
message: notif.message,
}),
}));
return {};
}],
}],
PostToolUse: [{
hooks: [async (input) => {
const toolEvent = input as PostToolUseHookInput;
controller.enqueue(formatSSE({
type: 'tool_result',
data: JSON.stringify({
tool_use_id: toolEvent.tool_use_id,
content: typeof toolEvent.tool_response === 'string'
? toolEvent.tool_response
: JSON.stringify(toolEvent.tool_response),
is_error: false,
}),
}));
return {};
}],
}],
};
// Capture real-time stderr output from Claude Code process
queryOptions.stderr = (data: string) => {
// Diagnostic: log raw stderr data length to server console
console.log(`[stderr] received ${data.length} bytes, first 200 chars:`, data.slice(0, 200).replace(/[\x00-\x1F\x7F]/g, '?'));
// Strip ANSI escape codes, OSC sequences, and control characters
// but preserve tabs (\x09) and carriage returns (\x0D)
const cleaned = data
.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '') // CSI sequences (colors, cursor)
.replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)/g, '') // OSC sequences
.replace(/\x1B\([A-Z]/g, '') // Character set selection
.replace(/\x1B[=>]/g, '') // Keypad mode
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '') // Control chars (keep \t \n \r)
.replace(/\r\n/g, '\n') // Normalize CRLF
.replace(/\r/g, '\n') // Convert remaining CR to LF
.replace(/\n{3,}/g, '\n\n') // Collapse multiple blank lines
.trim();
if (cleaned) {
controller.enqueue(formatSSE({
type: 'tool_output',
data: cleaned,
}));
}
};
const conversation = query({
prompt,
options: queryOptions,
});
let lastAssistantText = '';
let tokenUsage: TokenUsage | null = null;
for await (const message of conversation) {
if (abortController?.signal.aborted) {
break;
}
switch (message.type) {
case 'assistant': {
const assistantMsg = message as SDKAssistantMessage;
// Text deltas are handled by stream_event for real-time streaming.
// Only track lastAssistantText here and process tool_use blocks.
const text = extractTextFromMessage(assistantMsg);
if (text) {
lastAssistantText = text;
}
// Check for tool use blocks
for (const block of assistantMsg.message.content) {
if (block.type === 'tool_use') {
controller.enqueue(formatSSE({
type: 'tool_use',
data: JSON.stringify({
id: block.id,
name: block.name,
input: block.input,
}),
}));
}
}
break;
}
case 'user': {
// Tool execution results come back as user messages with tool_result blocks
const userMsg = message as SDKUserMessage;
const content = userMsg.message.content;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'tool_result') {
const resultContent = typeof block.content === 'string'
? block.content
: Array.isArray(block.content)
? block.content
.filter((c: { type: string }) => c.type === 'text')
.map((c: { text: string }) => c.text)
.join('\n')
: String(block.content ?? '');
controller.enqueue(formatSSE({
type: 'tool_result',
data: JSON.stringify({
tool_use_id: block.tool_use_id,
content: resultContent,
is_error: block.is_error || false,
}),
}));
}
}
}
break;
}
case 'stream_event': {
const streamEvent = message as SDKPartialAssistantMessage;
const evt = streamEvent.event;
if (evt.type === 'content_block_delta' && 'delta' in evt) {
const delta = evt.delta;
if ('text' in delta && delta.text) {
controller.enqueue(formatSSE({ type: 'text', data: delta.text }));
}
}
break;
}
case 'system': {
const sysMsg = message as SDKSystemMessage;
if ('subtype' in sysMsg) {
if (sysMsg.subtype === 'init') {
controller.enqueue(formatSSE({
type: 'status',
data: JSON.stringify({
session_id: sysMsg.session_id,
model: sysMsg.model,
tools: sysMsg.tools,
}),
}));
}
}
break;
}
case 'tool_progress': {
const progressMsg = message as SDKToolProgressMessage;
controller.enqueue(formatSSE({
type: 'tool_output',
data: JSON.stringify({
_progress: true,
tool_use_id: progressMsg.tool_use_id,
tool_name: progressMsg.tool_name,
elapsed_time_seconds: progressMsg.elapsed_time_seconds,
}),
}));
break;
}
case 'result': {
const resultMsg = message as SDKResultMessage;
tokenUsage = extractTokenUsage(resultMsg);
controller.enqueue(formatSSE({
type: 'result',
data: JSON.stringify({
subtype: resultMsg.subtype,
is_error: resultMsg.is_error,
num_turns: resultMsg.num_turns,
duration_ms: resultMsg.duration_ms,
usage: tokenUsage,
session_id: resultMsg.session_id,
}),
}));
break;
}
}
}
controller.enqueue(formatSSE({ type: 'done', data: '' }));
controller.close();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
controller.enqueue(formatSSE({ type: 'error', data: errorMessage }));
controller.enqueue(formatSSE({ type: 'done', data: '' }));
controller.close();
}
},
cancel() {
abortController?.abort();
},
});
}