diff --git a/src/__tests__/unit/chat-route-model-persistence.test.ts b/src/__tests__/unit/chat-route-model-persistence.test.ts new file mode 100644 index 00000000..c01ad0fb --- /dev/null +++ b/src/__tests__/unit/chat-route-model-persistence.test.ts @@ -0,0 +1,16 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +describe('chat route model persistence contract', () => { + it('persists the resolved SDK model when a status event includes statusData.model', () => { + const routePath = path.join(process.cwd(), 'src', 'app', 'api', 'chat', 'route.ts'); + const source = fs.readFileSync(routePath, 'utf8'); + + assert.match( + source, + /if\s*\(statusData\.model\)\s*\{\s*updateSessionModel\(sessionId,\s*statusData\.model\);?\s*\}/, + ); + }); +}); diff --git a/src/__tests__/unit/claude-client-launch.test.ts b/src/__tests__/unit/claude-client-launch.test.ts new file mode 100644 index 00000000..4ad6c3b8 --- /dev/null +++ b/src/__tests__/unit/claude-client-launch.test.ts @@ -0,0 +1,44 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { normalizeClaudeCodeModel, resolveClaudeLaunchConfig } from '../../lib/claude-client'; + +describe('claude client launch', () => { + it('resolves npm cmd wrappers to cli.js plus sibling node.exe on Windows', () => { + if (process.platform !== 'win32') return; + + const wrapperPath = 'C:\\Program Files\\nodejs\\claude.cmd'; + const config = resolveClaudeLaunchConfig(wrapperPath); + + assert.equal(config.pathToClaudeCodeExecutable, 'C:\\Program Files\\nodejs\\node_modules\\@anthropic-ai\\claude-code\\cli.js'); + assert.equal(config.executable, 'node'); + }); + + it('preserves non-npm Windows cmd shims instead of rewriting them to a node_modules path', () => { + if (process.platform !== 'win32') return; + + const scoopShimPath = 'C:\\Users\\zy\\scoop\\shims\\claude.cmd'; + const config = resolveClaudeLaunchConfig(scoopShimPath); + + assert.equal(config.pathToClaudeCodeExecutable, scoopShimPath); + assert.equal(config.executable, undefined); + }); + + it('normalizes upstream Anthropic model ids back to Claude Code aliases', () => { + const normalized = normalizeClaudeCodeModel('claude-sonnet-4-20250514', [ + { modelId: 'sonnet', upstreamModelId: 'claude-sonnet-4-20250514' }, + { modelId: 'opus', upstreamModelId: 'claude-opus-4-20250514' }, + ]); + + assert.equal(normalized, 'sonnet'); + }); + it('preserves exe paths', () => { + const exePath = process.platform === 'win32' + ? 'C:\\Program Files\\Claude\\claude.exe' + : '/usr/local/bin/claude'; + + const config = resolveClaudeLaunchConfig(exePath); + assert.equal(config.pathToClaudeCodeExecutable, exePath); + assert.equal(config.executable, undefined); + }); +}); diff --git a/src/__tests__/unit/claude-launch-error-messaging.test.ts b/src/__tests__/unit/claude-launch-error-messaging.test.ts new file mode 100644 index 00000000..ad90af34 --- /dev/null +++ b/src/__tests__/unit/claude-launch-error-messaging.test.ts @@ -0,0 +1,27 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { classifyClaudeLaunchFailure } from '../../lib/claude-client'; + +describe('claude launch error messaging', () => { + it('classifies spawn node ENOENT as missing node runtime', () => { + assert.equal( + classifyClaudeLaunchFailure('spawn node ENOENT', 'ENOENT'), + 'missing_node_runtime', + ); + }); + + it('classifies missing claude executable as missing cli', () => { + assert.equal( + classifyClaudeLaunchFailure('spawn claude ENOENT', 'ENOENT'), + 'missing_claude_cli', + ); + }); + + it('does not classify generic spawn failures without ENOENT', () => { + assert.equal( + classifyClaudeLaunchFailure('spawn node EPERM', 'EPERM'), + null, + ); + }); +}); diff --git a/src/__tests__/unit/claude-session-parser.test.ts b/src/__tests__/unit/claude-session-parser.test.ts index b3d94b00..0e48c2d7 100644 --- a/src/__tests__/unit/claude-session-parser.test.ts +++ b/src/__tests__/unit/claude-session-parser.test.ts @@ -10,6 +10,7 @@ import assert from 'node:assert/strict'; import fs from 'fs'; import path from 'path'; import os from 'os'; +import { pathToFileURL } from 'url'; // We test the parser functions by creating temporary JSONL files // that mimic Claude Code's session storage format. @@ -114,15 +115,16 @@ function makeAssistantEntry(opts: { // Since the project uses path aliases (@/), we import via a relative path // that tsx can resolve with the project's tsconfig. -const parserPath = path.resolve(__dirname, '../../lib/claude-session-parser.ts'); +const parserPath = pathToFileURL(path.resolve(__dirname, '../../lib/claude-session-parser.ts')).href; describe('claude-session-parser', () => { // We'll dynamically import the parser module let parser: typeof import('../../lib/claude-session-parser'); before(async () => { - // Set HOME to our test directory so the parser looks for sessions there + // Set HOME/USERPROFILE to our test directory so the parser looks for sessions there process.env.HOME = TEST_DIR; + process.env.USERPROFILE = TEST_DIR; // Dynamic import - tsx handles the TypeScript + path alias resolution parser = await import(parserPath); @@ -131,8 +133,9 @@ describe('claude-session-parser', () => { after(() => { // Clean up test directory fs.rmSync(TEST_DIR, { recursive: true, force: true }); - // Restore HOME + // Restore HOME/USERPROFILE process.env.HOME = os.homedir(); + process.env.USERPROFILE = os.homedir(); }); describe('decodeProjectPath', () => { diff --git a/src/__tests__/unit/platform-claude-detection.test.ts b/src/__tests__/unit/platform-claude-detection.test.ts new file mode 100644 index 00000000..34bd298f --- /dev/null +++ b/src/__tests__/unit/platform-claude-detection.test.ts @@ -0,0 +1,62 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; + +import { getClaudeCandidatePaths, getExtraPathDirs } from '../../lib/platform'; + +describe('platform claude detection', () => { + it('includes Scoop shim directories in expanded Windows PATH', () => { + if (process.platform !== 'win32') return; + + const home = os.homedir(); + const dirs = getExtraPathDirs(); + + assert.ok( + dirs.includes(path.join(home, 'scoop', 'shims')), + 'expected user Scoop shim dir to be searched', + ); + + const programData = process.env.ProgramData || 'C:\\ProgramData'; + assert.ok( + dirs.includes(path.join(programData, 'scoop', 'shims')), + 'expected system Scoop shim dir to be searched', + ); + }); + + it('includes Scoop claude wrapper candidates on Windows', () => { + if (process.platform !== 'win32') return; + + const home = os.homedir(); + const candidates = getClaudeCandidatePaths(); + + assert.ok( + candidates.includes(path.join(home, 'scoop', 'shims', 'claude.cmd')), + 'expected user Scoop claude.cmd candidate', + ); + + const programData = process.env.ProgramData || 'C:\\ProgramData'; + assert.ok( + candidates.includes(path.join(programData, 'scoop', 'shims', 'claude.cmd')), + 'expected system Scoop claude.cmd candidate', + ); + }); + + it('includes Node.js global install directory candidates on Windows', () => { + if (process.platform !== 'win32') return; + + const candidates = getClaudeCandidatePaths(); + const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; + const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; + + assert.ok( + candidates.includes(path.join(programFiles, 'nodejs', 'claude.cmd')), + 'expected Program Files nodejs claude.cmd candidate', + ); + + assert.ok( + candidates.includes(path.join(programFilesX86, 'nodejs', 'claude.cmd')), + 'expected Program Files (x86) nodejs claude.cmd candidate', + ); + }); +}); diff --git a/src/__tests__/unit/provider-resolver.test.ts b/src/__tests__/unit/provider-resolver.test.ts index 63e8343a..5b880cdf 100644 --- a/src/__tests__/unit/provider-resolver.test.ts +++ b/src/__tests__/unit/provider-resolver.test.ts @@ -1,5 +1,8 @@ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { VENDOR_PRESETS, inferProtocolFromLegacy, @@ -9,7 +12,7 @@ import { } from '../../lib/provider-catalog'; import type { Protocol } from '../../lib/provider-catalog'; -// ── Provider Catalog Tests ────────────────────────────────────── +// Provider Catalog Tests describe('Provider Catalog', () => { describe('VENDOR_PRESETS', () => { @@ -103,86 +106,86 @@ describe('Provider Catalog', () => { }); describe('inferProtocolFromLegacy', () => { - it('anthropic type → anthropic protocol', () => { + it('anthropic type ?anthropic protocol', () => { assert.equal(inferProtocolFromLegacy('anthropic', 'https://api.anthropic.com'), 'anthropic'); }); - it('openrouter type → openrouter protocol', () => { + it('openrouter type ?openrouter protocol', () => { assert.equal(inferProtocolFromLegacy('openrouter', 'https://openrouter.ai/api'), 'openrouter'); }); - it('bedrock type → bedrock protocol', () => { + it('bedrock type ?bedrock protocol', () => { assert.equal(inferProtocolFromLegacy('bedrock', ''), 'bedrock'); }); - it('vertex type → vertex protocol', () => { + it('vertex type ?vertex protocol', () => { assert.equal(inferProtocolFromLegacy('vertex', ''), 'vertex'); }); - it('gemini-image type → gemini-image protocol', () => { + it('gemini-image type ?gemini-image protocol', () => { assert.equal(inferProtocolFromLegacy('gemini-image', 'https://generativelanguage.googleapis.com'), 'gemini-image'); }); // Critical: Chinese vendors with custom type should infer anthropic - it('custom type + GLM base_url → anthropic protocol', () => { + it('custom type + GLM base_url ?anthropic protocol', () => { assert.equal(inferProtocolFromLegacy('custom', 'https://open.bigmodel.cn/api/anthropic'), 'anthropic'); assert.equal(inferProtocolFromLegacy('custom', 'https://api.z.ai/api/anthropic'), 'anthropic'); }); - it('custom type + Kimi base_url → anthropic protocol', () => { + it('custom type + Kimi base_url ?anthropic protocol', () => { assert.equal(inferProtocolFromLegacy('custom', 'https://api.kimi.com/coding/'), 'anthropic'); }); - it('custom type + Moonshot base_url → anthropic protocol', () => { + it('custom type + Moonshot base_url ?anthropic protocol', () => { assert.equal(inferProtocolFromLegacy('custom', 'https://api.moonshot.cn/anthropic'), 'anthropic'); }); - it('custom type + MiniMax base_url → anthropic protocol', () => { + it('custom type + MiniMax base_url ?anthropic protocol', () => { assert.equal(inferProtocolFromLegacy('custom', 'https://api.minimaxi.com/anthropic'), 'anthropic'); assert.equal(inferProtocolFromLegacy('custom', 'https://api.minimax.io/anthropic'), 'anthropic'); }); - it('custom type + Volcengine base_url → anthropic protocol', () => { + it('custom type + Volcengine base_url ?anthropic protocol', () => { assert.equal(inferProtocolFromLegacy('custom', 'https://ark.cn-beijing.volces.com/api/coding'), 'anthropic'); }); - it('custom type + Bailian base_url → anthropic protocol', () => { + it('custom type + Bailian base_url ?anthropic protocol', () => { assert.equal(inferProtocolFromLegacy('custom', 'https://coding.dashscope.aliyuncs.com/apps/anthropic'), 'anthropic'); }); - it('custom type + unknown URL → openai-compatible protocol', () => { + it('custom type + unknown URL ?openai-compatible protocol', () => { assert.equal(inferProtocolFromLegacy('custom', 'https://my-server.example.com/v1'), 'openai-compatible'); }); - it('custom type + URL containing /anthropic → anthropic protocol', () => { + it('custom type + URL containing /anthropic ?anthropic protocol', () => { assert.equal(inferProtocolFromLegacy('custom', 'https://proxy.example.com/anthropic'), 'anthropic'); }); }); describe('inferAuthStyleFromLegacy', () => { - it('bedrock → env_only', () => { + it('bedrock ?env_only', () => { assert.equal(inferAuthStyleFromLegacy('bedrock', '{}'), 'env_only'); }); - it('vertex → env_only', () => { + it('vertex ?env_only', () => { assert.equal(inferAuthStyleFromLegacy('vertex', '{}'), 'env_only'); }); - it('extra_env with ANTHROPIC_AUTH_TOKEN → auth_token', () => { + it('extra_env with ANTHROPIC_AUTH_TOKEN ?auth_token', () => { assert.equal( inferAuthStyleFromLegacy('custom', '{"ANTHROPIC_AUTH_TOKEN":""}'), 'auth_token', ); }); - it('extra_env with ANTHROPIC_API_KEY → api_key', () => { + it('extra_env with ANTHROPIC_API_KEY ?api_key', () => { assert.equal( inferAuthStyleFromLegacy('custom', '{"ANTHROPIC_API_KEY":""}'), 'api_key', ); }); - it('empty extra_env → api_key', () => { + it('empty extra_env ?api_key', () => { assert.equal(inferAuthStyleFromLegacy('anthropic', '{}'), 'api_key'); }); }); @@ -245,7 +248,7 @@ describe('Provider Catalog', () => { }); }); -// ── Provider Resolver Tests ───────────────────────────────────── +// Provider Resolver Tests import { resolveProvider, toClaudeCodeEnv, toAiSdkConfig } from '../../lib/provider-resolver'; import type { ResolvedProvider } from '../../lib/provider-resolver'; @@ -262,7 +265,7 @@ describe('Provider Resolver', () => { it('returns env-based resolution when no provider configured', () => { // With no providers in DB, should return env-based const resolved = resolveProvider({}); - // provider may be undefined or the default — depends on DB state + // provider may be undefined or the default ?depends on DB state assert.equal(resolved.protocol, 'anthropic'); }); }); @@ -371,8 +374,8 @@ describe('Provider Resolver', () => { headers: {}, envOverrides: { API_TIMEOUT_MS: '3000000', - ANTHROPIC_API_KEY: '', // legacy placeholder — should be skipped (auth keys handled by auth injection) - SOME_CUSTOM_VAR: '', // non-auth key — should be deleted + ANTHROPIC_API_KEY: '', // legacy placeholder ?should be skipped (auth keys handled by auth injection) + SOME_CUSTOM_VAR: '', // non-auth key ?should be deleted }, roleModels: {}, hasCredentials: true, @@ -382,7 +385,7 @@ describe('Provider Resolver', () => { const env = toClaudeCodeEnv({ PATH: '/usr/bin', SOME_CUSTOM_VAR: 'old' }, resolved); assert.equal(env.API_TIMEOUT_MS, '3000000'); - // Auth keys are NOT deleted by envOverrides — they're managed by the auth injection logic above + // Auth keys are NOT deleted by envOverrides ?they're managed by the auth injection logic above assert.equal(env.ANTHROPIC_API_KEY, 'key'); // preserved from auth injection assert.equal(env.SOME_CUSTOM_VAR, undefined); // non-auth key deleted by empty string }); @@ -454,8 +457,54 @@ describe('Provider Resolver', () => { }); }); + describe('env-mode Claude settings inheritance', () => { + it('reads auth env from ~/.claude/settings.json in env mode', () => { + const origHome = process.env.HOME; + const origUserProfile = process.env.USERPROFILE; + const origApiKey = process.env.ANTHROPIC_API_KEY; + const origAuthToken = process.env.ANTHROPIC_AUTH_TOKEN; + const origBaseUrl = process.env.ANTHROPIC_BASE_URL; + + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cp-claude-settings-')); + const claudeDir = path.join(tempRoot, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync( + path.join(claudeDir, 'settings.json'), + JSON.stringify({ + env: { + ANTHROPIC_AUTH_TOKEN: 'settings-token', + ANTHROPIC_BASE_URL: 'https://settings.example/anthropic', + }, + }), + ); + + process.env.HOME = tempRoot; + process.env.USERPROFILE = tempRoot; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_AUTH_TOKEN; + delete process.env.ANTHROPIC_BASE_URL; + + try { + const resolved = resolveProvider({ providerId: 'env' }); + assert.equal(resolved.provider, undefined); + assert.equal(resolved.hasCredentials, true); + + const env = toClaudeCodeEnv({ PATH: '/usr/bin' }, resolved); + assert.equal(env.ANTHROPIC_AUTH_TOKEN, 'settings-token'); + assert.equal(env.ANTHROPIC_BASE_URL, 'https://settings.example/anthropic'); + } finally { + if (origHome !== undefined) process.env.HOME = origHome; else delete process.env.HOME; + if (origUserProfile !== undefined) process.env.USERPROFILE = origUserProfile; else delete process.env.USERPROFILE; + if (origApiKey !== undefined) process.env.ANTHROPIC_API_KEY = origApiKey; else delete process.env.ANTHROPIC_API_KEY; + if (origAuthToken !== undefined) process.env.ANTHROPIC_AUTH_TOKEN = origAuthToken; else delete process.env.ANTHROPIC_AUTH_TOKEN; + if (origBaseUrl !== undefined) process.env.ANTHROPIC_BASE_URL = origBaseUrl; else delete process.env.ANTHROPIC_BASE_URL; + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + }); + describe('toAiSdkConfig', () => { - it('anthropic protocol → anthropic SDK', () => { + it('anthropic protocol ?anthropic SDK', () => { const resolved: ResolvedProvider = { provider: { id: 'test', name: 'Test', provider_type: 'anthropic', protocol: 'anthropic', @@ -484,7 +533,7 @@ describe('Provider Resolver', () => { assert.deepEqual(config.processEnvInjections, {}); }); - it('openrouter protocol → openai SDK with correct base URL', () => { + it('openrouter protocol ?openai SDK with correct base URL', () => { const resolved: ResolvedProvider = { provider: { id: 'test', name: 'OR', provider_type: 'openrouter', protocol: 'openrouter', @@ -511,7 +560,7 @@ describe('Provider Resolver', () => { assert.equal(config.baseUrl, 'https://openrouter.ai/api'); }); - it('bedrock protocol → injects env overrides', () => { + it('bedrock protocol ?injects env overrides', () => { const resolved: ResolvedProvider = { provider: { id: 'test', name: 'Bedrock', provider_type: 'bedrock', protocol: 'bedrock', @@ -536,14 +585,14 @@ describe('Provider Resolver', () => { }; const config = toAiSdkConfig(resolved); - assert.equal(config.sdkType, 'bedrock'); // no base_url → native bedrock SDK + assert.equal(config.sdkType, 'bedrock'); // no base_url ?native bedrock SDK assert.deepEqual(config.processEnvInjections, { CLAUDE_CODE_USE_BEDROCK: '1', AWS_REGION: 'us-east-1', }); }); - it('openai-compatible protocol → openai SDK', () => { + it('openai-compatible protocol ?openai SDK', () => { const resolved: ResolvedProvider = { provider: { id: 'test', name: 'Custom', provider_type: 'custom', protocol: 'openai-compatible', @@ -594,7 +643,7 @@ describe('Provider Resolver', () => { assert.equal(config.modelId, 'opus'); }); - it('gemini-image protocol → google SDK', () => { + it('gemini-image protocol ?google SDK', () => { const resolved: ResolvedProvider = { provider: { id: 'test', name: 'Gemini', provider_type: 'gemini-image', protocol: 'gemini-image', @@ -623,7 +672,7 @@ describe('Provider Resolver', () => { }); }); -// ── Entry Point Consistency Tests ─────────────────────────────── +// Entry Point Consistency Tests describe('Entry Point Consistency', () => { it('all Anthropic-compatible Chinese vendors infer correct protocol from legacy custom type', () => { @@ -654,7 +703,7 @@ describe('Entry Point Consistency', () => { }); }); -// ── Env Provider in AI SDK Path ───────────────────────────────── +// Env Provider in AI SDK Path describe('Env Provider AI SDK Consistency', () => { it('env resolution with ANTHROPIC_API_KEY sets hasCredentials=true', () => { @@ -692,30 +741,43 @@ describe('Env Provider AI SDK Consistency', () => { }); it('toAiSdkConfig with env resolution produces valid anthropic config', () => { - const resolved: ResolvedProvider = { - provider: undefined, - protocol: 'anthropic', - authStyle: 'api_key', - model: 'sonnet', - upstreamModel: 'sonnet', - modelDisplayName: undefined, - headers: {}, - envOverrides: {}, - roleModels: {}, - hasCredentials: true, - availableModels: [], - settingSources: ['user', 'project', 'local'], - }; - const config = toAiSdkConfig(resolved); - assert.equal(config.sdkType, 'anthropic'); - assert.equal(config.modelId, 'sonnet'); - // No apiKey/baseUrl — SDK will read from process.env - assert.equal(config.apiKey, undefined); - assert.equal(config.baseUrl, undefined); + const origHome = process.env.HOME; + const origUserProfile = process.env.USERPROFILE; + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cp-empty-claude-home-')); + + process.env.HOME = tempRoot; + process.env.USERPROFILE = tempRoot; + + try { + const resolved: ResolvedProvider = { + provider: undefined, + protocol: 'anthropic', + authStyle: 'api_key', + model: 'sonnet', + upstreamModel: 'sonnet', + modelDisplayName: undefined, + headers: {}, + envOverrides: {}, + roleModels: {}, + hasCredentials: true, + availableModels: [], + settingSources: ['user', 'project', 'local'], + }; + const config = toAiSdkConfig(resolved); + assert.equal(config.sdkType, 'anthropic'); + assert.equal(config.modelId, 'sonnet'); + // No apiKey/baseUrl SDK will read from process.env + assert.equal(config.apiKey, undefined); + assert.equal(config.baseUrl, undefined); + } finally { + if (origHome !== undefined) process.env.HOME = origHome; else delete process.env.HOME; + if (origUserProfile !== undefined) process.env.USERPROFILE = origUserProfile; else delete process.env.USERPROFILE; + fs.rmSync(tempRoot, { recursive: true, force: true }); + } }); }); -// ── Upstream Model ID Mapping ─────────────────────────────────── +// Upstream Model ID Mapping describe('Upstream Model ID Mapping', () => { it('toAiSdkConfig maps internal model ID to upstream via availableModels', () => { @@ -742,15 +804,15 @@ describe('Upstream Model ID Mapping', () => { settingSources: ['project', 'local'], }; - // Without override — uses resolved.upstreamModel + // Without override ?uses resolved.upstreamModel const config1 = toAiSdkConfig(resolved); assert.equal(config1.modelId, 'glm-4.7', 'should use upstream model ID from resolution'); - // With override matching an available model — should map to upstream + // With override matching an available model ?should map to upstream const config2 = toAiSdkConfig(resolved, 'opus'); assert.equal(config2.modelId, 'glm-5', 'override "opus" should map to upstream "glm-5"'); - // With override NOT in available models — passes through as-is + // With override NOT in available models ?passes through as-is const config3 = toAiSdkConfig(resolved, 'unknown-model'); assert.equal(config3.modelId, 'unknown-model', 'unknown override should pass through'); }); @@ -784,7 +846,7 @@ describe('Upstream Model ID Mapping', () => { }); }); -// ── Entry Point Resolution Contract ───────────────────────────── +// Entry Point Resolution Contract // Verifies that ALL entry points (chat, bridge, onboarding, check-in, media plan) // produce identical resolution results for the same inputs, and that the AI SDK // path does not have any fallback logic outside the unified resolver. @@ -793,7 +855,7 @@ describe('Entry Point Resolution Contract', () => { it('env provider with no credentials does not silently fallback', () => { // When providerId='env' is explicitly selected but shell has no credentials, // the resolver must return hasCredentials=false. The AI SDK path (text-generator) - // must then throw — NOT silently pick a random DB provider. + // must then throw ?NOT silently pick a random DB provider. const origKey = process.env.ANTHROPIC_API_KEY; const origToken = process.env.ANTHROPIC_AUTH_TOKEN; delete process.env.ANTHROPIC_API_KEY; @@ -804,7 +866,7 @@ describe('Entry Point Resolution Contract', () => { // hasCredentials should be false when no env vars are set // (may be true if legacy DB setting exists, which is also valid) if (!resolved.hasCredentials) { - // This is the case text-generator should throw on — NOT fallback to DB + // This is the case text-generator should throw on ?NOT fallback to DB assert.equal(resolved.hasCredentials, false); assert.equal(resolved.provider, undefined); // Contract: any consumer seeing this result must throw, not fallback @@ -847,27 +909,38 @@ describe('Entry Point Resolution Contract', () => { }); it('toAiSdkConfig for env mode does not require provider record', () => { - // env mode: provider=undefined, hasCredentials=true - // toAiSdkConfig must produce a valid config that relies on process.env for auth - const resolved: ResolvedProvider = { - provider: undefined, - protocol: 'anthropic', - authStyle: 'api_key', - model: 'sonnet', - upstreamModel: 'sonnet', - modelDisplayName: undefined, - headers: {}, - envOverrides: {}, - roleModels: {}, - hasCredentials: true, - availableModels: [], - settingSources: ['user', 'project', 'local'], - }; - const config = toAiSdkConfig(resolved); - assert.equal(config.sdkType, 'anthropic'); - assert.equal(config.apiKey, undefined, 'env mode should not inject apiKey — SDK reads from process.env'); - assert.equal(config.baseUrl, undefined, 'env mode should not inject baseUrl — SDK reads from process.env'); - assert.equal(config.modelId, 'sonnet'); + const origHome = process.env.HOME; + const origUserProfile = process.env.USERPROFILE; + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cp-empty-claude-home-')); + + process.env.HOME = tempRoot; + process.env.USERPROFILE = tempRoot; + + try { + const resolved: ResolvedProvider = { + provider: undefined, + protocol: 'anthropic', + authStyle: 'api_key', + model: 'sonnet', + upstreamModel: 'sonnet', + modelDisplayName: undefined, + headers: {}, + envOverrides: {}, + roleModels: {}, + hasCredentials: true, + availableModels: [], + settingSources: ['user', 'project', 'local'], + }; + const config = toAiSdkConfig(resolved); + assert.equal(config.sdkType, 'anthropic'); + assert.equal(config.apiKey, undefined, 'env mode should not inject apiKey SDK reads from process.env'); + assert.equal(config.baseUrl, undefined, 'env mode should not inject baseUrl when no Claude settings are present'); + assert.equal(config.modelId, 'sonnet'); + } finally { + if (origHome !== undefined) process.env.HOME = origHome; else delete process.env.HOME; + if (origUserProfile !== undefined) process.env.USERPROFILE = origUserProfile; else delete process.env.USERPROFILE; + fs.rmSync(tempRoot, { recursive: true, force: true }); + } }); it('upstream model mapping is consistent between AI SDK and Claude Code paths', () => { @@ -908,3 +981,6 @@ describe('Entry Point Resolution Contract', () => { assert.equal(aiConfig.modelId, ccEnv.ANTHROPIC_MODEL, 'AI SDK and Claude Code must use same upstream model'); }); }); + + + diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 07764174..288bc6ab 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -80,7 +80,7 @@ export async function POST(request: NextRequest) { }; notifySessionStart(telegramNotifyOpts).catch(() => {}); - // Save user message — persist file metadata so attachments survive page reload + // Save user message - persist file metadata so attachments survive page reload // Skip saving for autoTrigger messages (invisible system triggers for assistant hooks) // Use displayOverride for DB storage if provided (e.g. /skillName instead of expanded prompt) let savedContent = displayOverride || content; @@ -138,7 +138,7 @@ export async function POST(request: NextRequest) { updateSessionProviderId(session_id, persistProviderId); } - // Determine permission mode from chat mode: code → acceptEdits, plan → plan, ask → default (no tools) + // Determine permission mode from chat mode: code => acceptEdits, plan => plan, ask => default (no tools) const effectiveMode = mode || session.mode || 'code'; let permissionMode: string; let systemPromptOverride: string | undefined; @@ -312,14 +312,14 @@ Start by greeting the user and asking the first question. finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + cliToolsCtx; } } catch { - // CLI tools context injection failed — don't block chat + // CLI tools context injection failed; do not block chat } // Load recent conversation history from DB as fallback context. // This is used when SDK session resume is unavailable or fails, // so the model still has conversation context. const { messages: recentMsgs } = getMessages(session_id, { limit: 50 }); - // Exclude the user message we just saved (last in the list) — it's already the prompt + // Exclude the user message we just saved (last in the list); it is already the prompt const historyMsgs = recentMsgs.slice(0, -1).map(m => ({ role: m.role as 'user' | 'assistant', content: m.content, @@ -577,7 +577,7 @@ async function collectStreamResponse( } } } finally { - // ── Server-side completion detection (reliable path) ── + // Server-side completion detection (reliable path) // After persisting the assistant message, check for onboarding/checkin // fences and process them directly on the server. This ensures completion // is captured even if the frontend misses it (page refresh, parse failure, etc.). @@ -616,7 +616,7 @@ async function collectStreamResponse( /** * Process a detected onboarding/checkin completion on the server side. - * Calls the shared processor functions directly — no HTTP round-trip needed. + * Calls the shared processor functions directly - no HTTP round-trip needed. * * Both processors are internally idempotent: * - processOnboarding checks state.onboardingComplete diff --git a/src/lib/bridge/conversation-engine.ts b/src/lib/bridge/conversation-engine.ts index f290c5bb..165ef3b8 100644 --- a/src/lib/bridge/conversation-engine.ts +++ b/src/lib/bridge/conversation-engine.ts @@ -1,5 +1,5 @@ /** - * Conversation Engine — processes inbound IM messages through Claude. + * Conversation Engine - processes inbound IM messages through Claude. * * Takes a ChannelBinding + inbound message, calls streamClaude(), * consumes the SSE stream server-side, saves messages to DB, @@ -64,7 +64,7 @@ export type OnPermissionRequest = (perm: PermissionRequestInfo) => Promise /** * Callback invoked on each `text` SSE event with the full accumulated text so far. - * Must return synchronously — the bridge-manager handles throttling and fire-and-forget. + * Must return synchronously - the bridge-manager handles throttling and fire-and-forget. */ export type OnPartialText = (fullText: string) => void; @@ -115,10 +115,10 @@ export async function processMessage( }, 60_000); try { - // Resolve session early — needed for workingDirectory and provider resolution + // Resolve session early - needed for workingDirectory and provider resolution const session = getSession(sessionId); - // Save user message — persist file attachments to disk using the same + // Save user message - persist file attachments to disk using the same // format as the desktop chat route, so the UI can render them. // Also attach filePath to the file objects so streamClaude() can reuse // on-disk copies (matching the desktop route behavior, preventing duplicate writes). @@ -216,7 +216,7 @@ export async function processMessage( // Consume the stream server-side (replicate collectStreamResponse pattern). // Permission requests are forwarded immediately via the callback during streaming - // because the stream blocks until permission is resolved — we can't wait until after. + // because the stream blocks until permission is resolved - we can't wait until after. return await consumeStream(stream, sessionId, onPermissionRequest, onPartialText); } finally { clearInterval(renewalInterval); @@ -238,7 +238,7 @@ async function consumeStream( const reader = stream.getReader(); const contentBlocks: MessageContentBlock[] = []; let currentText = ''; - /** Monotonically accumulated text for streaming preview — never resets on tool_use. */ + /** Monotonically accumulated text for streaming preview - never resets on tool_use. */ let previewText = ''; let tokenUsage: TokenUsage | null = null; let hasError = false; @@ -321,7 +321,7 @@ async function consumeStream( suggestions: permData.suggestions, }; permissionRequests.push(perm); - // Forward immediately — the stream blocks until the permission is + // Forward immediately - the stream blocks until the permission is // resolved, so we must send the IM prompt *now*, not after the stream ends. if (onPermissionRequest) { onPermissionRequest(perm).catch((err) => { @@ -374,7 +374,7 @@ async function consumeStream( break; } - // tool_output, tool_timeout, mode_changed, done — ignored for bridge + // tool_output, tool_timeout, mode_changed, done - ignored for bridge } } } diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index 6de134fd..69043d64 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -50,40 +50,82 @@ function sanitizeEnv(env: Record): Record { } return clean; } +const claudeLaunchLogDir = path.join(process.env.CLAUDE_GUI_DATA_DIR || path.join(os.homedir(), '.codepilot'), 'logs'); +const claudeLaunchLogPath = path.join(claudeLaunchLogDir, 'claude-launch.log'); -/** - * On Windows, npm installs CLI tools as .cmd wrappers that can't be - * spawned without shell:true. Parse the wrapper to extract the real - * .js script path so we can pass it to the SDK directly. - */ -function resolveScriptFromCmd(cmdPath: string): string | undefined { +function maskSecret(value: string | undefined): string | undefined { + if (!value) return undefined; + if (value.length <= 8) return '***'; + return `${value.slice(0, 4)}***${value.slice(-4)}`; +} + +function appendClaudeLaunchLog(event: string, payload: Record): void { try { - const content = fs.readFileSync(cmdPath, 'utf-8'); - const cmdDir = path.dirname(cmdPath); - - // npm .cmd wrappers typically contain a line like: - // "%~dp0\node_modules\@anthropic-ai\claude-code\cli.js" %* - // Match paths containing claude-code or claude-agent and ending in .js - const patterns = [ - // Quoted: "%~dp0\...\cli.js" - /"%~dp0\\([^"]*claude[^"]*\.js)"/i, - // Unquoted: %~dp0\...\cli.js - /%~dp0\\(\S*claude\S*\.js)/i, - // Quoted with %dp0%: "%dp0%\...\cli.js" - /"%dp0%\\([^"]*claude[^"]*\.js)"/i, - ]; - - for (const re of patterns) { - const m = content.match(re); - if (m) { - const resolved = path.normalize(path.join(cmdDir, m[1])); - if (fs.existsSync(resolved)) return resolved; - } + if (!fs.existsSync(claudeLaunchLogDir)) { + fs.mkdirSync(claudeLaunchLogDir, { recursive: true }); } + const line = JSON.stringify({ ts: new Date().toISOString(), pid: process.pid, event, ...payload }); + fs.appendFileSync(claudeLaunchLogPath, line + '\n', 'utf8'); } catch { - // ignore read errors + // best effort only + } +} + +export interface ClaudeLaunchConfig { + executable?: 'node'; + pathToClaudeCodeExecutable: string; +} + +/** + * Resolve how CodePilot should launch Claude Code on the current machine. + * + * For npm-installed Windows wrappers, use the sibling node.exe explicitly and + * point the SDK at the real cli.js entrypoint. This avoids both shell issues + * with .cmd files and accidental fallback to the SDK's bundled cli.js path. + */ +export function resolveClaudeLaunchConfig(claudePath: string): ClaudeLaunchConfig { + const ext = path.extname(claudePath).toLowerCase(); + if (process.platform === 'win32' && (ext === '.cmd' || ext === '.bat')) { + const installDir = path.dirname(claudePath); + const npmCliPath = path.join(installDir, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'); + if (fs.existsSync(npmCliPath)) { + return { + executable: 'node', + pathToClaudeCodeExecutable: npmCliPath, + }; + } + } + return { pathToClaudeCodeExecutable: claudePath }; +} + +export function normalizeClaudeCodeModel( + requestedModel: string | undefined, + availableModels: Array<{ modelId: string; upstreamModelId?: string }>, +): string | undefined { + if (!requestedModel) return undefined; + const normalized = requestedModel.trim(); + if (!normalized) return undefined; + + const exactModel = availableModels.find(m => m.modelId === normalized); + if (exactModel) return exactModel.modelId; + + const upstreamMatch = availableModels.find(m => m.upstreamModelId === normalized); + if (upstreamMatch) return upstreamMatch.modelId; + + return normalized; +} + +export function classifyClaudeLaunchFailure(rawMessage: string, code?: string): 'missing_node_runtime' | 'missing_claude_cli' | null { + const lowered = rawMessage.toLowerCase(); + const hasEnoent = code === 'ENOENT' || lowered.includes('enoent'); + if (!hasEnoent) return null; + if (lowered.includes('spawn node ') || lowered.includes('spawn node.exe ')) { + return 'missing_node_runtime'; } - return undefined; + if (lowered.includes('spawn claude ') || lowered.includes('spawn claude.exe ') || lowered.includes('spawn claude.cmd ')) { + return 'missing_claude_cli'; + } + return 'missing_claude_cli'; } let cachedClaudePath: string | null | undefined; @@ -237,7 +279,7 @@ function buildPromptWithHistory( const lines: string[] = [ '', - '(This is a summary of earlier conversation turns for context. Tool calls shown here were already executed — do not repeat them or output their markers as text.)', + '(This is a summary of earlier conversation turns for context. Tool calls shown here were already executed - do not repeat them or output their markers as text.)', ]; for (const msg of history) { // For assistant messages with tool blocks (JSON arrays), extract only the text portions. @@ -249,7 +291,7 @@ function buildPromptWithHistory( const parts: string[] = []; for (const b of blocks) { if (b.type === 'text' && b.text) parts.push(b.text); - // Skip tool_use and tool_result — they were already executed + // Skip tool_use and tool_result - they were already executed } content = parts.length > 0 ? parts.join('\n') : '(assistant used tools)'; } catch { @@ -314,18 +356,20 @@ export async function generateTextViaSdk(params: { maxTurns: 1, }; - if (params.model) { - queryOptions.model = params.model; + const normalizedModel = normalizeClaudeCodeModel( + params.model, + resolved.availableModels, + ); + if (normalizedModel) { + queryOptions.model = normalizedModel; } const claudePath = findClaudePath(); if (claudePath) { - const ext = path.extname(claudePath).toLowerCase(); - if (ext === '.cmd' || ext === '.bat') { - const scriptPath = resolveScriptFromCmd(claudePath); - if (scriptPath) queryOptions.pathToClaudeCodeExecutable = scriptPath; - } else { - queryOptions.pathToClaudeCodeExecutable = claudePath; + const launchConfig = resolveClaudeLaunchConfig(claudePath); + queryOptions.pathToClaudeCodeExecutable = launchConfig.pathToClaudeCodeExecutable; + if (launchConfig.executable) { + queryOptions.executable = launchConfig.executable; } } @@ -389,7 +433,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream ({ modelId: m.modelId, upstreamModelId: m.upstreamModelId })), + cwd: workingDirectory || os.homedir(), + home: sdkEnv.HOME, + userProfile: sdkEnv.USERPROFILE, + claudeCodeGitBashPath: sdkEnv.CLAUDE_CODE_GIT_BASH_PATH, + anthropicBaseUrl: sdkEnv.ANTHROPIC_BASE_URL, + anthropicAuthToken: maskSecret(sdkEnv.ANTHROPIC_AUTH_TOKEN), + anthropicApiKey: maskSecret(sdkEnv.ANTHROPIC_API_KEY), + settingSources: resolved.settingSources, + claudePath, + launchExecutable: queryOptions.executable, + launchPath: queryOptions.pathToClaudeCodeExecutable, + }); + if (systemPrompt) { // Use preset append mode to keep Claude Code's default system prompt // (which includes skills, working directory awareness, etc.) @@ -601,11 +665,11 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream tool_result. // Capture real-time stderr output from Claude Code process queryOptions.stderr = (data: string) => { @@ -624,6 +688,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream isImageFile(f.type)); - const imageHint = hasImages ? '\n• Provider may not support image/vision input' : ''; - errorMessage = `Claude Code process exited with an error${providerHint}. This is often caused by:\n• Invalid or missing API Key\n• Incorrect Base URL configuration\n• Network connectivity issues${imageHint}${detailHint}\n\nOriginal error: ${rawMessage}`; + const imageHint = hasImages ? '\n- Provider may not support image/vision input' : ''; + errorMessage = `Claude Code process exited with an error${providerHint}. This is often caused by:\n- Invalid or missing API Key\n- Incorrect Base URL configuration\n- Network connectivity issues${imageHint}${detailHint}\n\nOriginal error: ${rawMessage}`; } else if (rawMessage.includes('exited with code')) { const providerHint = resolved.provider?.name ? ` (Provider: ${resolved.provider?.name})` : ''; errorMessage = `Claude Code process crashed unexpectedly${providerHint}.\n\nOriginal error: ${rawMessage}`; @@ -1043,11 +1111,21 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream { + try { + if (!fs.existsSync(settingsPath)) return {}; + const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { env?: Record }; + const env = parsed?.env; + if (!env || typeof env !== 'object') return {}; + + const clean: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (typeof value === 'string' && value !== '') { + clean[key] = value; + } + } + return clean; + } catch { + return {}; + } +} + /** * Whether the given binary path requires shell execution. * On Windows, .cmd/.bat files cannot be executed directly by execFileSync. @@ -25,6 +44,9 @@ export function getExtraPathDirs(): string[] { if (isWindows) { const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); + const programData = process.env.ProgramData || 'C:\\ProgramData'; + const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; + const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; return [ path.join(appData, 'npm'), path.join(localAppData, 'npm'), @@ -32,6 +54,10 @@ export function getExtraPathDirs(): string[] { path.join(home, '.claude', 'bin'), path.join(home, '.local', 'bin'), path.join(home, '.nvm', 'current', 'bin'), + path.join(programFilesX86, 'nodejs'), + path.join(programFiles, 'nodejs'), + path.join(programData, 'scoop', 'shims'), + path.join(home, 'scoop', 'shims'), ]; } return [ @@ -54,6 +80,9 @@ export function getClaudeCandidatePaths(): string[] { if (isWindows) { const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); + const programData = process.env.ProgramData || 'C:\\ProgramData'; + const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; + const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; const exts = ['.cmd', '.exe', '.bat', '']; const baseDirs = [ path.join(appData, 'npm'), @@ -61,6 +90,10 @@ export function getClaudeCandidatePaths(): string[] { path.join(home, '.npm-global', 'bin'), path.join(home, '.claude', 'bin'), path.join(home, '.local', 'bin'), + path.join(home, 'scoop', 'shims'), + path.join(programData, 'scoop', 'shims'), + path.join(programFiles, 'nodejs'), + path.join(programFilesX86, 'nodejs'), ]; const candidates: string[] = []; for (const dir of baseDirs) { @@ -117,7 +150,7 @@ export function findClaudeBinary(): string | undefined { _cachedBinaryPath = found; _cachedBinaryTimestamp = now; } else { - // Don't cache "not found" — user may install CLI any moment + // Don't cache "not found" - user may install CLI any moment _cachedBinaryPath = null; } return found; @@ -127,6 +160,10 @@ function _findClaudeBinaryUncached(): string | undefined { // Try known candidate paths first for (const p of getClaudeCandidatePaths()) { try { + if (isWindows && /\.(cmd|bat)$/i.test(p)) { + if (fs.existsSync(p)) return p; + continue; + } execFileSync(p, ['--version'], { timeout: 3000, stdio: 'pipe', @@ -154,6 +191,10 @@ function _findClaudeBinaryUncached(): string | undefined { const candidate = line.trim(); if (!candidate) continue; try { + if (isWindows && /\.(cmd|bat)$/i.test(candidate)) { + if (fs.existsSync(candidate)) return candidate; + continue; + } execFileSync(candidate, ['--version'], { timeout: 3000, stdio: 'pipe', diff --git a/src/lib/provider-resolver.ts b/src/lib/provider-resolver.ts index 0e59fa80..8a5eeddd 100644 --- a/src/lib/provider-resolver.ts +++ b/src/lib/provider-resolver.ts @@ -1,5 +1,5 @@ /** - * Provider Resolver — unified provider/model resolution for all consumers. + * Provider Resolver - unified provider/model resolution for all consumers. * * Every entry point (chat, bridge, onboarding, check-in, media plan) calls * this module instead of doing its own provider resolution. This guarantees @@ -24,8 +24,9 @@ import { getSetting, getModelsForProvider, } from './db'; +import { readClaudeSettingsEnv } from './platform'; -// ── Resolution result ─────────────────────────────────────────── +// Resolution result export interface ResolvedProvider { /** The DB provider record (undefined = use env vars) */ @@ -36,7 +37,7 @@ export interface ResolvedProvider { authStyle: AuthStyle; /** Resolved model ID (internal/UI model ID) */ model: string | undefined; - /** Upstream model ID (what actually gets sent to the API — may differ from model) */ + /** Upstream model ID (what actually gets sent to the API - may differ from model) */ upstreamModel: string | undefined; /** Display name for the model */ modelDisplayName: string | undefined; @@ -54,7 +55,7 @@ export interface ResolvedProvider { settingSources: string[]; } -// ── Public API ────────────────────────────────────────────────── +// Public API export interface ResolveOptions { /** Explicit provider ID from request (highest priority) */ @@ -65,7 +66,7 @@ export interface ResolveOptions { model?: string; /** Session's stored model */ sessionModel?: string; - /** Use case — affects which role model to pick */ + /** Use case - affects which role model to pick */ useCase?: 'default' | 'reasoning' | 'small'; } @@ -86,7 +87,7 @@ export function resolveProvider(opts: ResolveOptions = {}): ResolvedProvider { let provider: ApiProvider | undefined; if (effectiveProviderId && effectiveProviderId !== 'env') { - // Explicit provider — look it up + // Explicit provider - look it up provider = getProvider(effectiveProviderId); if (!provider) { // Requested provider not found, fall back to default @@ -94,11 +95,11 @@ export function resolveProvider(opts: ResolveOptions = {}): ResolvedProvider { if (defaultId) provider = getProvider(defaultId); } } else if (!effectiveProviderId) { - // No provider specified — use global default + // No provider specified - use global default const defaultId = getDefaultProviderId(); if (defaultId) provider = getProvider(defaultId); } - // effectiveProviderId === 'env' → provider stays undefined + // effectiveProviderId === 'env' -> provider stays undefined return buildResolution(provider, opts); } @@ -124,7 +125,7 @@ export function resolveForClaudeCode( if (!resolved.provider && !opts.providerId && !opts.sessionProviderId) { const defaultId = getDefaultProviderId(); if (!defaultId) { - // No default configured either — last resort backwards compat + // No default configured either - last resort backwards compat const active = getActiveProvider(); if (active) return buildResolution(active, opts); } @@ -132,7 +133,7 @@ export function resolveForClaudeCode( return resolved; } -// ── Claude Code env builder ───────────────────────────────────── +// Claude Code env builder /** * Build environment variables for a Claude Code SDK subprocess. @@ -147,6 +148,7 @@ export function toClaudeCodeEnv( resolved: ResolvedProvider, ): Record { const env = { ...baseEnv }; + const claudeSettingsEnv = readClaudeSettingsEnv(); // Managed env vars that must be cleaned when switching providers to prevent leaks const MANAGED_ENV_KEYS = new Set([ @@ -220,7 +222,7 @@ export function toClaudeCodeEnv( } // Inject env overrides (empty string = delete). - // Skip auth-related keys — they were already correctly injected above based on authStyle. + // Skip auth-related keys - they were already correctly injected above based on authStyle. // Legacy extra_env often contains placeholder entries like {"ANTHROPIC_AUTH_TOKEN":""} or // {"ANTHROPIC_API_KEY":""} that would delete the freshly-injected credentials. const AUTH_ENV_KEYS = new Set([ @@ -239,7 +241,12 @@ export function toClaudeCodeEnv( } } } else if (!resolved.provider) { - // No provider — check legacy DB settings, then fall back to existing env + // No provider - inherit CLI env defaults from ~/.claude/settings.json, + // then allow explicit app-level settings to override them. + for (const [key, value] of Object.entries(claudeSettingsEnv)) { + if (env[key] === undefined) env[key] = value; + } + const appToken = getSetting('anthropic_auth_token'); const appBaseUrl = getSetting('anthropic_base_url'); if (appToken) env.ANTHROPIC_AUTH_TOKEN = appToken; @@ -249,7 +256,7 @@ export function toClaudeCodeEnv( return env; } -// ── AI SDK config builder ─────────────────────────────────────── +// AI SDK config builder export interface AiSdkConfig { /** Which AI SDK factory to use */ @@ -307,20 +314,24 @@ export function toAiSdkConfig( // and they are mutually exclusive. We must pick the right one based on authStyle. const resolveAnthropicAuth = (): { apiKey: string | undefined; authToken: string | undefined } => { if (provider) { - // Configured provider — use authStyle to decide + // Configured provider - use authStyle to decide if (resolved.authStyle === 'auth_token') { return { apiKey: undefined, authToken: provider.api_key || undefined }; } return { apiKey: provider.api_key || undefined, authToken: undefined }; } - // Env mode — check env vars and legacy DB settings. + // Env mode - check env vars and legacy DB settings. // ANTHROPIC_AUTH_TOKEN takes precedence (it's the Claude Code SDK auth path). - const envAuthToken = process.env.ANTHROPIC_AUTH_TOKEN || getSetting('anthropic_auth_token'); + const claudeSettingsEnv = readClaudeSettingsEnv(); + const envAuthToken = + process.env.ANTHROPIC_AUTH_TOKEN || + claudeSettingsEnv.ANTHROPIC_AUTH_TOKEN || + getSetting('anthropic_auth_token'); if (envAuthToken) { // If we also have an API key, prefer auth_token (matches Claude Code SDK behavior) return { apiKey: undefined, authToken: envAuthToken }; } - const envApiKey = process.env.ANTHROPIC_API_KEY; + const envApiKey = process.env.ANTHROPIC_API_KEY || claudeSettingsEnv.ANTHROPIC_API_KEY; return { apiKey: envApiKey || undefined, authToken: undefined }; }; @@ -339,7 +350,13 @@ export function toAiSdkConfig( switch (protocol) { case 'anthropic': { const auth = resolveAnthropicAuth(); - const rawBaseUrl = provider?.base_url || process.env.ANTHROPIC_BASE_URL || getSetting('anthropic_base_url') || undefined; + const claudeSettingsEnv = readClaudeSettingsEnv(); + const rawBaseUrl = + provider?.base_url || + process.env.ANTHROPIC_BASE_URL || + claudeSettingsEnv.ANTHROPIC_BASE_URL || + getSetting('anthropic_base_url') || + undefined; return { sdkType: 'anthropic', ...auth, @@ -444,17 +461,21 @@ export function toAiSdkConfig( } } -// ── Internal helpers ──────────────────────────────────────────── +// Internal helpers function buildResolution( provider: ApiProvider | undefined, opts: ResolveOptions, ): ResolvedProvider { if (!provider) { - // Environment-based provider (no DB record) — credentials come from shell env or legacy DB settings + // Environment-based provider (no DB record) - credentials can come from shell env, + // ~/.claude/settings.json, or legacy DB settings. + const claudeSettingsEnv = readClaudeSettingsEnv(); const envHasCredentials = !!( process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || + claudeSettingsEnv.ANTHROPIC_API_KEY || + claudeSettingsEnv.ANTHROPIC_AUTH_TOKEN || getSetting('anthropic_auth_token') ); const model = opts.model || opts.sessionModel || getSetting('default_model') || undefined; @@ -513,7 +534,7 @@ function buildResolution( } } catch { /* provider_models table may not exist in old DBs */ } - // Resolve model — priority: + // Resolve model - priority: // 1. Explicit request model (opts.model) // 2. Session's stored model (opts.sessionModel) // 3. Provider's roleModels.default (configured per-provider default, e.g. "ark-code-latest") @@ -545,7 +566,7 @@ function buildResolution( // Has credentials? const hasCredentials = !!(provider.api_key) || authStyle === 'env_only'; - // Settings sources — always include 'user' so SDK can load skills from + // Settings sources - always include 'user' so SDK can load skills from // ~/.claude/skills/. Env override conflicts are handled by envOverrides. const settingSources = ['user', 'project', 'local']; @@ -605,4 +626,4 @@ function safeParseCapabilities(json: string | undefined | null): CatalogModel['c } // ApiProvider now includes protocol, headers_json, env_overrides_json, role_models_json -// directly — no type augmentation needed. +// directly - no type augmentation needed.