Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 112 additions & 6 deletions packages/core/src/server/ai-provider-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,35 @@ interface ClaudeCliInputMessage {
const INLINE_IMAGE_DATA_URL_REGEX =
/data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=]+)/g;

const CLAUDE_MODEL_LOG_MAX_CHARS = 1800;

function truncateForLog(text: string, max = CLAUDE_MODEL_LOG_MAX_CHARS): string {
if (!text) return '';
if (text.length <= max) return text;
return `${text.slice(0, max)}...[truncated ${text.length - max} chars]`;
}

function stringifyForLog(value: unknown): string {
if (typeof value === 'string') {
return truncateForLog(value);
}
try {
return truncateForLog(JSON.stringify(value));
} catch {
return truncateForLog(String(value));
}
}

function logClaudeModelIO(stage: string, payload: unknown): void {
const prefix = chalk.gray('[claude-model-io]');
const output = stringifyForLog(payload);
if (output) {
console.log(`${prefix} ${stage}: ${output}`);
} else {
console.log(`${prefix} ${stage}`);
}
}

function stripInlineImageDataUrls(text: string): string {
return text.replace(
INLINE_IMAGE_DATA_URL_REGEX,
Expand Down Expand Up @@ -235,12 +264,20 @@ export async function getModelInfo(

try {
const model = await new Promise<string>((resolve) => {
const probeArgs = ['-p', 'hi', '--output-format', 'stream-json', '--verbose'];
const probeUseShell = cliPathNeedsShellSpawnOnWindows(cliPath);
logClaudeModelIO('probe.spawn', {
cliPath,
args: probeArgs,
shell: probeUseShell,
});
const child = spawn(
cliPath,
['-p', 'hi', '--output-format', 'stream-json', '--verbose'],
probeArgs,
{
stdio: ['pipe', 'pipe', 'pipe'],
env: getEnvVars(),
...(probeUseShell ? { shell: true } : {}),
},
);
child.stdin?.end();
Expand All @@ -258,6 +295,7 @@ export async function getModelInfo(

for (const line of lines) {
if (!line.trim()) continue;
logClaudeModelIO('probe.stdout', line);
try {
const event = JSON.parse(line);
if (event.type === 'system' && event.model) {
Expand Down Expand Up @@ -537,6 +575,42 @@ export function handleClaudeRequest(
/** 缓存的 CLI 路径 */
let cachedCliPath: string | null | undefined = undefined;

/**
* Windows 上 `where claude` 常指向 npm 目录里无后缀的 `claude`(实为 Unix shell 脚本)。
* `child_process.spawn` 无法将其作为可执行文件启动,会报 ENOENT;同目录下的 `claude.cmd` 才是可 spawn 的入口。
*/
function normalizeClaudeCliPathForWindowsSpawn(resolvedPath: string): string {
if (process.platform !== 'win32') {
return resolvedPath;
}
const ext = path.extname(resolvedPath).toLowerCase();
if (
ext === '.cmd' ||
ext === '.bat' ||
ext === '.exe' ||
ext === '.com' ||
ext === '.ps1'
) {
return resolvedPath;
}
if (path.basename(resolvedPath) !== 'claude') {
return resolvedPath;
}
const cmdPath = path.join(path.dirname(resolvedPath), 'claude.cmd');
return fs.existsSync(cmdPath) ? cmdPath : resolvedPath;
}

/**
* Windows 上不能直接 spawn `.cmd`/`.bat`/`.ps1`(会 EINVAL),需 `shell: true` 经 cmd 解释。
*/
function cliPathNeedsShellSpawnOnWindows(cliPath: string): boolean {
if (process.platform !== 'win32') {
return false;
}
const ext = path.extname(cliPath).toLowerCase();
return ext === '.cmd' || ext === '.bat' || ext === '.ps1';
}

/**
* 查找本地 Claude Code CLI 路径
*/
Expand All @@ -553,7 +627,8 @@ function findClaudeCodeCli(): string | null {
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
if (result) {
cachedCliPath = result.split('\n')[0];
const first = result.split(/\r?\n/)[0].trim();
cachedCliPath = normalizeClaudeCliPathForWindowsSpawn(first);
return cachedCliPath;
}
} catch {
Expand All @@ -571,7 +646,7 @@ function findClaudeCodeCli(): string | null {

for (const p of possiblePaths) {
if (fs.existsSync(p)) {
cachedCliPath = p;
cachedCliPath = normalizeClaudeCliPathForWindowsSpawn(p);
return cachedCliPath;
}
}
Expand All @@ -596,7 +671,16 @@ function queryViaCli(
onSessionId?: (id: string) => void,
): ChildProcess {
const opts = getClaudeCliOptions(aiOptions);
const args = inputMessage
// On Windows with shell spawning, long multiline prompt passed as CLI args can
// be mangled by cmd parsing. Force stream-json stdin input to avoid arg parsing issues.
const forceStreamInputOnWindows = process.platform === 'win32';
const effectiveInputMessage =
inputMessage ||
(forceStreamInputOnWindows
? buildClaudeCliInputMessage(prompt, [], sessionId)
: undefined);

const args = effectiveInputMessage
? [
'-p',
'--output-format',
Expand Down Expand Up @@ -661,15 +745,28 @@ function queryViaCli(
}

const env = { ...getEnvVars(), ...opts?.env };
const useShell = cliPathNeedsShellSpawnOnWindows(cliPath);
logClaudeModelIO('cli.spawn', {
cliPath,
args,
cwd,
shell: useShell,
hasInputMessage: Boolean(effectiveInputMessage),
promptPreview: effectiveInputMessage ? '[sent via stream-json stdin]' : prompt,
});

const child = spawn(cliPath, args, {
cwd,
env,
stdio: ['pipe', 'pipe', 'pipe'],
...(useShell ? { shell: true } : {}),
});

if (inputMessage) {
child.stdin?.write(`${JSON.stringify(inputMessage)}\n`);
if (effectiveInputMessage) {
logClaudeModelIO('cli.stdin', effectiveInputMessage);
child.stdin?.write(`${JSON.stringify(effectiveInputMessage)}\n`);
} else {
logClaudeModelIO('cli.prompt', prompt);
}
child.stdin?.end();

Expand All @@ -689,6 +786,7 @@ function queryViaCli(

for (const line of lines) {
if (!line.trim()) continue;
logClaudeModelIO('cli.stdout', line);

try {
const event = JSON.parse(line);
Expand Down Expand Up @@ -997,10 +1095,17 @@ async function queryViaSdk(
}

const queryOptions = buildSdkQueryOptions(aiOptions, cwd, sessionId);
logClaudeModelIO('sdk.query.options', queryOptions);
if (typeof prompt === 'string') {
logClaudeModelIO('sdk.query.prompt', prompt);
} else {
logClaudeModelIO('sdk.query.prompt', '[async iterable multimodal input]');
}
if (typeof queryOptions.stderr !== 'function') {
queryOptions.stderr = (data: string) => {
const text = String(data || '').trim();
if (text) {
logClaudeModelIO('sdk.stderr', text);
sendSSE({ type: 'info', message: text });
}
};
Expand Down Expand Up @@ -1042,6 +1147,7 @@ async function queryViaSdk(

try {
for await (const sdkMessage of conversation) {
logClaudeModelIO('sdk.event', sdkMessage);
if (isAborted()) {
conversation.interrupt();
break;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/types/server/ai-provider-claude.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ declare function findClaudeCodeCli(): string | null;
*/
declare function queryViaCli(cliPath: string, prompt: string, inputMessage: ClaudeCliInputMessage | undefined, cwd: string, aiOptions: ClaudeCodeOptions | undefined, onData: (data: string) => void, onError: (error: string) => void, onEnd: () => void, sessionId?: string, onSessionId?: (id: string) => void): ChildProcess;
declare function getClaudeQuery(): Promise<Function | null>;
declare function setupSdkEnvironment(aiOptions?: ClaudeCodeOptions): void;
declare function setupSdkEnvironment(aiOptions?: ClaudeCodeOptions): () => void;
declare function buildSdkQueryOptions(aiOptions: ClaudeCodeOptions | undefined, cwd: string, sessionId?: string): Record<string, any>;
declare function queryViaSdk(prompt: string | AsyncIterable<any>, cwd: string, aiOptions: ClaudeCodeOptions | undefined, sessionId: string | undefined, sendSSE: (data: object | string) => void, isAborted: () => boolean): Promise<{
timedOut: boolean;
Expand Down