Files
ccs/src/targets/claude-adapter.ts
Kai (Tam Nhu) Tran 499aec8a9b feat(config): add opt-in output limits for spawned CLI (#1544)
Adds opt-in config.runtime.outputLimits to inject MAX_MCP_OUTPUT_TOKENS/BASH_MAX_OUTPUT_LENGTH into the spawned CLI; defaults unchanged when unset. Closes #231.
2026-06-15 22:16:54 -04:00

185 lines
5.9 KiB
TypeScript

/**
* Claude Adapter
*
* TargetAdapter implementation for Claude Code CLI.
* Wraps existing detection, spawning, and execution logic.
*/
import { spawn, ChildProcess } from 'child_process';
import { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from './target-adapter';
import { detectClaudeCli, getClaudeCliInfo } from '../utils/claude-detector';
import type { ProfileType } from '../types/profile';
import {
escapeShellArg,
getWindowsEscapedCommandShell,
stripBrowserEnv,
stripAnthropicEnv,
stripClaudeCodeEnv,
} from '../utils/shell-executor';
import { ErrorManager } from '../utils/error-manager';
import { getWebSearchHookEnv } from '../utils/websearch-manager';
import { getOutputLimitsEnv } from '../config/config-loader-facade';
import { appendBrowserToolArgs } from '../utils/browser';
import { wireChildProcessSignals } from '../utils/signal-forwarder';
import { runCleanup } from '../errors';
import { createLogger } from '../services/logging';
const adapterLogger = createLogger('targets:claude');
export class ClaudeAdapter implements TargetAdapter {
readonly type: TargetType = 'claude';
readonly displayName = 'Claude Code';
detectBinary(): TargetBinaryInfo | null {
const info = getClaudeCliInfo();
if (!info) return null;
return { path: info.path, needsShell: info.needsShell };
}
/**
* Claude uses env vars for credential delivery — no config file writes needed.
*/
async prepareCredentials(_creds: TargetCredentials): Promise<void> {
// No-op: Claude receives credentials via environment variables
}
buildArgs(
_profile: string,
userArgs: string[],
options?: {
creds?: TargetCredentials;
profileType?: ProfileType;
binaryInfo?: TargetBinaryInfo;
}
): string[] {
return options?.creds?.browserRuntimeEnv ? appendBrowserToolArgs(userArgs) : userArgs;
}
buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv {
const webSearchEnv = getWebSearchHookEnv();
// For account/default profiles, strip ANTHROPIC_* from parent env to prevent
// stale proxy config from interfering with native Claude API routing.
const baseEnv =
profileType === 'account' || profileType === 'default'
? stripAnthropicEnv(process.env)
: process.env;
const env: NodeJS.ProcessEnv = { ...stripBrowserEnv(baseEnv), ...webSearchEnv };
if (creds.envVars) {
Object.assign(env, stripBrowserEnv(creds.envVars));
}
if (creds.browserRuntimeEnv) {
Object.assign(env, creds.browserRuntimeEnv);
}
if (creds.baseUrl) env['ANTHROPIC_BASE_URL'] = creds.baseUrl;
if (creds.apiKey) env['ANTHROPIC_AUTH_TOKEN'] = creds.apiKey;
if (creds.model) env['ANTHROPIC_MODEL'] = creds.model;
// Opt-in output limits (issue #231): inject only the env vars the user has
// explicitly configured under config.runtime.outputLimits. When unset, this
// is {} and nothing is injected, so Claude Code keeps its own default caps.
Object.assign(env, getOutputLimitsEnv());
return stripClaudeCodeEnv(env);
}
exec(
args: string[],
env: NodeJS.ProcessEnv,
_options?: { cwd?: string; binaryInfo?: TargetBinaryInfo }
): void {
const exitWithCleanup = (code: number): never => {
try {
runCleanup();
} catch {
// Cleanup should be best-effort on launch errors.
}
process.exit(code);
};
const claudeCli = detectClaudeCli();
if (!claudeCli) {
void ErrorManager.showClaudeNotFound();
return exitWithCleanup(1);
}
const isWindows = process.platform === 'win32';
const isPowerShellScript = isWindows && /\.ps1$/i.test(claudeCli);
const needsShell = isWindows && /\.(cmd|bat)$/i.test(claudeCli);
const spawnStartedAt = Date.now();
adapterLogger.stage('dispatch', 'target.spawn', 'Spawning Claude CLI child process', {
target: 'claude',
claudeCli,
argCount: args.length,
});
let child: ChildProcess;
if (isPowerShellScript) {
child = spawn(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...args],
{
stdio: 'inherit',
windowsHide: true,
env,
}
);
} else if (needsShell) {
const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
child = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: getWindowsEscapedCommandShell(),
env,
});
} else {
child = spawn(claudeCli, args, {
stdio: 'inherit',
windowsHide: true,
env,
});
}
child.on('exit', (code, signal) => {
adapterLogger.stage(
'respond',
'target.exit',
'Claude CLI child process exited',
{ target: 'claude', exitCode: code, signal },
{ latencyMs: Date.now() - spawnStartedAt }
);
});
wireChildProcessSignals(child, async (err: NodeJS.ErrnoException) => {
if (err.code === 'EACCES') {
console.error(`[X] Claude CLI is not executable: ${claudeCli}`);
console.error(' Check file permissions and executable bit.');
} else if (err.code === 'ENOENT') {
if (isPowerShellScript) {
console.error('[X] PowerShell executable not found (required for .ps1 wrapper launch).');
console.error(' Ensure powershell.exe is available in PATH.');
} else if (needsShell) {
console.error('[X] Windows command shell not found for Claude wrapper launch.');
console.error(' Ensure cmd.exe is available and accessible.');
} else {
await ErrorManager.showClaudeNotFound();
}
} else {
console.error(`[X] Failed to start Claude CLI (${claudeCli}): ${err.message}`);
}
return exitWithCleanup(1);
});
}
/**
* Claude supports all CCS profile types.
*/
supportsProfileType(_profileType: ProfileType): boolean {
return true;
}
}