diff --git a/src/ccs.ts b/src/ccs.ts index d1a27bfc..b1163244 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -1,84 +1,17 @@ import './utils/fetch-proxy-setup'; -import { getSettingsPath, loadSettings } from './utils/config-manager'; -import { expandPath } from './utils/helpers'; -import { - validateGlmKey, - validateMiniMaxKey, - validateAnthropicKey, -} from './utils/api-key-validator'; import { ErrorManager } from './utils/error-manager'; -import { - execClaudeWithCLIProxy, - CLIProxyProvider, - ensureCliproxyService, - isAuthenticated, -} from './cliproxy'; -import { getEffectiveEnvVars, getCompositeEnvVars } from './cliproxy/config/env-builder'; -import { CLIPROXY_DEFAULT_PORT } from './cliproxy/config/port-manager'; -import { - ensureWebSearchMcpOrThrow, - displayWebSearchStatus, - getWebSearchHookEnv, - syncWebSearchMcpToConfigDir, - appendThirdPartyWebSearchToolArgs, - createWebSearchTraceContext, -} from './utils/websearch-manager'; -import { - ensureImageAnalysisMcpOrThrow, - syncImageAnalysisMcpToConfigDir, - appendThirdPartyImageAnalysisToolArgs, -} from './utils/image-analysis'; -import { - appendBrowserToolArgs, - ensureBrowserMcpOrThrow, - resolveOptionalBrowserAttachRuntime, - syncBrowserMcpToConfigDir, -} from './utils/browser'; -import { getGlobalEnvConfig } from './config/unified-config-loader'; -import { - ensureProfileHooks as ensureImageAnalyzerHooks, - removeImageAnalysisProfileHook, -} from './utils/hooks/image-analyzer-profile-hook-injector'; -import { - applyImageAnalysisRuntimeOverrides, - getImageAnalysisHookEnv, - installImageAnalyzerHook, - prepareImageAnalysisFallbackHook, - resolveImageAnalysisRuntimeConnection, - resolveImageAnalysisRuntimeStatus, -} from './utils/hooks'; -import { fail, info, warn } from './utils/ui'; +import { fail } from './utils/ui'; // Import centralized error handling import { handleError, runCleanup } from './errors'; -// Import extracted utility functions -import { execClaude, stripAnthropicRoutingEnv, stripBrowserEnv } from './utils/shell-executor'; -import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation'; -import { createOpenAICompatLaunchSettings } from './utils/openai-compat-launch-settings'; -import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning'; import { createLogger, runWithRequestId } from './services/logging'; // Import target adapter system -import { - registerTarget, - ClaudeAdapter, - DroidAdapter, - CodexAdapter, - evaluateTargetRuntimeCompatibility, - resolveDroidProvider, - type TargetCredentials, -} from './targets'; -import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge'; -import { - buildOpenAICompatProxyEnv, - resolveOpenAICompatProfileConfig, - startOpenAICompatProxy, -} from './proxy'; +import { registerTarget, ClaudeAdapter, DroidAdapter, CodexAdapter } from './targets'; // Import extracted dispatcher modules import { bootstrapAndParseEarlyCli } from './dispatcher/cli-argument-parser'; -import { resolveNativeClaudeLaunchArgs } from './dispatcher/environment-builder'; -import { type ProfileError } from './dispatcher/target-executor'; +import { type ProfileError, dispatchProfile } from './dispatcher/target-executor'; import { runPreDispatchHandlers } from './dispatcher/pre-dispatch'; import { resolveProfileAndTarget } from './dispatcher/profile-resolver'; @@ -113,36 +46,10 @@ async function main(): Promise { } // 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 }); + const resolvedProfile = await resolveProfileAndTarget({ args, browserLaunchOverride, cliLogger }); + const { profile, profileInfo, resolvedTarget, nativeClaudeRemainingArgs } = resolvedProfile; - // 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). + // Dynamic imports needed by Phase E flows — preserve original load ordering. const InstanceManagerModule = await import('./management/instance-manager'); const InstanceManager = InstanceManagerModule.default; const ProfileRegistryModule = await import('./auth/profile-registry'); @@ -152,6 +59,16 @@ async function main(): Promise { const ProfileContinuityModule = await import('./auth/profile-continuity-inheritance'); const { resolveProfileContinuityInheritance } = ProfileContinuityModule; + // Build full dispatch context (Phase E) + const dispatchCtx = { + ...resolvedProfile, + InstanceManager, + ProfileRegistry, + resolveAccountContextPolicy, + isAccountContextMetadata, + resolveProfileContinuityInheritance, + }; + try { // Special case: headless delegation (-p/--prompt) // Keep existing behavior for Claude targets only; non-claude targets must continue @@ -166,795 +83,8 @@ async function main(): Promise { } } - if (profileInfo.type === 'cliproxy') { - // CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants - const imageAnalysisMcpReady = - resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true; - if (resolvedTarget === 'claude') { - ensureWebSearchMcpOrThrow(); - } - const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider); - const expandedCliproxySettingsPath = profileInfo.settingsPath - ? expandPath(profileInfo.settingsPath) - : undefined; - if (resolvedTarget === 'claude') { - if (imageAnalysisMcpReady) { - removeImageAnalysisProfileHook(profileInfo.name, expandedCliproxySettingsPath); - } else { - const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); - ensureImageAnalyzerHooks({ - profileName: profileInfo.name, - profileType: profileInfo.type, - cliproxyProvider: provider, - isComposite: profileInfo.isComposite, - settingsPath: expandedCliproxySettingsPath, - sharedHookInstalled: imageAnalysisFallbackHookReady, - }); - } - } - const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles - const variantPort = profileInfo.port; // variant-specific port for isolation - const cliproxyPort = variantPort || CLIPROXY_DEFAULT_PORT; - - if (resolvedTarget !== 'claude') { - const adapter = targetAdapter; - if (!adapter) { - console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); - process.exitCode = 1; - return; - } - if (!adapter.supportsProfileType('cliproxy')) { - console.error(fail(`${adapter.displayName} does not support CLIProxy profiles`)); - process.exitCode = 1; - return; - } - - // Keep CLIProxy management/auth flags on Claude flow only. - const unsupportedCliproxyFlags = [ - '--auth', - '--logout', - '--accounts', - '--add', - '--use', - '--config', - '--headless', - '--paste-callback', - '--port-forward', - '--nickname', - '--kiro-auth-method', - '--kiro-idc-start-url', - '--kiro-idc-region', - '--kiro-idc-flow', - '--backend', - '--proxy-host', - '--proxy-port', - '--proxy-protocol', - '--proxy-auth-token', - '--proxy-timeout', - '--local-proxy', - '--remote-only', - '--no-fallback', - '--allow-self-signed', - '--1m', - '--no-1m', - ]; - const providedUnsupportedFlag = unsupportedCliproxyFlags.find( - (flag) => - targetRemainingArgs.includes(flag) || - targetRemainingArgs.some((arg) => arg.startsWith(`${flag}=`)) - ); - if (providedUnsupportedFlag) { - console.error( - fail( - `${providedUnsupportedFlag} is only supported when running CLIProxy profiles on Claude target` - ) - ); - console.error( - info(`Run with Claude target: ccs ${profileInfo.name} --target claude ...`) - ); - process.exitCode = 1; - return; - } - - // For Droid execution path, require existing OAuth auth and running local proxy. - if (profileInfo.isComposite && profileInfo.compositeTiers) { - const compositeProviders = [ - ...new Set(Object.values(profileInfo.compositeTiers).map((tier) => tier.provider)), - ] as CLIProxyProvider[]; - const missingProvider = compositeProviders.find((p) => !isAuthenticated(p)); - if (missingProvider) { - console.error( - fail(`Missing OAuth auth for composite tier provider: ${missingProvider}`) - ); - console.error(info(`Authenticate first: ccs ${missingProvider} --auth`)); - process.exitCode = 1; - return; - } - } else if (!isAuthenticated(provider)) { - console.error(fail(`No OAuth authentication found for provider: ${provider}`)); - console.error(info(`Authenticate first: ccs ${provider} --auth`)); - process.exitCode = 1; - return; - } - - const ensureServiceResult = await ensureCliproxyService( - cliproxyPort, - targetRemainingArgs.includes('--verbose') || targetRemainingArgs.includes('-v') - ); - if (!ensureServiceResult.started) { - console.error( - fail(ensureServiceResult.error || 'Failed to start local CLIProxy service') - ); - process.exitCode = 1; - return; - } - - const envVars = - profileInfo.isComposite && profileInfo.compositeTiers && profileInfo.compositeDefaultTier - ? getCompositeEnvVars( - profileInfo.compositeTiers, - profileInfo.compositeDefaultTier, - cliproxyPort, - customSettingsPath - ) - : getEffectiveEnvVars(provider, cliproxyPort, customSettingsPath); - - const creds: TargetCredentials = { - profile: profileInfo.name, - baseUrl: envVars['ANTHROPIC_BASE_URL'] || '', - apiKey: envVars['ANTHROPIC_AUTH_TOKEN'] || '', - model: envVars['ANTHROPIC_MODEL'] || undefined, - provider: resolveDroidProvider({ - provider: envVars['CCS_DROID_PROVIDER'] || envVars['DROID_PROVIDER'], - baseUrl: envVars['ANTHROPIC_BASE_URL'], - model: envVars['ANTHROPIC_MODEL'], - }), - reasoningOverride: runtimeReasoningOverride, - runtimeConfigOverrides: codexRuntimeConfigOverrides, - envVars, - }; - - if (!creds.baseUrl || !creds.apiKey) { - console.error( - fail( - `Missing CLIProxy runtime credentials for ${profileInfo.name} (ANTHROPIC_BASE_URL/AUTH_TOKEN)` - ) - ); - console.error( - info('Reconfigure with: ccs config > CLIProxy, or run ccs --config') - ); - process.exitCode = 1; - return; - } - - await adapter.prepareCredentials(creds); - const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs, { - creds, - profileType: profileInfo.type, - binaryInfo: targetBinaryInfo || undefined, - }); - const targetEnv = adapter.buildEnv(creds, profileInfo.type); - adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); - return; - } - - await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, { - customSettingsPath, - port: cliproxyPort, - isComposite: profileInfo.isComposite, - compositeTiers: profileInfo.compositeTiers, - compositeDefaultTier: profileInfo.compositeDefaultTier, - profileName: profileInfo.name, - }); - } else if (profileInfo.type === 'copilot') { - // COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy - ensureWebSearchMcpOrThrow(); - const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); - if (resolvedTarget === 'claude') { - if (imageAnalysisMcpReady) { - removeImageAnalysisProfileHook(profileInfo.name); - } else { - const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); - ensureImageAnalyzerHooks({ - profileName: profileInfo.name, - profileType: profileInfo.type, - sharedHookInstalled: imageAnalysisFallbackHookReady, - }); - } - } - - const { executeCopilotProfile } = await import('./copilot'); - const copilotConfig = profileInfo.copilotConfig; - if (!copilotConfig) { - console.error(fail('Copilot configuration not found')); - process.exit(1); - } - const continuityInheritance = await resolveProfileContinuityInheritance({ - profileName: profileInfo.name, - profileType: profileInfo.type, - target: resolvedTarget, - }); - if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { - console.error( - info( - `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` - ) - ); - } - const exitCode = await executeCopilotProfile( - copilotConfig, - remainingArgs, - continuityInheritance.claudeConfigDir, - claudeCli - ); - process.exit(exitCode); - } else if (profileInfo.type === 'cursor') { - // CURSOR FLOW: local Cursor daemon profile - ensureWebSearchMcpOrThrow(); - installImageAnalyzerHook(); - ensureImageAnalyzerHooks({ - profileName: profileInfo.name, - profileType: profileInfo.type, - }); - - const { executeCursorProfile } = await import('./cursor'); - const cursorConfig = profileInfo.cursorConfig; - if (!cursorConfig) { - console.error(fail('Cursor configuration not found')); - process.exit(1); - } - const continuityInheritance = await resolveProfileContinuityInheritance({ - profileName: profileInfo.name, - profileType: profileInfo.type, - target: resolvedTarget, - }); - if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { - console.error( - info( - `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` - ) - ); - } - const exitCode = await executeCursorProfile( - cursorConfig, - remainingArgs, - continuityInheritance.claudeConfigDir, - claudeCli - ); - process.exit(exitCode); - } else if (profileInfo.type === 'settings') { - // Settings-based profiles (glm, glmt) are third-party providers - const imageAnalysisMcpReady = - resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true; - const browserAttachRuntime = - resolvedTarget === 'claude' && - claudeBrowserExposure?.exposeForLaunch && - claudeAttachConfig?.enabled - ? await resolveOptionalBrowserAttachRuntime(claudeAttachConfig) - : undefined; - const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; - if (browserAttachRuntime?.warning) { - process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`); - } - if (resolvedTarget === 'claude') { - ensureWebSearchMcpOrThrow(); - if (browserRuntimeEnv) { - ensureBrowserMcpOrThrow(); - } - } - - // Display WebSearch status (single line, equilibrium UX) - displayWebSearchStatus(); - - const continuityInheritance = - resolvedTarget === 'claude' - ? await resolveProfileContinuityInheritance({ - profileName: profileInfo.name, - profileType: profileInfo.type, - target: resolvedTarget, - }) - : {}; - if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { - console.error( - info( - `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` - ) - ); - } - const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir; - syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); - syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); - if ( - browserRuntimeEnv && - inheritedClaudeConfigDir && - !syncBrowserMcpToConfigDir(inheritedClaudeConfigDir) - ) { - throw new Error( - 'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.' - ); - } - const expandedSettingsPath = - resolvedSettingsPath ?? - (profileInfo.settingsPath - ? expandPath(profileInfo.settingsPath) - : getSettingsPath(profileInfo.name)); - const settings = resolvedSettings ?? loadSettings(expandedSettingsPath); - const cliproxyBridge = resolvedCliproxyBridge ?? resolveCliproxyBridgeMetadata(settings); - - let imageAnalysisFallbackHookReady: boolean | undefined; - if (resolvedTarget === 'claude') { - if (imageAnalysisMcpReady) { - removeImageAnalysisProfileHook(profileInfo.name, expandedSettingsPath); - } else { - imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); - ensureImageAnalyzerHooks({ - profileName: profileInfo.name, - profileType: profileInfo.type, - settingsPath: expandedSettingsPath, - settings, - cliproxyBridge, - sharedHookInstalled: imageAnalysisFallbackHookReady, - }); - } - } - if (resolvedTarget !== 'claude') { - const compatibility = evaluateTargetRuntimeCompatibility({ - target: resolvedTarget, - profileType: profileInfo.type, - cliproxyBridgeProvider: cliproxyBridge?.provider ?? null, - }); - if (!compatibility.supported) { - console.error( - fail( - compatibility.reason || - `${targetAdapter?.displayName || resolvedTarget} does not support this profile.` - ) - ); - if (compatibility.suggestion) { - console.error(info(compatibility.suggestion)); - } - process.exit(1); - } - } - const rawSettingsEnv = profileInfo.env ?? settings.env ?? {}; - const isDeprecatedGlmtProfile = isDeprecatedGlmtProfileName(profileInfo.name); - const glmtNormalization = isDeprecatedGlmtProfile - ? normalizeDeprecatedGlmtEnv(rawSettingsEnv) - : null; - const settingsEnv = glmtNormalization?.env ?? rawSettingsEnv; - - if (glmtNormalization) { - for (const message of glmtNormalization.warnings) { - console.error(warn(message)); - } - } - - // Pre-flight validation for Z.AI-compatible profiles. - if (profileInfo.name === 'glm' || isDeprecatedGlmtProfile) { - const apiKey = settingsEnv['ANTHROPIC_AUTH_TOKEN']; - - if (apiKey) { - const validation = await validateGlmKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']); - - if (!validation.valid) { - console.error(''); - console.error(fail(validation.error || 'API key validation failed')); - if (validation.suggestion) { - console.error(''); - console.error(validation.suggestion); - } - console.error(''); - console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs glm "prompt"')); - process.exit(1); - } - } - } - - if (profileInfo.name === 'mm') { - const apiKey = settingsEnv['ANTHROPIC_AUTH_TOKEN']; - - if (apiKey) { - const validation = await validateMiniMaxKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']); - - if (!validation.valid) { - console.error(''); - console.error(fail(validation.error || 'API key validation failed')); - if (validation.suggestion) { - console.error(''); - console.error(validation.suggestion); - } - console.error(''); - console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs mm "prompt"')); - process.exit(1); - } - } - } - - // Pre-flight validation for Anthropic direct profiles (ANTHROPIC_API_KEY + no BASE_URL) - { - const anthropicApiKey = settingsEnv['ANTHROPIC_API_KEY']; - const hasBaseUrl = !!settingsEnv['ANTHROPIC_BASE_URL']; - if (anthropicApiKey && !hasBaseUrl) { - const validation = await validateAnthropicKey(anthropicApiKey); - if (!validation.valid) { - console.error(''); - console.error(fail(validation.error || 'API key validation failed')); - if (validation.suggestion) { - console.error(''); - console.error(validation.suggestion); - } - console.error(''); - console.error( - info(`To skip validation: CCS_SKIP_PREFLIGHT=1 ccs ${profileInfo.name} "prompt"`) - ); - process.exit(1); - } - } - } - - const webSearchEnv = getWebSearchHookEnv(); - const imageAnalysisStatus = await resolveImageAnalysisRuntimeStatus({ - profileName: profileInfo.name, - profileType: profileInfo.type, - settings, - cliproxyBridge, - sharedHookInstalled: imageAnalysisFallbackHookReady, - }); - const runtimeConnection = resolveImageAnalysisRuntimeConnection(); - let imageAnalysisEnv = getImageAnalysisHookEnv({ - profileName: profileInfo.name, - profileType: profileInfo.type, - settings, - cliproxyBridge, - }); - imageAnalysisEnv = applyImageAnalysisRuntimeOverrides(imageAnalysisEnv, { - backendId: imageAnalysisStatus.backendId, - model: imageAnalysisStatus.model, - runtimePath: imageAnalysisStatus.runtimePath, - baseUrl: runtimeConnection.baseUrl, - apiKey: runtimeConnection.apiKey, - allowSelfSigned: runtimeConnection.allowSelfSigned, - }); - imageAnalysisEnv = { - ...imageAnalysisEnv, - CCS_IMAGE_ANALYSIS_SKIP_HOOK: - resolvedTarget === 'claude' && imageAnalysisMcpReady ? '1' : '0', - }; - - const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER']; - if ( - resolvedTarget === 'claude' && - imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' && - imageAnalysisProvider - ) { - const verboseProxyLaunch = - remainingArgs.includes('--verbose') || - remainingArgs.includes('-v') || - targetRemainingArgs.includes('--verbose') || - targetRemainingArgs.includes('-v'); - - if (imageAnalysisStatus.effectiveRuntimeMode === 'native-read') { - console.error( - info( - `${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This session will use native Read.` - ) - ); - imageAnalysisEnv = { - ...imageAnalysisEnv, - CCS_CURRENT_PROVIDER: '', - CCS_IMAGE_ANALYSIS_SKIP: '1', - CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '', - CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: '', - CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: '', - CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED: '0', - }; - } else if (imageAnalysisStatus.proxyReadiness === 'stopped') { - const ensureServiceResult = await ensureCliproxyService( - CLIPROXY_DEFAULT_PORT, - verboseProxyLaunch - ); - if (!ensureServiceResult.started) { - console.error( - warn( - `Image analysis via ${imageAnalysisProvider} is unavailable because CCS could not start the local CLIProxy service. This session will use native Read.` - ) - ); - imageAnalysisEnv = { - ...imageAnalysisEnv, - CCS_CURRENT_PROVIDER: '', - CCS_IMAGE_ANALYSIS_SKIP: '1', - }; - } - } - } - // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles - const globalEnvConfig = getGlobalEnvConfig(); - const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; - - // Log global env injection for visibility (debug mode only) - if (globalEnvConfig.enabled && Object.keys(globalEnv).length > 0 && process.env.CCS_DEBUG) { - const envNames = Object.keys(globalEnv).join(', '); - console.error(info(`Global env: ${envNames}`)); - } - - // For Claude target launches that already pass `--settings`, keep runtime - // env free of ANTHROPIC routing/auth while preserving non-routing profile - // env so nested Team/subagent sessions can still inherit model intent and - // other profile-scoped runtime flags. - const settingsRuntimeEnv = stripBrowserEnv({ ...globalEnv, ...settingsEnv }); - const claudeRuntimeEnvVars: NodeJS.ProcessEnv = { - ...stripAnthropicRoutingEnv(settingsRuntimeEnv), - ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), - ...webSearchEnv, - ...imageAnalysisEnv, - ...(browserRuntimeEnv || {}), - CCS_PROFILE_TYPE: 'settings', - CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1', - }; - - // Non-Claude targets still need effective credentials injected directly. - const envVars: NodeJS.ProcessEnv = { - ...settingsRuntimeEnv, - ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), - ...webSearchEnv, - ...imageAnalysisEnv, - ...(browserRuntimeEnv || {}), - CCS_PROFILE_TYPE: 'settings', - }; - - // Dispatch through target adapter for non-claude targets - if (resolvedTarget !== 'claude') { - const adapter = targetAdapter; - if (!adapter) { - console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); - process.exit(1); - } - const directAnthropicBaseUrl = - settingsEnv['ANTHROPIC_BASE_URL'] || - (settingsEnv['ANTHROPIC_API_KEY'] ? 'https://api.anthropic.com' : ''); - const creds: TargetCredentials = { - profile: profileInfo.name, - baseUrl: directAnthropicBaseUrl, - apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || settingsEnv['ANTHROPIC_API_KEY'] || '', - model: settingsEnv['ANTHROPIC_MODEL'], - provider: resolveDroidProvider({ - provider: settingsEnv['CCS_DROID_PROVIDER'] || settingsEnv['DROID_PROVIDER'], - baseUrl: directAnthropicBaseUrl, - model: settingsEnv['ANTHROPIC_MODEL'], - }), - reasoningOverride: runtimeReasoningOverride, - runtimeConfigOverrides: codexRuntimeConfigOverrides, - envVars, - }; - await adapter.prepareCredentials(creds); - const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs, { - creds, - profileType: profileInfo.type, - binaryInfo: targetBinaryInfo || undefined, - }); - const targetEnv = adapter.buildEnv(creds, profileInfo.type); - adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); - return; - } - - const imageAnalysisArgs = imageAnalysisMcpReady - ? appendThirdPartyImageAnalysisToolArgs(nativeClaudeRemainingArgs) - : nativeClaudeRemainingArgs; - const browserArgs = browserRuntimeEnv - ? appendBrowserToolArgs(imageAnalysisArgs) - : imageAnalysisArgs; - const openAICompatProfile = resolveOpenAICompatProfileConfig( - profileInfo.name, - expandedSettingsPath, - settingsEnv - ); - if (openAICompatProfile) { - const proxyStart = await startOpenAICompatProxy(openAICompatProfile, { - insecure: openAICompatProfile.insecure, - }); - if (!proxyStart.success) { - console.error(fail(proxyStart.error || 'Failed to start local OpenAI-compatible proxy')); - process.exit(1); - } - - console.error( - info( - `Using local OpenAI-compatible proxy for "${profileInfo.name}" on port ${proxyStart.port}` - ) - ); - - const proxyEnv = { - ...envVars, - ...buildOpenAICompatProxyEnv( - openAICompatProfile, - proxyStart.port, - proxyStart.authToken || '', - inheritedClaudeConfigDir - ), - }; - delete proxyEnv.ANTHROPIC_API_KEY; - const launchSettings = createOpenAICompatLaunchSettings(expandedSettingsPath, settings); - - const launchArgs = [ - '--settings', - launchSettings.settingsPath, - ...appendThirdPartyWebSearchToolArgs(browserArgs), - ]; - const traceEnv = createWebSearchTraceContext({ - launcher: 'ccs.settings-profile.proxy', - args: launchArgs, - profile: profileInfo.name, - profileType: profileInfo.type, - settingsPath: expandedSettingsPath, - }); - - execClaude(claudeCli, launchArgs, { ...proxyEnv, ...traceEnv }, launchSettings.cleanup); - return; - } - const launchArgs = [ - '--settings', - expandedSettingsPath, - ...appendThirdPartyWebSearchToolArgs(browserArgs), - ]; - const traceEnv = createWebSearchTraceContext({ - launcher: 'ccs.settings-profile', - args: launchArgs, - profile: profileInfo.name, - profileType: profileInfo.type, - settingsPath: expandedSettingsPath, - }); - - execClaude(claudeCli, launchArgs, { ...claudeRuntimeEnvVars, ...traceEnv }); - } else if (profileInfo.type === 'account') { - // NEW FLOW: Account-based profile (work, personal) - // All platforms: Use instance isolation with CLAUDE_CONFIG_DIR - const registry = new ProfileRegistry(); - const instanceMgr = new InstanceManager(); - const accountMetadata = isAccountContextMetadata(profileInfo.profile) - ? profileInfo.profile - : undefined; - const isBareProfile = - typeof profileInfo.profile === 'object' && - profileInfo.profile !== null && - (profileInfo.profile as { bare?: unknown }).bare === true; - const contextPolicy = resolveAccountContextPolicy(accountMetadata); - - // Ensure instance exists (lazy init if needed) - const instancePath = await instanceMgr.ensureInstance(profileInfo.name, contextPolicy, { - bare: isBareProfile, - }); - - // Update last_used timestamp (check unified config first, fallback to legacy) - if (registry.hasAccountUnified(profileInfo.name)) { - registry.touchAccountUnified(profileInfo.name); - } else { - registry.touchProfile(profileInfo.name); - } - - // Execute Claude with instance isolation - // Skip WebSearch hook - account profiles use native server-side WebSearch - // Skip Image Analyzer hook - account profiles have native vision support - const envVars: NodeJS.ProcessEnv = { - CLAUDE_CONFIG_DIR: instancePath, - CCS_PROFILE_TYPE: 'account', - CCS_WEBSEARCH_SKIP: '1', - CCS_IMAGE_ANALYSIS_SKIP: '1', - }; - await maybeWarnAboutResumeLaneMismatch( - profileInfo.name, - instancePath, - nativeClaudeRemainingArgs - ); - const launchArgs = resolveNativeClaudeLaunchArgs( - nativeClaudeRemainingArgs, - 'account', - instancePath - ); - execClaude(claudeCli, launchArgs, envVars); - } else { - // DEFAULT: No profile configured, use Claude's own defaults - // Skip WebSearch hook - native Claude has server-side WebSearch - // Skip Image Analyzer hook - native Claude has native vision support - const envVars: NodeJS.ProcessEnv = { - CCS_PROFILE_TYPE: 'default', - CCS_WEBSEARCH_SKIP: '1', - CCS_IMAGE_ANALYSIS_SKIP: '1', - }; - const browserAttachRuntime = - resolvedTarget === 'claude' && - claudeBrowserExposure?.exposeForLaunch && - claudeAttachConfig?.enabled - ? await resolveOptionalBrowserAttachRuntime(claudeAttachConfig) - : undefined; - const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; - if (browserAttachRuntime?.warning) { - process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`); - } - - if (resolvedTarget === 'claude') { - if (browserRuntimeEnv) { - ensureBrowserMcpOrThrow(); - Object.assign(envVars, browserRuntimeEnv); - } - const defaultContinuityInheritance = await resolveProfileContinuityInheritance({ - profileName: profileInfo.name, - profileType: profileInfo.type, - target: resolvedTarget, - }); - if (defaultContinuityInheritance.sourceAccount && process.env.CCS_DEBUG) { - console.error( - info( - `Continuity inheritance active: profile "${profileInfo.name}" -> account "${defaultContinuityInheritance.sourceAccount}"` - ) - ); - } - if (defaultContinuityInheritance.claudeConfigDir) { - envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir; - if ( - browserRuntimeEnv && - !syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir) - ) { - throw new Error( - 'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.' - ); - } - } - } - - // Dispatch through target adapter for non-claude targets - if (resolvedTarget !== 'claude') { - const adapter = targetAdapter; - if (!adapter) { - console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); - process.exit(1); - } - if (!adapter.supportsProfileType('default')) { - console.error(fail(`${adapter.displayName} does not support default profile mode`)); - process.exit(1); - } - const creds: TargetCredentials = { - profile: 'default', - baseUrl: process.env['ANTHROPIC_BASE_URL'] || '', - apiKey: process.env['ANTHROPIC_AUTH_TOKEN'] || '', - model: process.env['ANTHROPIC_MODEL'], - provider: resolveDroidProvider({ - provider: process.env['CCS_DROID_PROVIDER'] || process.env['DROID_PROVIDER'], - baseUrl: process.env['ANTHROPIC_BASE_URL'], - model: process.env['ANTHROPIC_MODEL'], - }), - reasoningOverride: runtimeReasoningOverride, - runtimeConfigOverrides: codexRuntimeConfigOverrides, - browserRuntimeEnv, - }; - if (resolvedTarget === 'droid' && (!creds.baseUrl || !creds.apiKey)) { - console.error( - fail( - `${adapter.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); - } - await adapter.prepareCredentials(creds); - const targetArgs = adapter.buildArgs('default', targetRemainingArgs, { - creds, - profileType: 'default', - binaryInfo: targetBinaryInfo || undefined, - }); - const targetEnv = adapter.buildEnv(creds, 'default'); - adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); - return; - } - - const launchArgs = resolveNativeClaudeLaunchArgs( - browserRuntimeEnv - ? appendBrowserToolArgs(nativeClaudeRemainingArgs) - : nativeClaudeRemainingArgs, - 'default', - envVars.CLAUDE_CONFIG_DIR - ); - execClaude(claudeCli, launchArgs, envVars); - } + // Phase E: dispatch to per-profile-type flow (all 6 branches now live in flows/) + await dispatchProfile(dispatchCtx); } catch (error) { const err = error as ProfileError; // Check if this is a profile not found error with suggestions diff --git a/src/dispatcher/dispatcher-context.ts b/src/dispatcher/dispatcher-context.ts new file mode 100644 index 00000000..b637bdc0 --- /dev/null +++ b/src/dispatcher/dispatcher-context.ts @@ -0,0 +1,40 @@ +/** + * Shared context type for Phase E profile dispatch flows. + * + * ProfileDispatchContext extends ResolvedProfile with the dynamic imports + * needed by all flows (InstanceManager, ProfileRegistry, AccountContext, + * ProfileContinuity), and the browser-exposure fields already computed by + * Phase C (profile-resolver.ts). + */ + +import type { ResolvedProfile } from './profile-resolver'; +import type { loadSettings } from '../utils/config-manager'; +import type { getEffectiveClaudeBrowserAttachConfig } from '../utils/browser'; +import type { resolveCliproxyBridgeMetadata } from '../api/services/cliproxy-profile-bridge'; +import type { resolveTargetType } from '../targets/target-resolver'; + +// Re-export ResolvedProfile fields as ProfileDispatchContext — +// all browser-exposure fields are already present in ResolvedProfile. +export type ProfileDispatchContext = ResolvedProfile & { + /** Dynamic-imported modules needed by account/settings flows */ + InstanceManager: Awaited['default']; + ProfileRegistry: Awaited['default']; + resolveAccountContextPolicy: Awaited< + typeof import('../auth/account-context') + >['resolveAccountContextPolicy']; + isAccountContextMetadata: Awaited< + typeof import('../auth/account-context') + >['isAccountContextMetadata']; + resolveProfileContinuityInheritance: Awaited< + typeof import('../auth/profile-continuity-inheritance') + >['resolveProfileContinuityInheritance']; +}; + +// Re-export for convenience +export type { ResolvedProfile }; +export type { + loadSettings, + getEffectiveClaudeBrowserAttachConfig, + resolveCliproxyBridgeMetadata, + resolveTargetType, +}; diff --git a/src/dispatcher/flows/account-flow.ts b/src/dispatcher/flows/account-flow.ts new file mode 100644 index 00000000..6fec0073 --- /dev/null +++ b/src/dispatcher/flows/account-flow.ts @@ -0,0 +1,63 @@ +/** + * Account dispatch flow — account-based profile (work, personal) with instance isolation. + * + * Extracted from src/ccs.ts main() profileInfo.type === 'account' branch. + * Uses CLAUDE_CONFIG_DIR for per-profile instance isolation on all platforms. + */ + +import { execClaude } from '../../utils/shell-executor'; +import { maybeWarnAboutResumeLaneMismatch } from '../../auth/resume-lane-warning'; +import { resolveNativeClaudeLaunchArgs } from '../environment-builder'; +import type { ProfileDispatchContext } from '../dispatcher-context'; + +export async function runAccountFlow(ctx: ProfileDispatchContext): Promise { + const { + profileInfo, + claudeCli, + nativeClaudeRemainingArgs, + InstanceManager, + ProfileRegistry, + resolveAccountContextPolicy, + isAccountContextMetadata, + } = ctx; + + const registry = new ProfileRegistry(); + const instanceMgr = new InstanceManager(); + const accountMetadata = isAccountContextMetadata(profileInfo.profile) + ? profileInfo.profile + : undefined; + const isBareProfile = + typeof profileInfo.profile === 'object' && + profileInfo.profile !== null && + (profileInfo.profile as { bare?: unknown }).bare === true; + const contextPolicy = resolveAccountContextPolicy(accountMetadata); + + // Ensure instance exists (lazy init if needed) + const instancePath = await instanceMgr.ensureInstance(profileInfo.name, contextPolicy, { + bare: isBareProfile, + }); + + // Update last_used timestamp (check unified config first, fallback to legacy) + if (registry.hasAccountUnified(profileInfo.name)) { + registry.touchAccountUnified(profileInfo.name); + } else { + registry.touchProfile(profileInfo.name); + } + + // Execute Claude with instance isolation. + // Skip WebSearch hook — account profiles use native server-side WebSearch. + // Skip Image Analyzer hook — account profiles have native vision support. + const envVars: NodeJS.ProcessEnv = { + CLAUDE_CONFIG_DIR: instancePath, + CCS_PROFILE_TYPE: 'account', + CCS_WEBSEARCH_SKIP: '1', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + await maybeWarnAboutResumeLaneMismatch(profileInfo.name, instancePath, nativeClaudeRemainingArgs); + const launchArgs = resolveNativeClaudeLaunchArgs( + nativeClaudeRemainingArgs, + 'account', + instancePath + ); + execClaude(claudeCli, launchArgs, envVars); +} diff --git a/src/dispatcher/flows/cliproxy-flow.ts b/src/dispatcher/flows/cliproxy-flow.ts new file mode 100644 index 00000000..577fd020 --- /dev/null +++ b/src/dispatcher/flows/cliproxy-flow.ts @@ -0,0 +1,211 @@ +/** + * CLIProxy dispatch flow — OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants. + * + * Extracted from src/ccs.ts main() profileInfo.type === 'cliproxy' branch. + */ + +import { expandPath } from '../../utils/helpers'; +import { fail, info } from '../../utils/ui'; +import { + execClaudeWithCLIProxy, + type CLIProxyProvider, + ensureCliproxyService, + isAuthenticated, +} from '../../cliproxy'; +import { getEffectiveEnvVars, getCompositeEnvVars } from '../../cliproxy/config/env-builder'; +import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager'; +import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; +import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; +import { + ensureProfileHooks as ensureImageAnalyzerHooks, + removeImageAnalysisProfileHook, +} from '../../utils/hooks/image-analyzer-profile-hook-injector'; +import { prepareImageAnalysisFallbackHook } from '../../utils/hooks'; +import { resolveDroidProvider, type TargetCredentials } from '../../targets'; +import type { ProfileDispatchContext } from '../dispatcher-context'; + +export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise { + const { + profileInfo, + resolvedTarget, + claudeCli, + targetAdapter, + targetRemainingArgs, + runtimeReasoningOverride, + codexRuntimeConfigOverrides, + remainingArgs, + } = ctx; + + const imageAnalysisMcpReady = + resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true; + if (resolvedTarget === 'claude') { + ensureWebSearchMcpOrThrow(); + } + const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider); + const expandedCliproxySettingsPath = profileInfo.settingsPath + ? expandPath(profileInfo.settingsPath) + : undefined; + if (resolvedTarget === 'claude') { + if (imageAnalysisMcpReady) { + removeImageAnalysisProfileHook(profileInfo.name, expandedCliproxySettingsPath); + } else { + const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); + ensureImageAnalyzerHooks({ + profileName: profileInfo.name, + profileType: profileInfo.type, + cliproxyProvider: provider, + isComposite: profileInfo.isComposite, + settingsPath: expandedCliproxySettingsPath, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + } + } + const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles + const variantPort = profileInfo.port; // variant-specific port for isolation + const cliproxyPort = variantPort || CLIPROXY_DEFAULT_PORT; + + if (resolvedTarget !== 'claude') { + const adapter = targetAdapter; + if (!adapter) { + console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); + process.exitCode = 1; + return; + } + if (!adapter.supportsProfileType('cliproxy')) { + console.error(fail(`${adapter.displayName} does not support CLIProxy profiles`)); + process.exitCode = 1; + return; + } + + // Keep CLIProxy management/auth flags on Claude flow only. + const unsupportedCliproxyFlags = [ + '--auth', + '--logout', + '--accounts', + '--add', + '--use', + '--config', + '--headless', + '--paste-callback', + '--port-forward', + '--nickname', + '--kiro-auth-method', + '--kiro-idc-start-url', + '--kiro-idc-region', + '--kiro-idc-flow', + '--backend', + '--proxy-host', + '--proxy-port', + '--proxy-protocol', + '--proxy-auth-token', + '--proxy-timeout', + '--local-proxy', + '--remote-only', + '--no-fallback', + '--allow-self-signed', + '--1m', + '--no-1m', + ]; + const providedUnsupportedFlag = unsupportedCliproxyFlags.find( + (flag) => + targetRemainingArgs.includes(flag) || + targetRemainingArgs.some((arg) => arg.startsWith(`${flag}=`)) + ); + if (providedUnsupportedFlag) { + console.error( + fail( + `${providedUnsupportedFlag} is only supported when running CLIProxy profiles on Claude target` + ) + ); + console.error(info(`Run with Claude target: ccs ${profileInfo.name} --target claude ...`)); + process.exitCode = 1; + return; + } + + // For Droid execution path, require existing OAuth auth and running local proxy. + if (profileInfo.isComposite && profileInfo.compositeTiers) { + const compositeProviders = [ + ...new Set(Object.values(profileInfo.compositeTiers).map((tier) => tier.provider)), + ] as CLIProxyProvider[]; + const missingProvider = compositeProviders.find((p) => !isAuthenticated(p)); + if (missingProvider) { + console.error(fail(`Missing OAuth auth for composite tier provider: ${missingProvider}`)); + console.error(info(`Authenticate first: ccs ${missingProvider} --auth`)); + process.exitCode = 1; + return; + } + } else if (!isAuthenticated(provider)) { + console.error(fail(`No OAuth authentication found for provider: ${provider}`)); + console.error(info(`Authenticate first: ccs ${provider} --auth`)); + process.exitCode = 1; + return; + } + + const ensureServiceResult = await ensureCliproxyService( + cliproxyPort, + targetRemainingArgs.includes('--verbose') || targetRemainingArgs.includes('-v') + ); + if (!ensureServiceResult.started) { + console.error(fail(ensureServiceResult.error || 'Failed to start local CLIProxy service')); + process.exitCode = 1; + return; + } + + const envVars = + profileInfo.isComposite && profileInfo.compositeTiers && profileInfo.compositeDefaultTier + ? getCompositeEnvVars( + profileInfo.compositeTiers, + profileInfo.compositeDefaultTier, + cliproxyPort, + customSettingsPath + ) + : getEffectiveEnvVars(provider, cliproxyPort, customSettingsPath); + + const creds: TargetCredentials = { + profile: profileInfo.name, + baseUrl: envVars['ANTHROPIC_BASE_URL'] || '', + apiKey: envVars['ANTHROPIC_AUTH_TOKEN'] || '', + model: envVars['ANTHROPIC_MODEL'] || undefined, + provider: resolveDroidProvider({ + provider: envVars['CCS_DROID_PROVIDER'] || envVars['DROID_PROVIDER'], + baseUrl: envVars['ANTHROPIC_BASE_URL'], + model: envVars['ANTHROPIC_MODEL'], + }), + reasoningOverride: runtimeReasoningOverride, + runtimeConfigOverrides: codexRuntimeConfigOverrides, + envVars, + }; + + if (!creds.baseUrl || !creds.apiKey) { + console.error( + fail( + `Missing CLIProxy runtime credentials for ${profileInfo.name} (ANTHROPIC_BASE_URL/AUTH_TOKEN)` + ) + ); + console.error( + info('Reconfigure with: ccs config > CLIProxy, or run ccs --config') + ); + process.exitCode = 1; + return; + } + + await adapter.prepareCredentials(creds); + const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs, { + creds, + profileType: profileInfo.type, + binaryInfo: ctx.targetBinaryInfo || undefined, + }); + const targetEnv = adapter.buildEnv(creds, profileInfo.type); + adapter.exec(targetArgs, targetEnv, { binaryInfo: ctx.targetBinaryInfo || undefined }); + return; + } + + await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, { + customSettingsPath, + port: cliproxyPort, + isComposite: profileInfo.isComposite, + compositeTiers: profileInfo.compositeTiers, + compositeDefaultTier: profileInfo.compositeDefaultTier, + profileName: profileInfo.name, + }); +} diff --git a/src/dispatcher/flows/copilot-flow.ts b/src/dispatcher/flows/copilot-flow.ts new file mode 100644 index 00000000..a294c5a7 --- /dev/null +++ b/src/dispatcher/flows/copilot-flow.ts @@ -0,0 +1,66 @@ +/** + * Copilot dispatch flow — GitHub Copilot subscription via copilot-api proxy. + * + * Extracted from src/ccs.ts main() profileInfo.type === 'copilot' branch. + */ + +import { fail, info } from '../../utils/ui'; +import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; +import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; +import { + ensureProfileHooks as ensureImageAnalyzerHooks, + removeImageAnalysisProfileHook, +} from '../../utils/hooks/image-analyzer-profile-hook-injector'; +import { prepareImageAnalysisFallbackHook } from '../../utils/hooks'; +import type { ProfileDispatchContext } from '../dispatcher-context'; + +export async function runCopilotFlow(ctx: ProfileDispatchContext): Promise { + const { + profileInfo, + resolvedTarget, + claudeCli, + remainingArgs, + resolveProfileContinuityInheritance, + } = ctx; + + ensureWebSearchMcpOrThrow(); + const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); + if (resolvedTarget === 'claude') { + if (imageAnalysisMcpReady) { + removeImageAnalysisProfileHook(profileInfo.name); + } else { + const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); + ensureImageAnalyzerHooks({ + profileName: profileInfo.name, + profileType: profileInfo.type, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + } + } + + const { executeCopilotProfile } = await import('../../copilot'); + const copilotConfig = profileInfo.copilotConfig; + if (!copilotConfig) { + console.error(fail('Copilot configuration not found')); + process.exit(1); + } + const continuityInheritance = await resolveProfileContinuityInheritance({ + profileName: profileInfo.name, + profileType: profileInfo.type, + target: resolvedTarget, + }); + if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { + console.error( + info( + `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` + ) + ); + } + const exitCode = await executeCopilotProfile( + copilotConfig, + remainingArgs, + continuityInheritance.claudeConfigDir, + claudeCli + ); + process.exit(exitCode); +} diff --git a/src/dispatcher/flows/cursor-flow.ts b/src/dispatcher/flows/cursor-flow.ts new file mode 100644 index 00000000..8a98c0b8 --- /dev/null +++ b/src/dispatcher/flows/cursor-flow.ts @@ -0,0 +1,54 @@ +/** + * Cursor dispatch flow — local Cursor daemon profile. + * + * Extracted from src/ccs.ts main() profileInfo.type === 'cursor' branch. + */ + +import { fail, info } from '../../utils/ui'; +import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; +import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector'; +import { installImageAnalyzerHook } from '../../utils/hooks'; +import type { ProfileDispatchContext } from '../dispatcher-context'; + +export async function runCursorFlow(ctx: ProfileDispatchContext): Promise { + const { + profileInfo, + resolvedTarget, + claudeCli, + remainingArgs, + resolveProfileContinuityInheritance, + } = ctx; + + ensureWebSearchMcpOrThrow(); + installImageAnalyzerHook(); + ensureImageAnalyzerHooks({ + profileName: profileInfo.name, + profileType: profileInfo.type, + }); + + const { executeCursorProfile } = await import('../../cursor'); + const cursorConfig = profileInfo.cursorConfig; + if (!cursorConfig) { + console.error(fail('Cursor configuration not found')); + process.exit(1); + } + const continuityInheritance = await resolveProfileContinuityInheritance({ + profileName: profileInfo.name, + profileType: profileInfo.type, + target: resolvedTarget, + }); + if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { + console.error( + info( + `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` + ) + ); + } + const exitCode = await executeCursorProfile( + cursorConfig, + remainingArgs, + continuityInheritance.claudeConfigDir, + claudeCli + ); + process.exit(exitCode); +} diff --git a/src/dispatcher/flows/default-flow.ts b/src/dispatcher/flows/default-flow.ts new file mode 100644 index 00000000..b782bfb7 --- /dev/null +++ b/src/dispatcher/flows/default-flow.ts @@ -0,0 +1,136 @@ +/** + * Default dispatch flow — no profile configured, use Claude's own defaults. + * + * Extracted from src/ccs.ts main() else branch (profileInfo.type === 'default'). + * Skip WebSearch hook — native Claude has server-side WebSearch. + * Skip Image Analyzer hook — native Claude has native vision support. + */ + +import { fail, info, warn } from '../../utils/ui'; +import { + appendBrowserToolArgs, + ensureBrowserMcpOrThrow, + resolveOptionalBrowserAttachRuntime, + syncBrowserMcpToConfigDir, +} from '../../utils/browser'; +import { execClaude } from '../../utils/shell-executor'; +import { resolveDroidProvider, type TargetCredentials } from '../../targets'; +import { resolveNativeClaudeLaunchArgs } from '../environment-builder'; +import type { ProfileDispatchContext } from '../dispatcher-context'; + +export async function runDefaultFlow(ctx: ProfileDispatchContext): Promise { + const { + profileInfo, + resolvedTarget, + claudeCli, + targetAdapter, + targetBinaryInfo, + targetRemainingArgs, + nativeClaudeRemainingArgs, + runtimeReasoningOverride, + codexRuntimeConfigOverrides, + claudeBrowserExposure, + claudeAttachConfig, + resolveProfileContinuityInheritance, + } = ctx; + + const envVars: NodeJS.ProcessEnv = { + CCS_PROFILE_TYPE: 'default', + CCS_WEBSEARCH_SKIP: '1', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + const browserAttachRuntime = + resolvedTarget === 'claude' && + claudeBrowserExposure?.exposeForLaunch && + claudeAttachConfig?.enabled + ? await resolveOptionalBrowserAttachRuntime(claudeAttachConfig) + : undefined; + const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; + if (browserAttachRuntime?.warning) { + process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`); + } + + if (resolvedTarget === 'claude') { + if (browserRuntimeEnv) { + ensureBrowserMcpOrThrow(); + Object.assign(envVars, browserRuntimeEnv); + } + const defaultContinuityInheritance = await resolveProfileContinuityInheritance({ + profileName: profileInfo.name, + profileType: profileInfo.type, + target: resolvedTarget, + }); + if (defaultContinuityInheritance.sourceAccount && process.env.CCS_DEBUG) { + console.error( + info( + `Continuity inheritance active: profile "${profileInfo.name}" -> account "${defaultContinuityInheritance.sourceAccount}"` + ) + ); + } + if (defaultContinuityInheritance.claudeConfigDir) { + envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir; + if ( + browserRuntimeEnv && + !syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir) + ) { + throw new Error( + 'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.' + ); + } + } + } + + // Dispatch through target adapter for non-claude targets + if (resolvedTarget !== 'claude') { + const adapter = targetAdapter; + if (!adapter) { + console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); + process.exit(1); + } + if (!adapter.supportsProfileType('default')) { + console.error(fail(`${adapter.displayName} does not support default profile mode`)); + process.exit(1); + } + const creds: TargetCredentials = { + profile: 'default', + baseUrl: process.env['ANTHROPIC_BASE_URL'] || '', + apiKey: process.env['ANTHROPIC_AUTH_TOKEN'] || '', + model: process.env['ANTHROPIC_MODEL'], + provider: resolveDroidProvider({ + provider: process.env['CCS_DROID_PROVIDER'] || process.env['DROID_PROVIDER'], + baseUrl: process.env['ANTHROPIC_BASE_URL'], + model: process.env['ANTHROPIC_MODEL'], + }), + reasoningOverride: runtimeReasoningOverride, + runtimeConfigOverrides: codexRuntimeConfigOverrides, + browserRuntimeEnv, + }; + if (resolvedTarget === 'droid' && (!creds.baseUrl || !creds.apiKey)) { + console.error( + fail( + `${adapter.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); + } + await adapter.prepareCredentials(creds); + const targetArgs = adapter.buildArgs('default', targetRemainingArgs, { + creds, + profileType: 'default', + binaryInfo: targetBinaryInfo || undefined, + }); + const targetEnv = adapter.buildEnv(creds, 'default'); + adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); + return; + } + + const launchArgs = resolveNativeClaudeLaunchArgs( + browserRuntimeEnv + ? appendBrowserToolArgs(nativeClaudeRemainingArgs) + : nativeClaudeRemainingArgs, + 'default', + envVars.CLAUDE_CONFIG_DIR + ); + execClaude(claudeCli, launchArgs, envVars); +} diff --git a/src/dispatcher/flows/settings-flow.ts b/src/dispatcher/flows/settings-flow.ts new file mode 100644 index 00000000..5da4094c --- /dev/null +++ b/src/dispatcher/flows/settings-flow.ts @@ -0,0 +1,400 @@ +/** + * Settings dispatch flow — settings-based profiles (glm, glmt, kimi, etc.). + * + * Extracted from src/ccs.ts main() profileInfo.type === 'settings' branch. + * Image-analysis prep is large enough to split; see settings-image-analysis-prep.ts. + */ + +import { getSettingsPath, loadSettings } from '../../utils/config-manager'; +import { expandPath } from '../../utils/helpers'; +import { + validateGlmKey, + validateMiniMaxKey, + validateAnthropicKey, +} from '../../utils/api-key-validator'; +import { + ensureWebSearchMcpOrThrow, + displayWebSearchStatus, + getWebSearchHookEnv, + syncWebSearchMcpToConfigDir, + appendThirdPartyWebSearchToolArgs, + createWebSearchTraceContext, +} from '../../utils/websearch-manager'; +import { + ensureImageAnalysisMcpOrThrow, + syncImageAnalysisMcpToConfigDir, + appendThirdPartyImageAnalysisToolArgs, +} from '../../utils/image-analysis'; +import { + appendBrowserToolArgs, + ensureBrowserMcpOrThrow, + resolveOptionalBrowserAttachRuntime, + syncBrowserMcpToConfigDir, +} from '../../utils/browser'; +import { getGlobalEnvConfig } from '../../config/unified-config-loader'; +import { + ensureProfileHooks as ensureImageAnalyzerHooks, + removeImageAnalysisProfileHook, +} from '../../utils/hooks/image-analyzer-profile-hook-injector'; +import { fail, info, warn } from '../../utils/ui'; +import { execClaude, stripAnthropicRoutingEnv, stripBrowserEnv } from '../../utils/shell-executor'; +import { + isDeprecatedGlmtProfileName, + normalizeDeprecatedGlmtEnv, +} from '../../utils/glmt-deprecation'; +import { createOpenAICompatLaunchSettings } from '../../utils/openai-compat-launch-settings'; +import { + resolveDroidProvider, + evaluateTargetRuntimeCompatibility, + type TargetCredentials, +} from '../../targets'; +import { resolveCliproxyBridgeMetadata } from '../../api/services/cliproxy-profile-bridge'; +import { + buildOpenAICompatProxyEnv, + resolveOpenAICompatProfileConfig, + startOpenAICompatProxy, +} from '../../proxy'; +import { resolveSettingsImageAnalysisEnv } from './settings-image-analysis-prep'; +import type { ProfileDispatchContext } from '../dispatcher-context'; + +export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise { + const { + profileInfo, + resolvedTarget, + claudeCli, + targetAdapter, + targetBinaryInfo, + targetRemainingArgs, + nativeClaudeRemainingArgs, + runtimeReasoningOverride, + codexRuntimeConfigOverrides, + claudeBrowserExposure, + claudeAttachConfig, + resolvedSettingsPath: preResolvedSettingsPath, + resolvedSettings: preResolvedSettings, + resolvedCliproxyBridge: preResolvedCliproxyBridge, + resolveProfileContinuityInheritance, + remainingArgs, + } = ctx; + + const imageAnalysisMcpReady = + resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true; + const browserAttachRuntime = + resolvedTarget === 'claude' && + claudeBrowserExposure?.exposeForLaunch && + claudeAttachConfig?.enabled + ? await resolveOptionalBrowserAttachRuntime(claudeAttachConfig) + : undefined; + const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; + if (browserAttachRuntime?.warning) { + process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`); + } + if (resolvedTarget === 'claude') { + ensureWebSearchMcpOrThrow(); + if (browserRuntimeEnv) { + ensureBrowserMcpOrThrow(); + } + } + + // Display WebSearch status (single line, equilibrium UX) + displayWebSearchStatus(); + + const continuityInheritance = + resolvedTarget === 'claude' + ? await resolveProfileContinuityInheritance({ + profileName: profileInfo.name, + profileType: profileInfo.type, + target: resolvedTarget, + }) + : {}; + if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { + console.error( + info( + `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` + ) + ); + } + const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir; + syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); + syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); + if ( + browserRuntimeEnv && + inheritedClaudeConfigDir && + !syncBrowserMcpToConfigDir(inheritedClaudeConfigDir) + ) { + throw new Error( + 'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.' + ); + } + + const expandedSettingsPath = + preResolvedSettingsPath ?? + (profileInfo.settingsPath + ? expandPath(profileInfo.settingsPath) + : getSettingsPath(profileInfo.name)); + const settings = preResolvedSettings ?? loadSettings(expandedSettingsPath); + const cliproxyBridge = preResolvedCliproxyBridge ?? resolveCliproxyBridgeMetadata(settings); + + let imageAnalysisFallbackHookReady: boolean | undefined; + if (resolvedTarget === 'claude') { + if (imageAnalysisMcpReady) { + removeImageAnalysisProfileHook(profileInfo.name, expandedSettingsPath); + } else { + imageAnalysisFallbackHookReady = ( + await import('../../utils/hooks') + ).prepareImageAnalysisFallbackHook(); + ensureImageAnalyzerHooks({ + profileName: profileInfo.name, + profileType: profileInfo.type, + settingsPath: expandedSettingsPath, + settings, + cliproxyBridge, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + } + } + if (resolvedTarget !== 'claude') { + const compatibility = evaluateTargetRuntimeCompatibility({ + target: resolvedTarget, + profileType: profileInfo.type, + cliproxyBridgeProvider: cliproxyBridge?.provider ?? null, + }); + if (!compatibility.supported) { + console.error( + fail( + compatibility.reason || + `${targetAdapter?.displayName || resolvedTarget} does not support this profile.` + ) + ); + if (compatibility.suggestion) { + console.error(info(compatibility.suggestion)); + } + process.exit(1); + } + } + + const rawSettingsEnv = profileInfo.env ?? settings.env ?? {}; + const isDeprecatedGlmtProfile = isDeprecatedGlmtProfileName(profileInfo.name); + const glmtNormalization = isDeprecatedGlmtProfile + ? normalizeDeprecatedGlmtEnv(rawSettingsEnv) + : null; + const settingsEnv = glmtNormalization?.env ?? rawSettingsEnv; + + if (glmtNormalization) { + for (const message of glmtNormalization.warnings) { + console.error(warn(message)); + } + } + + // Pre-flight validation for Z.AI-compatible profiles. + if (profileInfo.name === 'glm' || isDeprecatedGlmtProfile) { + const apiKey = settingsEnv['ANTHROPIC_AUTH_TOKEN']; + if (apiKey) { + const validation = await validateGlmKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']); + if (!validation.valid) { + console.error(''); + console.error(fail(validation.error || 'API key validation failed')); + if (validation.suggestion) { + console.error(''); + console.error(validation.suggestion); + } + console.error(''); + console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs glm "prompt"')); + process.exit(1); + } + } + } + + if (profileInfo.name === 'mm') { + const apiKey = settingsEnv['ANTHROPIC_AUTH_TOKEN']; + if (apiKey) { + const validation = await validateMiniMaxKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']); + if (!validation.valid) { + console.error(''); + console.error(fail(validation.error || 'API key validation failed')); + if (validation.suggestion) { + console.error(''); + console.error(validation.suggestion); + } + console.error(''); + console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs mm "prompt"')); + process.exit(1); + } + } + } + + // Pre-flight validation for Anthropic direct profiles (ANTHROPIC_API_KEY + no BASE_URL) + { + const anthropicApiKey = settingsEnv['ANTHROPIC_API_KEY']; + const hasBaseUrl = !!settingsEnv['ANTHROPIC_BASE_URL']; + if (anthropicApiKey && !hasBaseUrl) { + const validation = await validateAnthropicKey(anthropicApiKey); + if (!validation.valid) { + console.error(''); + console.error(fail(validation.error || 'API key validation failed')); + if (validation.suggestion) { + console.error(''); + console.error(validation.suggestion); + } + console.error(''); + console.error( + info(`To skip validation: CCS_SKIP_PREFLIGHT=1 ccs ${profileInfo.name} "prompt"`) + ); + process.exit(1); + } + } + } + + // Image analysis env resolution (split into sibling helper to stay under LOC budget) + const imageAnalysisEnv = await resolveSettingsImageAnalysisEnv({ + profileInfo, + resolvedTarget, + settings, + cliproxyBridge, + imageAnalysisMcpReady, + imageAnalysisFallbackHookReady, + remainingArgs, + targetRemainingArgs, + }); + + const webSearchEnv = getWebSearchHookEnv(); + + // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles + const globalEnvConfig = getGlobalEnvConfig(); + const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; + + // Log global env injection for visibility (debug mode only) + if (globalEnvConfig.enabled && Object.keys(globalEnv).length > 0 && process.env.CCS_DEBUG) { + const envNames = Object.keys(globalEnv).join(', '); + console.error(info(`Global env: ${envNames}`)); + } + + // For Claude target launches that already pass `--settings`, keep runtime env free of + // ANTHROPIC routing/auth while preserving non-routing profile env so nested Team/subagent + // sessions can still inherit model intent and other profile-scoped runtime flags. + const settingsRuntimeEnv = stripBrowserEnv({ ...globalEnv, ...settingsEnv }); + const claudeRuntimeEnvVars: NodeJS.ProcessEnv = { + ...stripAnthropicRoutingEnv(settingsRuntimeEnv), + ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), + ...webSearchEnv, + ...imageAnalysisEnv, + ...(browserRuntimeEnv || {}), + CCS_PROFILE_TYPE: 'settings', + CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1', + }; + + // Non-Claude targets still need effective credentials injected directly. + const envVars: NodeJS.ProcessEnv = { + ...settingsRuntimeEnv, + ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), + ...webSearchEnv, + ...imageAnalysisEnv, + ...(browserRuntimeEnv || {}), + CCS_PROFILE_TYPE: 'settings', + }; + + // Dispatch through target adapter for non-claude targets + if (resolvedTarget !== 'claude') { + const adapter = targetAdapter; + if (!adapter) { + console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); + process.exit(1); + } + const directAnthropicBaseUrl = + settingsEnv['ANTHROPIC_BASE_URL'] || + (settingsEnv['ANTHROPIC_API_KEY'] ? 'https://api.anthropic.com' : ''); + const creds: TargetCredentials = { + profile: profileInfo.name, + baseUrl: directAnthropicBaseUrl, + apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || settingsEnv['ANTHROPIC_API_KEY'] || '', + model: settingsEnv['ANTHROPIC_MODEL'], + provider: resolveDroidProvider({ + provider: settingsEnv['CCS_DROID_PROVIDER'] || settingsEnv['DROID_PROVIDER'], + baseUrl: directAnthropicBaseUrl, + model: settingsEnv['ANTHROPIC_MODEL'], + }), + reasoningOverride: runtimeReasoningOverride, + runtimeConfigOverrides: codexRuntimeConfigOverrides, + envVars, + }; + await adapter.prepareCredentials(creds); + const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs, { + creds, + profileType: profileInfo.type, + binaryInfo: targetBinaryInfo || undefined, + }); + const targetEnv = adapter.buildEnv(creds, profileInfo.type); + adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); + return; + } + + const imageAnalysisArgs = imageAnalysisMcpReady + ? appendThirdPartyImageAnalysisToolArgs(nativeClaudeRemainingArgs) + : nativeClaudeRemainingArgs; + const browserArgs = browserRuntimeEnv + ? appendBrowserToolArgs(imageAnalysisArgs) + : imageAnalysisArgs; + const openAICompatProfile = resolveOpenAICompatProfileConfig( + profileInfo.name, + expandedSettingsPath, + settingsEnv + ); + if (openAICompatProfile) { + const proxyStart = await startOpenAICompatProxy(openAICompatProfile, { + insecure: openAICompatProfile.insecure, + }); + if (!proxyStart.success) { + console.error(fail(proxyStart.error || 'Failed to start local OpenAI-compatible proxy')); + process.exit(1); + } + + console.error( + info( + `Using local OpenAI-compatible proxy for "${profileInfo.name}" on port ${proxyStart.port}` + ) + ); + + const proxyEnv = { + ...envVars, + ...buildOpenAICompatProxyEnv( + openAICompatProfile, + proxyStart.port, + proxyStart.authToken || '', + inheritedClaudeConfigDir + ), + }; + delete proxyEnv.ANTHROPIC_API_KEY; + const launchSettings = createOpenAICompatLaunchSettings(expandedSettingsPath, settings); + + const launchArgs = [ + '--settings', + launchSettings.settingsPath, + ...appendThirdPartyWebSearchToolArgs(browserArgs), + ]; + const traceEnv = createWebSearchTraceContext({ + launcher: 'ccs.settings-profile.proxy', + args: launchArgs, + profile: profileInfo.name, + profileType: profileInfo.type, + settingsPath: expandedSettingsPath, + }); + + execClaude(claudeCli, launchArgs, { ...proxyEnv, ...traceEnv }, launchSettings.cleanup); + return; + } + + const launchArgs = [ + '--settings', + expandedSettingsPath, + ...appendThirdPartyWebSearchToolArgs(browserArgs), + ]; + const traceEnv = createWebSearchTraceContext({ + launcher: 'ccs.settings-profile', + args: launchArgs, + profile: profileInfo.name, + profileType: profileInfo.type, + settingsPath: expandedSettingsPath, + }); + + execClaude(claudeCli, launchArgs, { ...claudeRuntimeEnvVars, ...traceEnv }); +} diff --git a/src/dispatcher/flows/settings-image-analysis-prep.ts b/src/dispatcher/flows/settings-image-analysis-prep.ts new file mode 100644 index 00000000..9e841807 --- /dev/null +++ b/src/dispatcher/flows/settings-image-analysis-prep.ts @@ -0,0 +1,126 @@ +/** + * Image analysis environment preparation for settings-based profiles. + * + * Split from settings-flow.ts to keep that file under the 300 LOC budget. + * Resolves runtime status, connection, and env overrides for image analysis MCP. + */ + +import { warn, info } from '../../utils/ui'; +import { + applyImageAnalysisRuntimeOverrides, + getImageAnalysisHookEnv, + resolveImageAnalysisRuntimeConnection, + resolveImageAnalysisRuntimeStatus, +} from '../../utils/hooks'; +import { ensureCliproxyService } from '../../cliproxy'; +import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager'; +import type { ProfileDetectionResult } from '../../auth/profile-detector'; +import type { loadSettings } from '../../utils/config-manager'; +import type { resolveCliproxyBridgeMetadata } from '../../api/services/cliproxy-profile-bridge'; +import type { resolveTargetType } from '../../targets/target-resolver'; + +export interface SettingsImageAnalysisPrepContext { + profileInfo: ProfileDetectionResult; + resolvedTarget: ReturnType; + settings: ReturnType; + cliproxyBridge: ReturnType | undefined; + imageAnalysisMcpReady: boolean; + imageAnalysisFallbackHookReady: boolean | undefined; + remainingArgs: string[]; + targetRemainingArgs: string[]; +} + +/** + * Resolve the complete image analysis env vars for a settings-based profile launch. + * Handles native-read fallback and proxy-stopped recovery. + */ +export async function resolveSettingsImageAnalysisEnv( + ctx: SettingsImageAnalysisPrepContext +): Promise { + const { + profileInfo, + resolvedTarget, + settings, + cliproxyBridge, + imageAnalysisMcpReady, + imageAnalysisFallbackHookReady, + remainingArgs, + targetRemainingArgs, + } = ctx; + + const imageAnalysisStatus = await resolveImageAnalysisRuntimeStatus({ + profileName: profileInfo.name, + profileType: profileInfo.type, + settings, + cliproxyBridge, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + const runtimeConnection = resolveImageAnalysisRuntimeConnection(); + let imageAnalysisEnv = getImageAnalysisHookEnv({ + profileName: profileInfo.name, + profileType: profileInfo.type, + settings, + cliproxyBridge, + }); + imageAnalysisEnv = applyImageAnalysisRuntimeOverrides(imageAnalysisEnv, { + backendId: imageAnalysisStatus.backendId, + model: imageAnalysisStatus.model, + runtimePath: imageAnalysisStatus.runtimePath, + baseUrl: runtimeConnection.baseUrl, + apiKey: runtimeConnection.apiKey, + allowSelfSigned: runtimeConnection.allowSelfSigned, + }); + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_IMAGE_ANALYSIS_SKIP_HOOK: resolvedTarget === 'claude' && imageAnalysisMcpReady ? '1' : '0', + }; + + const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER']; + if ( + resolvedTarget === 'claude' && + imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' && + imageAnalysisProvider + ) { + const verboseProxyLaunch = + remainingArgs.includes('--verbose') || + remainingArgs.includes('-v') || + targetRemainingArgs.includes('--verbose') || + targetRemainingArgs.includes('-v'); + + if (imageAnalysisStatus.effectiveRuntimeMode === 'native-read') { + console.error( + info( + `${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This session will use native Read.` + ) + ); + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '', + CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: '', + CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: '', + CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED: '0', + }; + } else if (imageAnalysisStatus.proxyReadiness === 'stopped') { + const ensureServiceResult = await ensureCliproxyService( + CLIPROXY_DEFAULT_PORT, + verboseProxyLaunch + ); + if (!ensureServiceResult.started) { + console.error( + warn( + `Image analysis via ${imageAnalysisProvider} is unavailable because CCS could not start the local CLIProxy service. This session will use native Read.` + ) + ); + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + } + } + } + + return imageAnalysisEnv; +} diff --git a/src/dispatcher/target-executor.ts b/src/dispatcher/target-executor.ts index a123746f..7016caf2 100644 --- a/src/dispatcher/target-executor.ts +++ b/src/dispatcher/target-executor.ts @@ -1,15 +1,25 @@ /** - * Native target execution — short-circuit dispatch for passthrough flag commands. + * Native target execution — short-circuit dispatch for passthrough flag commands, + * and top-level profile dispatcher (Phase E switch). * * Extracted from src/ccs.ts (lines 291-295, 394-426). * Handles direct execution of native Codex flag passthrough (--help, --version, etc.) * before the main profile dispatch loop runs. + * + * dispatchProfile() collapses the 6-branch switch in main() to a single call. */ import { fail, info } from '../utils/ui'; import { getTarget } from '../targets'; import { getNativeCodexPassthroughArgs } from './cli-argument-parser'; +import { runCliproxyFlow } from './flows/cliproxy-flow'; +import { runCopilotFlow } from './flows/copilot-flow'; +import { runCursorFlow } from './flows/cursor-flow'; +import { runSettingsFlow } from './flows/settings-flow'; +import { runAccountFlow } from './flows/account-flow'; +import { runDefaultFlow } from './flows/default-flow'; import type { TargetCredentials } from '../targets'; +import type { ProfileDispatchContext } from './dispatcher-context'; // ========== Interfaces ========== @@ -54,3 +64,31 @@ export function execNativeCodexFlagCommand(args: string[]): void { const targetEnv = adapter.buildEnv(creds, 'default'); adapter.exec(builtArgs, targetEnv, { binaryInfo }); } + +// ========== Profile Dispatcher ========== + +/** + * Dispatch to the correct per-profile-type flow. + * + * Collapses the 6-branch if/else-if switch that previously lived in main() into a + * single call site. The headless -p delegation short-circuit is handled in main() + * before this function is called. + */ +export async function dispatchProfile(ctx: ProfileDispatchContext): Promise { + const { profileInfo } = ctx; + + switch (profileInfo.type) { + case 'cliproxy': + return runCliproxyFlow(ctx); + case 'copilot': + return runCopilotFlow(ctx); + case 'cursor': + return runCursorFlow(ctx); + case 'settings': + return runSettingsFlow(ctx); + case 'account': + return runAccountFlow(ctx); + default: + return runDefaultFlow(ctx); + } +}