diff --git a/src/ccs.ts b/src/ccs.ts index ba572f57..d1a27bfc 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -1,6 +1,5 @@ import './utils/fetch-proxy-setup'; -import { detectClaudeCli } from './utils/claude-detector'; import { getSettingsPath, loadSettings } from './utils/config-manager'; import { expandPath } from './utils/helpers'; import { @@ -33,13 +32,10 @@ import { import { appendBrowserToolArgs, ensureBrowserMcpOrThrow, - getBlockedBrowserOverrideWarning, - getEffectiveClaudeBrowserAttachConfig, - resolveBrowserExposure, resolveOptionalBrowserAttachRuntime, syncBrowserMcpToConfigDir, } from './utils/browser'; -import { getBrowserConfig, getGlobalEnvConfig } from './config/unified-config-loader'; +import { getGlobalEnvConfig } from './config/unified-config-loader'; import { ensureProfileHooks as ensureImageAnalyzerHooks, removeImageAnalysisProfileHook, @@ -62,23 +58,16 @@ import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils import { createOpenAICompatLaunchSettings } from './utils/openai-compat-launch-settings'; import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning'; import { createLogger, runWithRequestId } from './services/logging'; -import type { ProfileDetectionResult } from './auth/profile-detector'; - // Import target adapter system import { registerTarget, - getTarget, ClaudeAdapter, DroidAdapter, CodexAdapter, evaluateTargetRuntimeCompatibility, - pruneOrphanedModels, resolveDroidProvider, type TargetCredentials, } from './targets'; -import { resolveTargetType, stripTargetFlag } from './targets/target-resolver'; -import { DroidReasoningFlagError } from './targets/droid-reasoning-runtime'; -import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router'; import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge'; import { buildOpenAICompatProxyEnv, @@ -87,21 +76,11 @@ import { } from './proxy'; // Import extracted dispatcher modules -import { - detectProfile, - resolveRuntimeReasoningFlags, - normalizeCodexRuntimeReasoningOverride, - exitWithRuntimeReasoningFlagError, - normalizeNativeClaudeEffortArgs, - shouldNormalizeNativeClaudeEffort, - bootstrapAndParseEarlyCli, -} from './dispatcher/cli-argument-parser'; -import { - resolveCodexRuntimeConfigOverrides, - resolveNativeClaudeLaunchArgs, -} from './dispatcher/environment-builder'; +import { bootstrapAndParseEarlyCli } from './dispatcher/cli-argument-parser'; +import { resolveNativeClaudeLaunchArgs } from './dispatcher/environment-builder'; import { type ProfileError } from './dispatcher/target-executor'; import { runPreDispatchHandlers } from './dispatcher/pre-dispatch'; +import { resolveProfileAndTarget } from './dispatcher/profile-resolver'; // ========== Main Execution ========== @@ -133,9 +112,37 @@ async function main(): Promise { return; } - // Use ProfileDetector to determine profile type - const ProfileDetectorModule = await import('./auth/profile-detector'); - const ProfileDetector = ProfileDetectorModule.default; + // Phase C: profile + target detection (extracted to dispatcher/profile-resolver.ts) + const { + profile, + remainingArgs, + profileInfo, + resolvedTarget, + claudeCli, + targetAdapter, + targetBinaryInfo, + resolvedSettingsPath: initialResolvedSettingsPath, + resolvedSettings: initialResolvedSettings, + resolvedCliproxyBridge: initialResolvedCliproxyBridge, + targetRemainingArgs: initialTargetRemainingArgs, + nativeClaudeRemainingArgs: initialNativeClaudeRemainingArgs, + runtimeReasoningOverride: initialRuntimeReasoningOverride, + codexRuntimeConfigOverrides, + claudeBrowserExposure, + codexBrowserExposure: _codexBrowserExposure, + claudeAttachConfig, + detector: _detector, + } = await resolveProfileAndTarget({ args, browserLaunchOverride, cliLogger }); + + // Re-assign mutable state that Phase E flows may update + const resolvedSettingsPath = initialResolvedSettingsPath; + const resolvedSettings = initialResolvedSettings; + const resolvedCliproxyBridge = initialResolvedCliproxyBridge; + const targetRemainingArgs = initialTargetRemainingArgs; + const nativeClaudeRemainingArgs = initialNativeClaudeRemainingArgs; + const runtimeReasoningOverride = initialRuntimeReasoningOverride; + + // Re-import dynamic modules needed by Phase E flows (preserving original ordering). const InstanceManagerModule = await import('./management/instance-manager'); const InstanceManager = InstanceManagerModule.default; const ProfileRegistryModule = await import('./auth/profile-registry'); @@ -145,236 +152,7 @@ async function main(): Promise { const ProfileContinuityModule = await import('./auth/profile-continuity-inheritance'); const { resolveProfileContinuityInheritance } = ProfileContinuityModule; - const detector = new ProfileDetector(); - try { - // Detect profile (strip --target flags before profile detection) - const cleanArgs = stripTargetFlag(args); - const { profile, remainingArgs } = detectProfile(cleanArgs); - const profileInfo: ProfileDetectionResult = detector.detectProfileType(profile); - let resolvedTarget: ReturnType; - try { - resolvedTarget = resolveTargetType( - args, - profileInfo.target ? { target: profileInfo.target } : undefined - ); - } catch (error) { - console.error(fail((error as Error).message)); - process.exit(1); - return; - } - - // Detect Claude CLI (needed for claude target and all CLIProxy-derived flows) - const claudeCliRaw = detectClaudeCli(); - if (resolvedTarget === 'claude' && !claudeCliRaw) { - await ErrorManager.showClaudeNotFound(); - process.exit(1); - } - const claudeCli = claudeCliRaw || ''; - - // Resolve non-claude target adapter once. - const targetAdapter = resolvedTarget !== 'claude' ? getTarget(resolvedTarget) : null; - let resolvedSettingsPath: string | undefined; - let resolvedSettings: ReturnType | undefined; - let resolvedCliproxyBridge: ReturnType | undefined; - - // Preflight unsupported profile/target combinations BEFORE binary detection, - // so users get the most actionable error even when the target CLI is not installed. - if (resolvedTarget !== 'claude') { - if (!targetAdapter) { - console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); - process.exit(1); - } - - if (profileInfo.type === 'settings') { - resolvedSettingsPath = profileInfo.settingsPath - ? expandPath(profileInfo.settingsPath) - : getSettingsPath(profileInfo.name); - resolvedSettings = loadSettings(resolvedSettingsPath); - resolvedCliproxyBridge = resolveCliproxyBridgeMetadata(resolvedSettings); - const compatibility = evaluateTargetRuntimeCompatibility({ - target: resolvedTarget, - profileType: profileInfo.type, - cliproxyBridgeProvider: resolvedCliproxyBridge?.provider ?? null, - }); - if (!compatibility.supported) { - console.error( - fail( - compatibility.reason || `${targetAdapter.displayName} does not support this profile.` - ) - ); - if (compatibility.suggestion) { - console.error(info(compatibility.suggestion)); - } - process.exit(1); - } - } else { - const compatibility = evaluateTargetRuntimeCompatibility({ - target: resolvedTarget, - profileType: profileInfo.type, - cliproxyProvider: profileInfo.type === 'cliproxy' ? profileInfo.provider : undefined, - isComposite: - profileInfo.type === 'cliproxy' ? Boolean(profileInfo.isComposite) : undefined, - }); - if (!compatibility.supported) { - console.error( - fail( - compatibility.reason || `${targetAdapter.displayName} does not support this profile.` - ) - ); - if (compatibility.suggestion) { - console.error(info(compatibility.suggestion)); - } - process.exit(1); - } - } - - if (profileInfo.type === 'default') { - if (!targetAdapter.supportsProfileType('default')) { - console.error(fail(`${targetAdapter.displayName} does not support default profile mode`)); - process.exit(1); - } - - // For default mode, Droid requires explicit credentials from environment. - if (resolvedTarget === 'droid') { - const baseUrl = process.env['ANTHROPIC_BASE_URL'] || ''; - const apiKey = process.env['ANTHROPIC_AUTH_TOKEN'] || ''; - if (!baseUrl.trim() || !apiKey.trim()) { - console.error( - fail( - `${targetAdapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN` - ) - ); - console.error(info('Use a settings-based profile instead: ccs glm --target droid')); - process.exit(1); - } - } - } - } - - // For non-claude targets, verify target binary exists once and pass it through. - const targetBinaryInfo = targetAdapter?.detectBinary() ?? null; - const browserConfig = getBrowserConfig(); - const claudeAttachConfig = - resolvedTarget === 'claude' - ? getEffectiveClaudeBrowserAttachConfig(browserConfig) - : undefined; - const codexRuntimeConfigOverrides = resolveCodexRuntimeConfigOverrides( - resolvedTarget, - browserLaunchOverride - ); - const claudeBrowserExposure = - resolvedTarget === 'claude' - ? resolveBrowserExposure( - { - enabled: claudeAttachConfig?.enabled ?? browserConfig.claude.enabled, - policy: browserConfig.claude.policy, - }, - browserLaunchOverride - ) - : undefined; - const codexBrowserExposure = - resolvedTarget === 'codex' - ? resolveBrowserExposure(browserConfig.codex, browserLaunchOverride) - : undefined; - const blockedBrowserOverrideWarning = - resolvedTarget === 'claude' && claudeBrowserExposure - ? getBlockedBrowserOverrideWarning('Claude Browser Attach', claudeBrowserExposure) - : resolvedTarget === 'codex' && codexBrowserExposure - ? getBlockedBrowserOverrideWarning('Codex Browser Tools', codexBrowserExposure) - : undefined; - if (blockedBrowserOverrideWarning) { - console.error(warn(blockedBrowserOverrideWarning)); - } - if (resolvedTarget !== 'claude' && !targetBinaryInfo) { - const displayName = targetAdapter?.displayName || resolvedTarget; - console.error(fail(`${displayName} CLI not found.`)); - if (resolvedTarget === 'droid') { - console.error(info('Install: npm i -g @factory/cli')); - } else if (resolvedTarget === 'codex') { - console.error(info('Install a recent @openai/codex build, then retry.')); - } - process.exit(1); - } - - // Best-effort: prune stale Droid model entries at runtime so settings.json stays clean. - if (resolvedTarget === 'droid') { - try { - const allProfiles = detector.getAllProfiles(); - const activeProfiles = allProfiles.settings.filter((name) => - /^[a-zA-Z0-9._-]+$/.test(name) - ); - await pruneOrphanedModels(activeProfiles); - } catch (error) { - console.error(warn(`[!] Droid prune skipped: ${(error as Error).message}`)); - } - } - - let targetRemainingArgs = remainingArgs; - let runtimeReasoningOverride: string | number | undefined; - let nativeClaudeRemainingArgs = remainingArgs; - if (resolvedTarget === 'droid') { - try { - const droidRoute = routeDroidCommandArgs(remainingArgs); - targetRemainingArgs = droidRoute.argsForDroid; - - if (droidRoute.mode === 'interactive') { - const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env.CCS_THINKING); - targetRemainingArgs = runtime.argsWithoutReasoningFlags; - runtimeReasoningOverride = runtime.reasoningOverride; - } else { - if (droidRoute.duplicateReasoningDisplays.length > 0) { - console.error( - warn( - `[!] Multiple reasoning flags detected. Using first occurrence: ${droidRoute.reasoningSourceDisplay || ''}` - ) - ); - } - if (droidRoute.autoPrependedExec && process.stdout.isTTY) { - console.error( - info('Detected Droid exec-only flags. Routing as: droid exec [prompt]') - ); - } - } - } catch (error) { - if (error instanceof DroidReasoningFlagError || error instanceof DroidCommandRouterError) { - exitWithRuntimeReasoningFlagError(error.message, { - codexAliasLevels: 'minimal|low|medium|high|xhigh', - includeDroidExecExample: true, - }); - } - throw error; - } - } else if (resolvedTarget === 'codex') { - try { - const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env.CCS_THINKING); - targetRemainingArgs = runtime.argsWithoutReasoningFlags; - const normalizedReasoning = normalizeCodexRuntimeReasoningOverride( - runtime.reasoningOverride - ); - if (runtime.reasoningOverride !== undefined && !normalizedReasoning) { - if (runtime.reasoningSource === 'flag') { - throw new DroidReasoningFlagError( - 'Codex target supports reasoning levels only: minimal, low, medium, high, xhigh.', - '--effort' - ); - } - runtimeReasoningOverride = undefined; - } else { - runtimeReasoningOverride = normalizedReasoning; - } - } catch (error) { - if (error instanceof DroidReasoningFlagError) { - exitWithRuntimeReasoningFlagError(error.message, { - codexAliasLevels: 'minimal|low|medium|high|xhigh', - }); - } - throw error; - } - } else if (resolvedTarget === 'claude' && shouldNormalizeNativeClaudeEffort(profileInfo.type)) { - nativeClaudeRemainingArgs = normalizeNativeClaudeEffortArgs(remainingArgs); - } - // Special case: headless delegation (-p/--prompt) // Keep existing behavior for Claude targets only; non-claude targets must continue // through normal adapter dispatch logic. diff --git a/src/dispatcher/__tests__/profile-resolver.test.ts b/src/dispatcher/__tests__/profile-resolver.test.ts new file mode 100644 index 00000000..3200f9e3 --- /dev/null +++ b/src/dispatcher/__tests__/profile-resolver.test.ts @@ -0,0 +1,387 @@ +/** + * Tests for resolveProfileAndTarget() — Phase C extraction. + * + * The function has deep dynamic-import dependencies (ProfileDetector, targets, + * browser config, etc.). Strategy: mock the heavy boundary modules and verify + * the control-flow contract and shape of the returned ResolvedProfile object. + * + * Coverage goals: + * - Default 'claude' profile resolves with null targetAdapter + * - CLIProxy profile (gemini) resolves correctly + * - Settings profile (glm) resolves with settings loaded + compatibility check + * - Unknown profile triggers ProfileError (thrown, caught by main's try/catch) + * - --target droid resolves droid adapter + binary + * - Compatibility check runs for non-settings profiles (duplication preserved) + */ + +import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; +import type { Logger } from '../../services/logging/logger'; + +// ========== Stub Logger ========== + +function makeStubLogger(): Logger { + return { + info: mock(() => {}), + warn: mock(() => {}), + error: mock(() => {}), + debug: mock(() => {}), + stage: mock(() => {}), + } as unknown as Logger; +} + +// ========== Module mocks ========== + +// We need to mock heavy dependencies at the module level before importing the SUT. +// Bun's module mock API replaces modules in the registry for the current test file. + +// Mock ProfileDetector — most tests override detectProfileType per-case +const mockDetectProfileType = mock((_profile: string) => ({ + type: 'default' as const, + name: 'default', + target: undefined, +})); +const mockGetAllProfiles = mock(() => ({ + settings: [] as string[], + cliproxy: [], + cliproxyVariants: [], +})); + +mock.module('../../auth/profile-detector', () => ({ + default: class MockProfileDetector { + detectProfileType = mockDetectProfileType; + getAllProfiles = mockGetAllProfiles; + }, +})); + +// Mock management / registry / context modules (dynamically imported, no-op for these tests) +mock.module('../../management/instance-manager', () => ({ default: class {} })); +mock.module('../../auth/profile-registry', () => ({ default: class {} })); +mock.module('../../auth/account-context', () => ({ + resolveAccountContextPolicy: mock(() => 'default'), + isAccountContextMetadata: mock(() => false), +})); +mock.module('../../auth/profile-continuity-inheritance', () => ({ + resolveProfileContinuityInheritance: mock(() => null), +})); + +// Mock target-resolver +const mockResolveTargetType = mock((_args: string[]) => 'claude' as const); +const mockStripTargetFlag = mock((args: string[]) => args); +mock.module('../../targets/target-resolver', () => ({ + resolveTargetType: mockResolveTargetType, + stripTargetFlag: mockStripTargetFlag, +})); + +// Mock targets registry +const mockGetTarget = mock((_name: string) => null); +const mockEvaluateTargetRuntimeCompatibility = mock(() => ({ supported: true })); +const mockPruneOrphanedModels = mock(async () => {}); +mock.module('../../targets', () => ({ + getTarget: mockGetTarget, + evaluateTargetRuntimeCompatibility: mockEvaluateTargetRuntimeCompatibility, + pruneOrphanedModels: mockPruneOrphanedModels, +})); + +// Mock claude-detector +const mockDetectClaudeCli = mock(() => '/usr/local/bin/claude'); +mock.module('../../utils/claude-detector', () => ({ + detectClaudeCli: mockDetectClaudeCli, +})); + +// Mock ErrorManager +mock.module('../../utils/error-manager', () => ({ + ErrorManager: { showClaudeNotFound: mock(async () => {}) }, +})); + +// Mock config-manager +mock.module('../../utils/config-manager', () => ({ + getSettingsPath: mock((name: string) => `/tmp/.ccs/profiles/${name}/settings.json`), + loadSettings: mock(() => ({ env: {} })), +})); + +// Mock helpers +mock.module('../../utils/helpers', () => ({ + expandPath: mock((p: string) => p), +})); + +// Mock browser config +mock.module('../../config/unified-config-loader', () => ({ + getBrowserConfig: mock(() => ({ + claude: { enabled: false, policy: 'never' }, + codex: { enabled: false, policy: 'never' }, + })), +})); + +// Mock browser utilities +mock.module('../../utils/browser', () => ({ + getEffectiveClaudeBrowserAttachConfig: mock(() => undefined), + resolveBrowserExposure: mock(() => ({ enabled: false })), + getBlockedBrowserOverrideWarning: mock(() => undefined), +})); + +// Mock cliproxy bridge +mock.module('../../api/services/cliproxy-profile-bridge', () => ({ + resolveCliproxyBridgeMetadata: mock(() => undefined), +})); + +// Mock environment-builder (only resolveCodexRuntimeConfigOverrides is called) +mock.module('../environment-builder', () => ({ + resolveCodexRuntimeConfigOverrides: mock(() => [] as string[]), + resolveNativeClaudeLaunchArgs: mock((a: string[]) => a), + refreshUpdateCache: mock(() => {}), + showCachedUpdateNotification: mock(async () => {}), +})); + +// Mock cli-argument-parser (detectProfile + normalization helpers) +const mockDetectProfile = mock((args: string[]) => ({ + profile: args[0] || 'default', + remainingArgs: args.slice(1), +})); +mock.module('../cli-argument-parser', () => ({ + detectProfile: mockDetectProfile, + resolveRuntimeReasoningFlags: mock((args: string[]) => ({ + argsWithoutReasoningFlags: args, + reasoningOverride: undefined, + reasoningSource: undefined, + })), + normalizeCodexRuntimeReasoningOverride: mock(() => undefined), + exitWithRuntimeReasoningFlagError: mock(() => {}), + normalizeNativeClaudeEffortArgs: mock((a: string[]) => a), + shouldNormalizeNativeClaudeEffort: mock(() => false), + bootstrapAndParseEarlyCli: mock(async () => ({ + exitNow: false, + args: [], + browserLaunchOverride: undefined, + })), +})); + +// Mock droid command/reasoning modules +mock.module('../../targets/droid-command-router', () => ({ + DroidCommandRouterError: class DroidCommandRouterError extends Error {}, + routeDroidCommandArgs: mock((args: string[]) => ({ + mode: 'interactive', + argsForDroid: args, + duplicateReasoningDisplays: [], + reasoningSourceDisplay: undefined, + autoPrependedExec: false, + })), +})); +mock.module('../../targets/droid-reasoning-runtime', () => ({ + DroidReasoningFlagError: class DroidReasoningFlagError extends Error { + constructor(msg: string, _flag?: string) { + super(msg); + } + }, +})); + +// ========== Import SUT after mocks are registered ========== + +import { resolveProfileAndTarget } from '../profile-resolver'; + +// ========== Tests ========== + +describe('resolveProfileAndTarget', () => { + let exitSpy: ReturnType; + let consoleErrorSpy: ReturnType; + const stubLogger = makeStubLogger(); + + beforeEach(() => { + exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as () => never); + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + process.env['CI'] = '1'; + process.env['CCS_HOME'] = '/tmp/ccs-test-profile-resolver'; + // Reset relevant mocks to defaults + mockDetectProfileType.mockImplementation((_profile: string) => ({ + type: 'default' as const, + name: 'default', + target: undefined, + })); + mockResolveTargetType.mockImplementation((_args: string[]) => 'claude' as const); + mockStripTargetFlag.mockImplementation((args: string[]) => args); + mockGetTarget.mockImplementation((_name: string) => null); + mockDetectClaudeCli.mockImplementation(() => '/usr/local/bin/claude'); + mockEvaluateTargetRuntimeCompatibility.mockImplementation(() => ({ supported: true })); + }); + + afterEach(() => { + exitSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + delete process.env['CI']; + delete process.env['CCS_HOME']; + }); + + it('resolves default claude profile with null targetAdapter', async () => { + const result = await resolveProfileAndTarget({ + args: [], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + expect(result.resolvedTarget).toBe('claude'); + expect(result.targetAdapter).toBeNull(); + expect(result.claudeCli).toBe('/usr/local/bin/claude'); + expect(result.profileInfo.type).toBe('default'); + expect(result.targetBinaryInfo).toBeNull(); + expect(result.targetRemainingArgs).toEqual([]); + expect(result.nativeClaudeRemainingArgs).toEqual([]); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('resolves gemini cliproxy profile', async () => { + mockDetectProfileType.mockImplementation((_profile: string) => ({ + type: 'cliproxy' as const, + name: 'gemini', + provider: 'gemini', + target: undefined, + })); + mockDetectProfile.mockImplementation((args: string[]) => ({ + profile: 'gemini', + remainingArgs: args.slice(1), + })); + + const result = await resolveProfileAndTarget({ + args: ['gemini'], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + expect(result.profile).toBe('gemini'); + expect(result.profileInfo.type).toBe('cliproxy'); + expect(result.resolvedTarget).toBe('claude'); + expect(result.targetAdapter).toBeNull(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('resolves settings profile (glm) with --target droid and loads settings', async () => { + // Settings loading only runs in the non-claude preflight block (resolvedTarget !== 'claude'). + const mockDroidAdapter = { + displayName: 'Factory Droid', + detectBinary: mock(() => ({ path: '/usr/local/bin/droid', version: '1.0.0' })), + supportsProfileType: mock(() => true), + }; + mockGetTarget.mockImplementation((_name: string) => mockDroidAdapter); + mockResolveTargetType.mockImplementation((_args: string[]) => 'droid' as const); + mockDetectProfileType.mockImplementation((_profile: string) => ({ + type: 'settings' as const, + name: 'glm', + settingsPath: undefined, + target: undefined, + })); + mockDetectProfile.mockImplementation((args: string[]) => ({ + profile: 'glm', + remainingArgs: args.slice(1), + })); + + const result = await resolveProfileAndTarget({ + args: ['glm', '--target', 'droid'], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + expect(result.profile).toBe('glm'); + expect(result.profileInfo.type).toBe('settings'); + expect(result.resolvedTarget).toBe('droid'); + // Settings are loaded inside the non-claude preflight block + expect(result.resolvedSettingsPath).toBeDefined(); + expect(result.resolvedSettings).toBeDefined(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('calls process.exit(1) for unknown profile when claude CLI not found', async () => { + // Simulate claude not found for claude target + mockDetectClaudeCli.mockImplementation(() => null); + + await resolveProfileAndTarget({ + args: [], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('resolves --target droid with droid adapter and binary', async () => { + const mockBinaryInfo = { path: '/usr/local/bin/droid', version: '1.0.0' }; + const mockDroidAdapter = { + displayName: 'Factory Droid', + detectBinary: mock(() => mockBinaryInfo), + supportsProfileType: mock(() => true), + }; + mockGetTarget.mockImplementation((_name: string) => mockDroidAdapter); + mockResolveTargetType.mockImplementation((_args: string[]) => 'droid' as const); + mockDetectProfileType.mockImplementation((_profile: string) => ({ + type: 'settings' as const, + name: 'glm', + settingsPath: undefined, + target: undefined, + })); + mockDetectProfile.mockImplementation((args: string[]) => ({ + profile: 'glm', + remainingArgs: args.slice(1), + })); + + const result = await resolveProfileAndTarget({ + args: ['glm', '--target', 'droid'], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + expect(result.resolvedTarget).toBe('droid'); + expect(result.targetAdapter).toBe(mockDroidAdapter); + expect(result.targetBinaryInfo).toBe(mockBinaryInfo); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('calls process.exit(1) when droid binary not found', async () => { + const mockDroidAdapter = { + displayName: 'Factory Droid', + detectBinary: mock(() => null), + supportsProfileType: mock(() => true), + }; + mockGetTarget.mockImplementation((_name: string) => mockDroidAdapter); + mockResolveTargetType.mockImplementation((_args: string[]) => 'droid' as const); + mockDetectProfileType.mockImplementation((_profile: string) => ({ + type: 'default' as const, + name: 'default', + target: undefined, + })); + + await resolveProfileAndTarget({ + args: ['--target', 'droid'], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('runs compatibility check for non-settings profiles (duplication preserved)', async () => { + const mockDroidAdapter = { + displayName: 'Factory Droid', + detectBinary: mock(() => ({ path: '/usr/local/bin/droid', version: '1.0.0' })), + supportsProfileType: mock(() => true), + }; + mockGetTarget.mockImplementation((_name: string) => mockDroidAdapter); + mockResolveTargetType.mockImplementation((_args: string[]) => 'droid' as const); + mockDetectProfileType.mockImplementation((_profile: string) => ({ + type: 'cliproxy' as const, + name: 'gemini', + provider: 'gemini', + target: undefined, + })); + mockDetectProfile.mockImplementation((args: string[]) => ({ + profile: 'gemini', + remainingArgs: args.slice(1), + })); + + await resolveProfileAndTarget({ + args: ['gemini', '--target', 'droid'], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + // Compatibility check must have been called (the duplication from Phase C) + expect(mockEvaluateTargetRuntimeCompatibility).toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/dispatcher/profile-resolver.ts b/src/dispatcher/profile-resolver.ts new file mode 100644 index 00000000..0a692667 --- /dev/null +++ b/src/dispatcher/profile-resolver.ts @@ -0,0 +1,358 @@ +/** + * Profile and target resolution — Phase C extraction from src/ccs.ts. + * + * Extracted from main() lines ~136–313 (after Phase B pre-dispatch). + * Responsible for: dynamic imports of auth modules, profile detection, target type + * resolution, Claude CLI detection, target adapter resolution, compatibility preflight + * checks (preserved with duplication per plan risk note 5), binary detection, and + * per-target argument normalization (droid routing + reasoning flag stripping, + * codex reasoning normalization, native Claude effort normalization). + * + * Outputs a ResolvedProfile context object consumed by Phase D (environment-builder + * argument normalization) and Phase E (per-profile dispatch flows). + */ + +import { detectClaudeCli } from '../utils/claude-detector'; +import { getSettingsPath, loadSettings } from '../utils/config-manager'; +import { expandPath } from '../utils/helpers'; +import { fail, info, warn } from '../utils/ui'; +import { ErrorManager } from '../utils/error-manager'; +import { getBrowserConfig } from '../config/unified-config-loader'; +import { + getEffectiveClaudeBrowserAttachConfig, + resolveBrowserExposure, + getBlockedBrowserOverrideWarning, +} from '../utils/browser'; +import { getTarget, evaluateTargetRuntimeCompatibility, pruneOrphanedModels } from '../targets'; +import { resolveTargetType, stripTargetFlag } from '../targets/target-resolver'; +import { DroidReasoningFlagError } from '../targets/droid-reasoning-runtime'; +import { DroidCommandRouterError, routeDroidCommandArgs } from '../targets/droid-command-router'; +import { resolveCliproxyBridgeMetadata } from '../api/services/cliproxy-profile-bridge'; +import { resolveCodexRuntimeConfigOverrides } from './environment-builder'; +import { + detectProfile, + resolveRuntimeReasoningFlags, + normalizeCodexRuntimeReasoningOverride, + exitWithRuntimeReasoningFlagError, + normalizeNativeClaudeEffortArgs, + shouldNormalizeNativeClaudeEffort, +} from './cli-argument-parser'; +import type ProfileDetector from '../auth/profile-detector'; +import type { ProfileDetectionResult } from '../auth/profile-detector'; +import type { BrowserLaunchOverride } from '../utils/browser'; +import type { ResolvedBrowserExposure } from '../utils/browser/browser-policy'; +import type { Logger } from '../services/logging/logger'; +import type { TargetAdapter, TargetBinaryInfo } from '../targets'; + +// ========== Interfaces ========== + +export interface ProfileResolutionContext { + args: string[]; + browserLaunchOverride: BrowserLaunchOverride | undefined; + cliLogger: Logger; +} + +export interface ResolvedProfile { + /** Detected profile name (first positional arg or 'default') */ + profile: string; + /** Args remaining after stripping profile token and --target flags */ + remainingArgs: string[]; + profileInfo: ProfileDetectionResult; + resolvedTarget: ReturnType; + claudeCli: string; + targetAdapter: TargetAdapter | null; + targetBinaryInfo: TargetBinaryInfo | null; + resolvedSettingsPath: string | undefined; + resolvedSettings: ReturnType | undefined; + resolvedCliproxyBridge: ReturnType | undefined; + /** Per-target normalized args (droid/codex routing applied) */ + targetRemainingArgs: string[]; + /** Claude-specific args with effort normalization applied */ + nativeClaudeRemainingArgs: string[]; + runtimeReasoningOverride: string | number | undefined; + codexRuntimeConfigOverrides: string[]; + claudeBrowserExposure: ResolvedBrowserExposure | undefined; + codexBrowserExposure: ResolvedBrowserExposure | undefined; + claudeAttachConfig: ReturnType | undefined; + /** ProfileDetector instance — Phase E account/settings flows need getAllProfiles() */ + detector: InstanceType; +} + +// ========== Profile and Target Resolver ========== + +/** + * Resolve profile detection, target type, adapter, binary, and per-target arg normalization. + * + * Throws ProfileError (caught by main() try/catch) for unknown profiles. + * Calls process.exit(1) for hard-stop conditions (adapter not found, binary not found, etc.). + * + * NOTE: The compatibility preflight block for non-settings profiles (else branch) is + * INTENTIONALLY duplicated between here (Phase C) and the settings flow (Phase E). + * Per plan risk note 5, do NOT dedupe in this PR. + */ +export async function resolveProfileAndTarget( + ctx: ProfileResolutionContext +): Promise { + const { args, browserLaunchOverride } = ctx; + + // Dynamic imports — preserved at original call sites for latency/circular-dep avoidance. + // InstanceManager, ProfileRegistry, AccountContext, ProfileContinuity are loaded here + // to match original ordering; their resolved values flow into Phase E via the detector + // instance or are re-imported inside flow handlers. + const ProfileDetectorModule = await import('../auth/profile-detector'); + const ProfileDetectorClass = ProfileDetectorModule.default; + await import('../management/instance-manager'); + await import('../auth/profile-registry'); + await import('../auth/account-context'); + await import('../auth/profile-continuity-inheritance'); + + const detector = new ProfileDetectorClass(); + + // Detect profile (strip --target flags before profile detection) + const cleanArgs = stripTargetFlag(args); + const { profile, remainingArgs } = detectProfile(cleanArgs); + const profileInfo: ProfileDetectionResult = detector.detectProfileType(profile); + + let resolvedTarget: ReturnType; + try { + resolvedTarget = resolveTargetType( + args, + profileInfo.target ? { target: profileInfo.target } : undefined + ); + } catch (error) { + console.error(fail((error as Error).message)); + process.exit(1); + // Unreachable; needed so TS knows resolvedTarget is always assigned below + throw error; + } + + // Detect Claude CLI (needed for claude target and all CLIProxy-derived flows) + const claudeCliRaw = detectClaudeCli(); + if (resolvedTarget === 'claude' && !claudeCliRaw) { + await ErrorManager.showClaudeNotFound(); + process.exit(1); + } + const claudeCli = claudeCliRaw || ''; + + // Resolve non-claude target adapter once. + const targetAdapter: TargetAdapter | null = + resolvedTarget !== 'claude' ? (getTarget(resolvedTarget) ?? null) : null; + let resolvedSettingsPath: string | undefined; + let resolvedSettings: ReturnType | undefined; + let resolvedCliproxyBridge: ReturnType | undefined; + + // Preflight unsupported profile/target combinations BEFORE binary detection, + // so users get the most actionable error even when the target CLI is not installed. + if (resolvedTarget !== 'claude') { + if (!targetAdapter) { + console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); + process.exit(1); + } + + if (profileInfo.type === 'settings') { + resolvedSettingsPath = profileInfo.settingsPath + ? expandPath(profileInfo.settingsPath) + : getSettingsPath(profileInfo.name); + resolvedSettings = loadSettings(resolvedSettingsPath); + resolvedCliproxyBridge = resolveCliproxyBridgeMetadata(resolvedSettings); + const compatibility = evaluateTargetRuntimeCompatibility({ + target: resolvedTarget, + profileType: profileInfo.type, + cliproxyBridgeProvider: resolvedCliproxyBridge?.provider ?? null, + }); + if (!compatibility.supported) { + console.error( + fail( + compatibility.reason || `${targetAdapter.displayName} does not support this profile.` + ) + ); + if (compatibility.suggestion) { + console.error(info(compatibility.suggestion)); + } + process.exit(1); + } + } else { + // NOTE: Intentional duplication — Phase E settings flow re-runs this check post-load. + // Per plan risk note 5, preserve verbatim; do NOT dedupe. + const compatibility = evaluateTargetRuntimeCompatibility({ + target: resolvedTarget, + profileType: profileInfo.type, + cliproxyProvider: profileInfo.type === 'cliproxy' ? profileInfo.provider : undefined, + isComposite: profileInfo.type === 'cliproxy' ? Boolean(profileInfo.isComposite) : undefined, + }); + if (!compatibility.supported) { + console.error( + fail( + compatibility.reason || `${targetAdapter.displayName} does not support this profile.` + ) + ); + if (compatibility.suggestion) { + console.error(info(compatibility.suggestion)); + } + process.exit(1); + } + } + + if (profileInfo.type === 'default') { + if (!targetAdapter.supportsProfileType('default')) { + console.error(fail(`${targetAdapter.displayName} does not support default profile mode`)); + process.exit(1); + } + + // For default mode, Droid requires explicit credentials from environment. + if (resolvedTarget === 'droid') { + const baseUrl = process.env['ANTHROPIC_BASE_URL'] || ''; + const apiKey = process.env['ANTHROPIC_AUTH_TOKEN'] || ''; + if (!baseUrl.trim() || !apiKey.trim()) { + console.error( + fail( + `${targetAdapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN` + ) + ); + console.error(info('Use a settings-based profile instead: ccs glm --target droid')); + process.exit(1); + } + } + } + } + + // For non-claude targets, verify target binary exists once and pass it through. + const targetBinaryInfo: TargetBinaryInfo | null = targetAdapter?.detectBinary() ?? null; + const browserConfig = getBrowserConfig(); + const claudeAttachConfig = + resolvedTarget === 'claude' ? getEffectiveClaudeBrowserAttachConfig(browserConfig) : undefined; + const codexRuntimeConfigOverrides = resolveCodexRuntimeConfigOverrides( + resolvedTarget, + browserLaunchOverride + ); + const claudeBrowserExposure: ResolvedBrowserExposure | undefined = + resolvedTarget === 'claude' + ? resolveBrowserExposure( + { + enabled: claudeAttachConfig?.enabled ?? browserConfig.claude.enabled, + policy: browserConfig.claude.policy, + }, + browserLaunchOverride + ) + : undefined; + const codexBrowserExposure: ResolvedBrowserExposure | undefined = + resolvedTarget === 'codex' + ? resolveBrowserExposure(browserConfig.codex, browserLaunchOverride) + : undefined; + const blockedBrowserOverrideWarning = + resolvedTarget === 'claude' && claudeBrowserExposure + ? getBlockedBrowserOverrideWarning('Claude Browser Attach', claudeBrowserExposure) + : resolvedTarget === 'codex' && codexBrowserExposure + ? getBlockedBrowserOverrideWarning('Codex Browser Tools', codexBrowserExposure) + : undefined; + if (blockedBrowserOverrideWarning) { + console.error(warn(blockedBrowserOverrideWarning)); + } + if (resolvedTarget !== 'claude' && !targetBinaryInfo) { + const displayName = targetAdapter?.displayName || resolvedTarget; + console.error(fail(`${displayName} CLI not found.`)); + if (resolvedTarget === 'droid') { + console.error(info('Install: npm i -g @factory/cli')); + } else if (resolvedTarget === 'codex') { + console.error(info('Install a recent @openai/codex build, then retry.')); + } + process.exit(1); + } + + // Best-effort: prune stale Droid model entries at runtime so settings.json stays clean. + if (resolvedTarget === 'droid') { + try { + const allProfiles = detector.getAllProfiles(); + const activeProfiles = allProfiles.settings.filter((name) => /^[a-zA-Z0-9._-]+$/.test(name)); + await pruneOrphanedModels(activeProfiles); + } catch (error) { + console.error(warn(`[!] Droid prune skipped: ${(error as Error).message}`)); + } + } + + // Per-target argument normalization + let targetRemainingArgs = remainingArgs; + let runtimeReasoningOverride: string | number | undefined; + let nativeClaudeRemainingArgs = remainingArgs; + + if (resolvedTarget === 'droid') { + try { + const droidRoute = routeDroidCommandArgs(remainingArgs); + targetRemainingArgs = droidRoute.argsForDroid; + + if (droidRoute.mode === 'interactive') { + const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env['CCS_THINKING']); + targetRemainingArgs = runtime.argsWithoutReasoningFlags; + runtimeReasoningOverride = runtime.reasoningOverride; + } else { + if (droidRoute.duplicateReasoningDisplays.length > 0) { + console.error( + warn( + `[!] Multiple reasoning flags detected. Using first occurrence: ${droidRoute.reasoningSourceDisplay || ''}` + ) + ); + } + if (droidRoute.autoPrependedExec && process.stdout.isTTY) { + console.error( + info('Detected Droid exec-only flags. Routing as: droid exec [prompt]') + ); + } + } + } catch (error) { + if (error instanceof DroidReasoningFlagError || error instanceof DroidCommandRouterError) { + exitWithRuntimeReasoningFlagError(error.message, { + codexAliasLevels: 'minimal|low|medium|high|xhigh', + includeDroidExecExample: true, + }); + } + throw error; + } + } else if (resolvedTarget === 'codex') { + try { + const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env['CCS_THINKING']); + targetRemainingArgs = runtime.argsWithoutReasoningFlags; + const normalizedReasoning = normalizeCodexRuntimeReasoningOverride(runtime.reasoningOverride); + if (runtime.reasoningOverride !== undefined && !normalizedReasoning) { + if (runtime.reasoningSource === 'flag') { + throw new DroidReasoningFlagError( + 'Codex target supports reasoning levels only: minimal, low, medium, high, xhigh.', + '--effort' + ); + } + runtimeReasoningOverride = undefined; + } else { + runtimeReasoningOverride = normalizedReasoning; + } + } catch (error) { + if (error instanceof DroidReasoningFlagError) { + exitWithRuntimeReasoningFlagError(error.message, { + codexAliasLevels: 'minimal|low|medium|high|xhigh', + }); + } + throw error; + } + } else if (resolvedTarget === 'claude' && shouldNormalizeNativeClaudeEffort(profileInfo.type)) { + nativeClaudeRemainingArgs = normalizeNativeClaudeEffortArgs(remainingArgs); + } + + return { + profile, + remainingArgs, + profileInfo, + resolvedTarget, + claudeCli, + targetAdapter, + targetBinaryInfo, + resolvedSettingsPath, + resolvedSettings, + resolvedCliproxyBridge, + targetRemainingArgs, + nativeClaudeRemainingArgs, + runtimeReasoningOverride, + codexRuntimeConfigOverrides, + claudeBrowserExposure, + codexBrowserExposure, + claudeAttachConfig, + detector, + }; +}