diff --git a/src/ccs.ts b/src/ccs.ts index d88d2f35..b1163244 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -1,521 +1,37 @@ import './utils/fetch-proxy-setup'; -import * as fs from 'fs'; -import { detectClaudeCli } from './utils/claude-detector'; -import { getSettingsPath, setGlobalConfigDir, detectCloudSyncPath } 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, - getBlockedBrowserOverrideWarning, - getEffectiveClaudeBrowserAttachConfig, - resolveBrowserExposure, - resolveBrowserLaunchFlagResolution, - resolveOptionalBrowserAttachRuntime, - syncBrowserMcpToConfigDir, -} from './utils/browser'; - -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 { isCopilotSubcommandToken } from './copilot/constants'; -import { - buildOfficialChannelsArgs, - getOfficialChannelsEnvironmentStatus, - officialChannelRequiresMacOS, - resolveOfficialChannelsLaunchPlan, -} from './channels/official-channels-runtime'; -import { getOfficialChannelReadiness } from './channels/official-channels-store'; -import { isCursorSubcommandToken, LEGACY_CURSOR_PROFILE_NAME } from './cursor/constants'; -import { isCLIProxyProvider } from './cliproxy/provider-capabilities'; - +import { fail } from './utils/ui'; // Import centralized error handling import { handleError, runCleanup } from './errors'; -import { tryHandleRootCommand } from './commands/root-command-router'; -// 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 { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides'; -import type { ProfileDetectionResult } from './auth/profile-detector'; -import type { BrowserLaunchOverride } from './utils/browser'; - // 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, - resolveDroidReasoningRuntime, -} from './targets/droid-reasoning-runtime'; -import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router'; -import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge'; -import { - buildOpenAICompatProxyEnv, - resolveOpenAICompatProfileConfig, - startOpenAICompatProxy, -} from './proxy'; +import { registerTarget, ClaudeAdapter, DroidAdapter, CodexAdapter } from './targets'; -// Version and Update check utilities -import { getVersion } from './utils/version'; -import { - checkForUpdates, - showUpdateNotification, - checkCachedUpdate, - isCacheStale, -} from './utils/update-checker'; -import { - getBrowserConfig, - getGlobalEnvConfig, - getOfficialChannelsConfig, - loadSettings, -} from './config/config-loader-facade'; -// Note: npm is now the only supported installation method - -// ========== Profile Detection ========== - -interface DetectedProfile { - profile: string; - remainingArgs: string[]; -} - -interface RuntimeReasoningResolution { - argsWithoutReasoningFlags: string[]; - reasoningOverride: string | number | undefined; - reasoningSource: 'flag' | 'env' | undefined; - sourceDisplay: string | undefined; -} - -const CODEX_RUNTIME_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']); -const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']); -const NATIVE_CLAUDE_EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const; -const NATIVE_CLAUDE_EFFORT_LEVEL_SET = new Set(NATIVE_CLAUDE_EFFORT_LEVELS); - -function resolveCodexRuntimeConfigOverrides( - target: ReturnType, - browserLaunchOverride: BrowserLaunchOverride | undefined -): string[] { - if (target !== 'codex') { - return []; - } - - const codexBrowserExposure = resolveBrowserExposure( - getBrowserConfig().codex, - browserLaunchOverride - ); - if (!codexBrowserExposure.exposeForLaunch) { - return []; - } - - return buildCodexBrowserMcpOverrides(); -} - -/** - * Smart profile detection - */ -function detectProfile(args: string[]): DetectedProfile { - if (args.length === 0 || args[0].startsWith('-')) { - // No args or first arg is a flag → use default profile - return { profile: 'default', remainingArgs: args }; - } else { - // First arg doesn't start with '-' → treat as profile name - return { profile: args[0], remainingArgs: args.slice(1) }; - } -} - -function normalizeLegacyCursorArgs(args: string[]): string[] { - if (args[0] === 'legacy' && args[1] === 'cursor') { - return [LEGACY_CURSOR_PROFILE_NAME, ...args.slice(2)]; - } - - return args; -} - -function printCursorLegacySubcommandDeprecation(subcommand: string): void { - console.error( - info(`\`ccs cursor ${subcommand}\` is deprecated for the legacy Cursor IDE bridge.`) - ); - console.error( - info( - `Use \`ccs legacy cursor ${subcommand}\` for the old bridge, or \`ccs cursor --auth|--accounts|--config\` for the CLIProxy provider.` - ) - ); - console.error(''); -} - -function resolveRuntimeReasoningFlags( - args: string[], - envThinkingValue: string | undefined -): RuntimeReasoningResolution { - const runtime = resolveDroidReasoningRuntime(args, envThinkingValue); - - if (runtime.duplicateDisplays.length > 0) { - console.error( - warn( - `[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || ''}` - ) - ); - } - - return { - argsWithoutReasoningFlags: runtime.argsWithoutReasoningFlags, - reasoningOverride: runtime.reasoningOverride, - reasoningSource: runtime.sourceFlag - ? 'flag' - : runtime.reasoningOverride !== undefined - ? 'env' - : undefined, - sourceDisplay: runtime.sourceDisplay, - }; -} - -function normalizeCodexRuntimeReasoningOverride( - value: string | number | undefined -): string | undefined { - return typeof value === 'string' && CODEX_RUNTIME_REASONING_LEVELS.has(value) ? value : undefined; -} - -function exitWithRuntimeReasoningFlagError( - message: string, - options: { - codexAliasLevels: string; - includeDroidExecExample?: boolean; - } -): never { - console.error(fail(message)); - console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); - console.error(` Codex alias: --effort ${options.codexAliasLevels}`); - if (options.includeDroidExecExample) { - console.error(' Droid exec: --reasoning-effort high'); - } - process.exit(1); -} - -function normalizeNativeClaudeEffortArgs(args: string[]): string[] { - const normalizedArgs: string[] = []; - const allowedValues = NATIVE_CLAUDE_EFFORT_LEVELS.join(', '); - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === '--effort') { - const rawValue = args[i + 1]; - if (!rawValue || rawValue.startsWith('-') || !rawValue.trim()) { - throw new Error(`--effort requires a value: ${allowedValues}`); - } - const value = rawValue.toLowerCase(); - if (!NATIVE_CLAUDE_EFFORT_LEVEL_SET.has(value)) { - throw new Error(`Invalid --effort value: ${rawValue}. Expected one of: ${allowedValues}.`); - } - normalizedArgs.push(arg, value); - i++; - continue; - } - - if (arg.startsWith('--effort=')) { - const rawValue = arg.slice('--effort='.length); - if (!rawValue.trim()) { - throw new Error(`--effort requires a value: ${allowedValues}`); - } - const value = rawValue.toLowerCase(); - if (!NATIVE_CLAUDE_EFFORT_LEVEL_SET.has(value)) { - throw new Error(`Invalid --effort value: ${rawValue}. Expected one of: ${allowedValues}.`); - } - normalizedArgs.push(`--effort=${value}`); - continue; - } - - normalizedArgs.push(arg); - } - - return normalizedArgs; -} - -function shouldNormalizeNativeClaudeEffort(profileType: ProfileDetectionResult['type']): boolean { - return profileType === 'default' || profileType === 'account' || profileType === 'settings'; -} +// Import extracted dispatcher modules +import { bootstrapAndParseEarlyCli } from './dispatcher/cli-argument-parser'; +import { type ProfileError, dispatchProfile } from './dispatcher/target-executor'; +import { runPreDispatchHandlers } from './dispatcher/pre-dispatch'; +import { resolveProfileAndTarget } from './dispatcher/profile-resolver'; // ========== Main Execution ========== -interface ProfileError extends Error { - profileName?: string; - availableProfiles?: string; - suggestions?: string[]; -} - -/** - * Perform background update check (refreshes cache, no notification) - */ -async function refreshUpdateCache(): Promise { - try { - const currentVersion = getVersion(); - // npm is now the only supported installation method - await checkForUpdates(currentVersion, true, 'npm'); - } catch (_e) { - // Silently fail - update check shouldn't crash main CLI - } -} - -/** - * Show update notification if cached result indicates update available - * Returns true if notification was shown - */ -async function showCachedUpdateNotification(): Promise { - try { - const currentVersion = getVersion(); - const updateInfo = checkCachedUpdate(currentVersion); - - if (updateInfo) { - await showUpdateNotification(updateInfo); - return true; - } - } catch (_e) { - // Silently fail - } - return false; -} - -function resolveNativeClaudeLaunchArgs( - args: string[], - profileType: 'default' | 'account', - targetConfigDir?: string -): string[] { - const config = getOfficialChannelsConfig(); - const environment = getOfficialChannelsEnvironmentStatus( - targetConfigDir ? { CLAUDE_CONFIG_DIR: targetConfigDir } : undefined - ); - const channelReadiness = { - telegram: getOfficialChannelReadiness('telegram'), - discord: getOfficialChannelReadiness('discord'), - imessage: !officialChannelRequiresMacOS('imessage') || process.platform === 'darwin', - }; - const plan = resolveOfficialChannelsLaunchPlan({ - args, - config, - target: 'claude', - profileType, - environment, - channelReadiness, - }); - - for (const message of plan.skippedMessages) { - console.error(warn(message)); - } - - if ( - config.selected.length > 0 && - environment.auth.state === 'eligible' && - environment.auth.orgRequirementMessage - ) { - console.error(warn(environment.auth.orgRequirementMessage)); - } - - if (!plan.applied) { - return args; - } - - return buildOfficialChannelsArgs(args, plan.appliedChannels, plan.wantsPermissionBypass); -} - -function shouldPassthroughNativeCodexFlagCommand(args: string[]): boolean { - return getNativeCodexPassthroughArgs(args) !== null; -} - -function getNativeCodexPassthroughArgs(args: string[]): string[] | null { - const targetArgs = stripTargetFlag(args); - if (resolveTargetType(args) !== 'codex' || targetArgs.length === 0) { - return null; - } - - const firstArg = targetArgs[0] || ''; - if (CODEX_NATIVE_PASSTHROUGH_FLAGS.has(firstArg)) { - return targetArgs; - } - - const secondArg = targetArgs[1] || ''; - if (firstArg === 'codex' && CODEX_NATIVE_PASSTHROUGH_FLAGS.has(secondArg)) { - return targetArgs.slice(1); - } - - return null; -} - -function execNativeCodexFlagCommand(args: string[]): void { - const adapter = getTarget('codex'); - if (!adapter) { - console.error(fail('Target adapter not found for "codex"')); - process.exit(1); - } - - const binaryInfo = adapter.detectBinary(); - if (!binaryInfo) { - console.error(fail('Codex CLI not found.')); - console.error(info('Install a recent @openai/codex build, then retry.')); - process.exit(1); - } - - const targetArgs = getNativeCodexPassthroughArgs(args); - if (!targetArgs) { - console.error(fail('Native Codex passthrough args could not be resolved.')); - process.exit(1); - } - const creds: TargetCredentials = { - profile: 'default', - baseUrl: '', - apiKey: '', - }; - - const builtArgs = adapter.buildArgs('default', targetArgs, { - creds, - profileType: 'default', - binaryInfo, - }); - const targetEnv = adapter.buildEnv(creds, 'default'); - adapter.exec(builtArgs, targetEnv, { binaryInfo }); -} - async function main(): Promise { - // Register target adapters + // Register target adapters (singleton wiring — stays in main) registerTarget(new ClaudeAdapter()); registerTarget(new DroidAdapter()); registerTarget(new CodexAdapter()); const cliLogger = createLogger('cli'); - let args = process.argv.slice(2); - const isCompletionCommand = args[0] === '__complete'; - - // Initialize UI colors early to ensure consistent colored output - // Must happen before any status messages (ok, info, fail, etc.) - if (!isCompletionCommand && process.stdout.isTTY && !process.env['CI']) { - const { initUI } = await import('./utils/ui'); - await initUI(); - } - - // Parse --config-dir flag (must happen before any config loading) - const configDirIdx = args.findIndex((a) => a === '--config-dir' || a.startsWith('--config-dir=')); - if (configDirIdx !== -1) { - const arg = args[configDirIdx]; - let configDirValue: string | undefined; - let spliceCount = 1; - - if (arg.startsWith('--config-dir=')) { - configDirValue = arg.split('=').slice(1).join('='); - } else { - configDirValue = args[configDirIdx + 1]; - spliceCount = 2; - } - - if (!configDirValue || configDirValue.startsWith('-')) { - console.error(fail('--config-dir requires a path argument')); - process.exit(1); - } - - try { - const stat = fs.statSync(configDirValue); - if (!stat.isDirectory()) { - console.error(fail(`Not a directory: ${configDirValue}`)); - process.exit(1); - } - } catch { - console.error(fail(`Config directory not found: ${configDirValue}`)); - console.error(info('Create the directory first, then copy your config files into it.')); - process.exit(1); - } - - setGlobalConfigDir(configDirValue); - - // Security warning: cloud sync paths expose OAuth tokens - const cloudService = detectCloudSyncPath(configDirValue); - if (!isCompletionCommand && cloudService) { - console.error(warn(`CCS directory is under ${cloudService}.`)); - console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); - console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); - } - - // Remove consumed args so they don't leak to Claude CLI - args.splice(configDirIdx, spliceCount); - } else if (process.env.CCS_DIR) { - // Also warn for CCS_DIR env var pointing to cloud sync - const cloudService = detectCloudSyncPath(process.env.CCS_DIR); - if (!isCompletionCommand && cloudService) { - console.error(warn(`CCS directory is under ${cloudService}.`)); - console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); - console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); - } - } else if (process.env.CCS_HOME) { - // Also warn for CCS_HOME env var pointing to cloud sync - const cloudService = detectCloudSyncPath(process.env.CCS_HOME); - if (!isCompletionCommand && cloudService) { - console.error(warn(`CCS directory is under ${cloudService}.`)); - console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); - console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); - } - } - - if (isCompletionCommand) { - await tryHandleRootCommand(args); + // Phase A: bootstrap + early arg pre-parse + const bootstrap = await bootstrapAndParseEarlyCli(process.argv.slice(2)); + if (bootstrap.exitNow) { return; } - args = normalizeLegacyCursorArgs(args); - let browserLaunchOverride: BrowserLaunchOverride | undefined; - try { - const browserLaunchFlags = resolveBrowserLaunchFlagResolution(args); - browserLaunchOverride = browserLaunchFlags.override; - args = browserLaunchFlags.argsWithoutFlags; - } catch (error) { - console.error(fail((error as Error).message)); - process.exit(1); - return; - } + const args = bootstrap.args; + const browserLaunchOverride = bootstrap.browserLaunchOverride; cliLogger.info('command.start', 'CLI invocation started', { command: args[0] || 'default', @@ -523,129 +39,17 @@ async function main(): Promise { flags: args.filter((arg) => arg.startsWith('-')).slice(0, 20), }); - if (shouldPassthroughNativeCodexFlagCommand(args)) { - execNativeCodexFlagCommand(args); + // Phase B: pre-dispatch side-effects (update check, migrate, recovery, root commands, routing) + const preDispatchConsumed = await runPreDispatchHandlers({ args, cliLogger }); + if (preDispatchConsumed) { return; } - const firstArg = args[0]; + // Phase C: profile + target detection (extracted to dispatcher/profile-resolver.ts) + const resolvedProfile = await resolveProfileAndTarget({ args, browserLaunchOverride, cliLogger }); + const { profile, profileInfo, resolvedTarget, nativeClaudeRemainingArgs } = resolvedProfile; - // Trigger update check early for ALL commands except version/help/update - // Only if TTY and not CI to avoid noise in automated environments - const skipUpdateCheck = [ - 'version', - '--version', - '-v', - 'help', - '--help', - '-h', - 'update', - '--update', - ]; - if (process.stdout.isTTY && !process.env['CI'] && !skipUpdateCheck.includes(firstArg)) { - // 1. Show cached update notification (async for proper UI) - await showCachedUpdateNotification(); - - // 2. Refresh cache in background if stale (non-blocking) - if (isCacheStale()) { - refreshUpdateCache(); - } - } - - // Auto-migrate to unified config format (silent if already migrated) - // Skip if user is explicitly running migrate command - if (firstArg !== 'migrate') { - const { autoMigrate } = await import('./config/migration-manager'); - await autoMigrate(); - } - - // Auto-recovery for missing configuration (BEFORE any early-exit commands) - // This ensures ALL commands benefit from auto-recovery, not just profile-switching flow - // Recovery is safe to run early - it only creates missing files with safe defaults - // Wrapped in try-catch to prevent blocking --version/--help on permission errors - try { - const RecoveryManagerModule = await import('./management/recovery-manager'); - const RecoveryManager = RecoveryManagerModule.default; - const recovery = new RecoveryManager(); - const recovered = recovery.recoverAll(); - - if (recovered) { - recovery.showRecoveryHints(); - } - } catch (err) { - cliLogger.warn('recovery.failed', 'Auto-recovery failed during CLI startup', { - message: (err as Error).message, - }); - // Recovery is best-effort - don't block basic CLI functionality - console.warn('[!] Recovery failed:', (err as Error).message); - } - - if (await tryHandleRootCommand(args)) { - return; - } - - if ( - typeof firstArg === 'string' && - isCLIProxyProvider(firstArg) && - args.length > 1 && - (args.includes('--help') || args.includes('-h')) - ) { - const { showProviderShortcutHelp } = await import('./commands/help-command'); - await showProviderShortcutHelp(firstArg); - return; - } - - // Special case: copilot command (GitHub Copilot integration) - // Route known subcommands to command handler, keep all other args as profile passthrough. - if (firstArg === 'copilot' && args.length > 1) { - const copilotToken = args[1]; - const shouldRouteToCopilotCommand = isCopilotSubcommandToken(copilotToken); - - if (shouldRouteToCopilotCommand) { - const { handleCopilotCommand } = await import('./commands/copilot-command'); - const exitCode = await handleCopilotCommand(args.slice(1)); - process.exit(exitCode); - } - } - - // Special case: explicit legacy Cursor bridge namespace. - if (firstArg === LEGACY_CURSOR_PROFILE_NAME && args.length > 1) { - const { handleCursorCommand } = await import('./commands/cursor-command'); - const cursorToken = args[1]; - - if (isCursorSubcommandToken(cursorToken)) { - const exitCode = await handleCursorCommand(args.slice(1)); - process.exit(exitCode); - } - } - - // Compatibility shim: old `ccs cursor ` still forwards to the legacy bridge - // for one migration window, but bare/positional `ccs cursor` now belongs to CLIProxy. - if (firstArg === 'cursor' && args.length > 1) { - const { handleCursorCommand } = await import('./commands/cursor-command'); - const cursorToken = args[1]; - - if (isCursorSubcommandToken(cursorToken) && cursorToken !== '--help' && cursorToken !== '-h') { - printCursorLegacySubcommandDeprecation(cursorToken); - const exitCode = await handleCursorCommand(args.slice(1)); - process.exit(exitCode); - } - } - - // First-time install: offer setup wizard for interactive users - // Check independently of recovery status (user may have empty config.yaml) - // Skip if headless, CI, or non-TTY environment - const { isFirstTimeInstall } = await import('./commands/setup-command'); - if (process.stdout.isTTY && !process.env['CI'] && isFirstTimeInstall()) { - console.log(''); - console.log(info('First-time install detected. Run `ccs setup` for guided configuration.')); - console.log(' Or use `ccs config` for the web dashboard.'); - console.log(''); - } - - // Use ProfileDetector to determine profile type - const ProfileDetectorModule = await import('./auth/profile-detector'); - const ProfileDetector = ProfileDetectorModule.default; + // 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'); @@ -655,236 +59,17 @@ async function main(): Promise { const ProfileContinuityModule = await import('./auth/profile-continuity-inheritance'); const { resolveProfileContinuityInheritance } = ProfileContinuityModule; - const detector = new ProfileDetector(); + // Build full dispatch context (Phase E) + const dispatchCtx = { + ...resolvedProfile, + InstanceManager, + ProfileRegistry, + resolveAccountContextPolicy, + isAccountContextMetadata, + resolveProfileContinuityInheritance, + }; 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. @@ -898,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/__tests__/cli-argument-parser-bootstrap.test.ts b/src/dispatcher/__tests__/cli-argument-parser-bootstrap.test.ts new file mode 100644 index 00000000..7372c1f9 --- /dev/null +++ b/src/dispatcher/__tests__/cli-argument-parser-bootstrap.test.ts @@ -0,0 +1,152 @@ +/** + * Tests for bootstrapAndParseEarlyCli() — Phase A extraction. + * + * Uses a real temp dir for --config-dir validation tests. + * Mocks process.exit to assert error paths without terminating the test runner. + */ + +import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; +import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; +import { bootstrapAndParseEarlyCli } from '../cli-argument-parser'; + +// ========== Helpers ========== + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bootstrap-test-')); +} + +function cleanupDir(dir: string): void { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // best-effort + } +} + +// ========== Tests ========== + +describe('bootstrapAndParseEarlyCli', () => { + let originalArgv: string[]; + let originalStdoutIsTTY: boolean | undefined; + let exitSpy: ReturnType; + + beforeEach(() => { + originalArgv = process.argv.slice(); + originalStdoutIsTTY = process.stdout.isTTY; + // Non-TTY so initUI and update checks don't fire + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + // Suppress CI to ensure TTY branches are testable separately + process.env['CI'] = '1'; + + // Mock process.exit so error paths don't terminate test runner + exitSpy = spyOn(process, 'exit').mockImplementation((_code?: number | string) => { + throw new Error(`process.exit(${_code})`); + }); + }); + + afterEach(() => { + process.argv = originalArgv; + Object.defineProperty(process.stdout, 'isTTY', { + value: originalStdoutIsTTY, + configurable: true, + }); + delete process.env['CI']; + exitSpy.mockRestore(); + }); + + // ---------- completion command ---------- + + it('returns isCompletionCommand=true and exitNow=true for __complete args', async () => { + const result = await bootstrapAndParseEarlyCli(['__complete', 'some', 'arg']); + expect(result.isCompletionCommand).toBe(true); + expect(result.exitNow).toBe(true); + }); + + // ---------- --config-dir flag ---------- + + it('accepts a valid --config-dir and strips it from args', async () => { + const tmpDir = makeTempDir(); + try { + const result = await bootstrapAndParseEarlyCli(['--config-dir', tmpDir, 'gemini']); + expect(result.exitNow).toBe(false); + // --config-dir and its value must be stripped + expect(result.args).not.toContain('--config-dir'); + expect(result.args).not.toContain(tmpDir); + expect(result.args).toContain('gemini'); + } finally { + cleanupDir(tmpDir); + } + }); + + it('accepts --config-dir= (= syntax) and strips it from args', async () => { + const tmpDir = makeTempDir(); + try { + const result = await bootstrapAndParseEarlyCli([`--config-dir=${tmpDir}`, 'gemini']); + expect(result.exitNow).toBe(false); + expect(result.args.some((a) => a.startsWith('--config-dir'))).toBe(false); + expect(result.args).toContain('gemini'); + } finally { + cleanupDir(tmpDir); + } + }); + + it('calls process.exit when --config-dir has no value', async () => { + await expect(bootstrapAndParseEarlyCli(['--config-dir'])).rejects.toThrow('process.exit(1)'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('calls process.exit when --config-dir value is a flag', async () => { + await expect(bootstrapAndParseEarlyCli(['--config-dir', '--other-flag'])).rejects.toThrow( + 'process.exit(1)' + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('calls process.exit when --config-dir points to non-existent path', async () => { + await expect( + bootstrapAndParseEarlyCli(['--config-dir', '/tmp/does-not-exist-ccs-test-xyz']) + ).rejects.toThrow('process.exit(1)'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('calls process.exit when --config-dir points to a file (not dir)', async () => { + const tmpDir = makeTempDir(); + const filePath = path.join(tmpDir, 'notadir.txt'); + fs.writeFileSync(filePath, 'content'); + try { + await expect(bootstrapAndParseEarlyCli(['--config-dir', filePath])).rejects.toThrow( + 'process.exit(1)' + ); + expect(exitSpy).toHaveBeenCalledWith(1); + } finally { + cleanupDir(tmpDir); + } + }); + + // ---------- legacy cursor args ---------- + + it('normalizes legacy cursor args (legacy cursor → legacy-cursor profile)', async () => { + const result = await bootstrapAndParseEarlyCli(['legacy', 'cursor', '--auth']); + expect(result.exitNow).toBe(false); + // normalizeLegacyCursorArgs transforms ['legacy', 'cursor', '--auth'] → ['legacy-cursor', '--auth'] + expect(result.args[0]).toBe('legacy-cursor'); + expect(result.args).toContain('--auth'); + }); + + // ---------- normal passthrough ---------- + + it('returns exitNow=false and preserves args for normal invocation', async () => { + const result = await bootstrapAndParseEarlyCli(['gemini', '-p', 'hello']); + expect(result.exitNow).toBe(false); + expect(result.args).toContain('gemini'); + expect(result.args).toContain('-p'); + expect(result.args).toContain('hello'); + }); + + it('returns browserLaunchOverride=undefined when no browser flags are present', async () => { + const result = await bootstrapAndParseEarlyCli(['gemini']); + expect(result.browserLaunchOverride).toBeUndefined(); + }); +}); diff --git a/src/dispatcher/__tests__/pre-dispatch-handlers.test.ts b/src/dispatcher/__tests__/pre-dispatch-handlers.test.ts new file mode 100644 index 00000000..d67781db --- /dev/null +++ b/src/dispatcher/__tests__/pre-dispatch-handlers.test.ts @@ -0,0 +1,150 @@ +/** + * Tests for runPreDispatchHandlers() — Phase B extraction. + * + * Heavy dynamic-import surface means most paths are tested via the return value + * (consumed=true/false) and spied process.exit, not deep module internals. + */ + +import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; +import { runPreDispatchHandlers } from '../pre-dispatch'; +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; +} + +// ========== Tests ========== + +describe('runPreDispatchHandlers', () => { + let exitSpy: ReturnType; + + beforeEach(() => { + process.env['CI'] = '1'; + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + // Suppress process.exit — handlers use it for subcommand routing + exitSpy = spyOn(process, 'exit').mockImplementation((_code?: number | string) => { + throw new Error(`process.exit(${_code})`); + }); + }); + + afterEach(() => { + delete process.env['CI']; + Object.defineProperty(process.stdout, 'isTTY', { value: undefined, configurable: true }); + exitSpy.mockRestore(); + }); + + // ---------- update check / migrate / recovery (non-consuming) ---------- + + it('returns false for a normal profile invocation (gemini)', async () => { + const consumed = await runPreDispatchHandlers({ + args: ['gemini', '-p', 'hello'], + cliLogger: makeStubLogger(), + }); + expect(consumed).toBe(false); + }); + + it('returns false for default (no-arg) invocation', async () => { + const consumed = await runPreDispatchHandlers({ + args: [], + cliLogger: makeStubLogger(), + }); + expect(consumed).toBe(false); + }); + + // ---------- root command router ---------- + + it('consumes --version (either returns true or exits via process.exit(0))', async () => { + // --version is handled by tryHandleRootCommand; it either returns true or calls process.exit(0) + let consumed: boolean | undefined; + try { + consumed = await runPreDispatchHandlers({ + args: ['--version'], + cliLogger: makeStubLogger(), + }); + } catch (e) { + // process.exit(0) thrown by our spy — that is also a valid "consumed" signal + expect((e as Error).message).toMatch(/process\.exit/); + return; + } + expect(consumed).toBe(true); + }); + + it('consumes --help (either returns true or exits via process.exit(0))', async () => { + let consumed: boolean | undefined; + try { + consumed = await runPreDispatchHandlers({ + args: ['--help'], + cliLogger: makeStubLogger(), + }); + } catch (e) { + expect((e as Error).message).toMatch(/process\.exit/); + return; + } + expect(consumed).toBe(true); + }); + + // ---------- provider help shortcut ---------- + + it('returns true for provider + --help shortcut (gemini --help)', async () => { + const consumed = await runPreDispatchHandlers({ + args: ['gemini', '--help'], + cliLogger: makeStubLogger(), + }); + expect(consumed).toBe(true); + }); + + it('returns true for provider + -h shortcut (codex -h)', async () => { + const consumed = await runPreDispatchHandlers({ + args: ['codex', '-h'], + cliLogger: makeStubLogger(), + }); + expect(consumed).toBe(true); + }); + + // ---------- copilot subcommand routing ---------- + + it('exits via process.exit for copilot subcommand (copilot --auth)', async () => { + // copilot --auth is a known subcommand token; handler exits with a code + await expect( + runPreDispatchHandlers({ + args: ['copilot', '--auth'], + cliLogger: makeStubLogger(), + }) + ).rejects.toThrow(/process\.exit/); + expect(exitSpy).toHaveBeenCalled(); + }); + + // ---------- cursor subcommand routing ---------- + + it('exits via process.exit for legacy-cursor subcommand (legacy-cursor auth)', async () => { + // 'auth' is a valid CURSOR_SUBCOMMANDS token; handler calls process.exit(exitCode) + await expect( + runPreDispatchHandlers({ + args: ['legacy-cursor', 'auth'], + cliLogger: makeStubLogger(), + }) + ).rejects.toThrow(/process\.exit/); + expect(exitSpy).toHaveBeenCalled(); + }); + + // ---------- recovery error (non-fatal) ---------- + + it('does not throw when recovery manager throws (best-effort)', async () => { + // Recovery errors are caught; handler must continue and return false + // We can't easily force recovery to throw here without mocking the dynamic import, + // but we verify the normal path doesn't surface recovery exceptions. + const consumed = await runPreDispatchHandlers({ + args: ['glm'], + cliLogger: makeStubLogger(), + }); + expect(consumed).toBe(false); + }); +}); 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/cli-argument-parser.ts b/src/dispatcher/cli-argument-parser.ts new file mode 100644 index 00000000..2a11a199 --- /dev/null +++ b/src/dispatcher/cli-argument-parser.ts @@ -0,0 +1,338 @@ +/** + * CLI argument parsing and normalization utilities. + * + * Extracted from src/ccs.ts (lines 129-244, 246-296, 371-392). + * Pure functions — no side effects except console.error and process.exit. + * + * Also contains bootstrapAndParseEarlyCli() — the Phase A bootstrap extracted + * from main() (lines 128-232 of the original). Handles: adapter registration, + * UI init, --config-dir flag, cloud-sync warnings, completion short-circuit, + * normalizeLegacyCursorArgs, resolveBrowserLaunchFlagResolution, codex passthrough. + */ + +import * as fs from 'fs'; +import { fail, warn, info } from '../utils/ui'; +import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants'; +import { resolveTargetType, stripTargetFlag } from '../targets/target-resolver'; +import { resolveDroidReasoningRuntime } from '../targets/droid-reasoning-runtime'; +import type { ProfileDetectionResult } from '../auth/profile-detector'; +import { setGlobalConfigDir, detectCloudSyncPath } from '../utils/config-manager'; +import { resolveBrowserLaunchFlagResolution } from '../utils/browser'; +import type { BrowserLaunchOverride } from '../utils/browser'; + +// ========== Bootstrap Result ========== + +/** + * Result of the early CLI bootstrap pass. + * All fields are consumed by main() to decide how to continue. + */ +export interface DispatcherBootstrap { + /** Normalized/mutated args after pre-parse (--config-dir stripped, browser flags stripped, legacy cursor normalized) */ + args: string[]; + isCompletionCommand: boolean; + browserLaunchOverride: BrowserLaunchOverride | undefined; + /** true when the caller should return immediately (completion handled, codex passthrough triggered, process.exit called) */ + exitNow: boolean; +} + +/** + * Phase A bootstrap: runs everything that must happen before config loading and profile detection. + * + * Side-effects preserved from original main(): + * - Dynamic import of initUI + * - setGlobalConfigDir (--config-dir flag) + * - Cloud-sync warnings + * - Completion short-circuit via tryHandleRootCommand + * - Codex native passthrough via execNativeCodexFlagCommand + * + * Adapter registration (registerTarget calls) stays in main() because it is + * singleton wiring with no dependency on the parsed args. + */ +export async function bootstrapAndParseEarlyCli(rawArgs: string[]): Promise { + let args = rawArgs; + const isCompletionCommand = args[0] === '__complete'; + + // Initialize UI colors early to ensure consistent colored output + // Must happen before any status messages (ok, info, fail, etc.) + if (!isCompletionCommand && process.stdout.isTTY && !process.env['CI']) { + const { initUI } = await import('../utils/ui'); + await initUI(); + } + + // Parse --config-dir flag (must happen before any config loading) + const configDirIdx = args.findIndex((a) => a === '--config-dir' || a.startsWith('--config-dir=')); + if (configDirIdx !== -1) { + const arg = args[configDirIdx]; + let configDirValue: string | undefined; + let spliceCount = 1; + + if (arg.startsWith('--config-dir=')) { + configDirValue = arg.split('=').slice(1).join('='); + } else { + configDirValue = args[configDirIdx + 1]; + spliceCount = 2; + } + + if (!configDirValue || configDirValue.startsWith('-')) { + console.error(fail('--config-dir requires a path argument')); + process.exit(1); + } + + try { + const stat = fs.statSync(configDirValue); + if (!stat.isDirectory()) { + console.error(fail(`Not a directory: ${configDirValue}`)); + process.exit(1); + } + } catch { + console.error(fail(`Config directory not found: ${configDirValue}`)); + console.error(info('Create the directory first, then copy your config files into it.')); + process.exit(1); + } + + setGlobalConfigDir(configDirValue); + + // Security warning: cloud sync paths expose OAuth tokens + const cloudService = detectCloudSyncPath(configDirValue); + if (!isCompletionCommand && cloudService) { + console.error(warn(`CCS directory is under ${cloudService}.`)); + console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); + console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); + } + + // Remove consumed args so they don't leak to Claude CLI + // Clone the array before splicing so the original rawArgs is unaffected + args = [...args]; + args.splice(configDirIdx, spliceCount); + } else if (process.env.CCS_DIR) { + // Also warn for CCS_DIR env var pointing to cloud sync + const cloudService = detectCloudSyncPath(process.env.CCS_DIR); + if (!isCompletionCommand && cloudService) { + console.error(warn(`CCS directory is under ${cloudService}.`)); + console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); + console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); + } + } else if (process.env.CCS_HOME) { + // Also warn for CCS_HOME env var pointing to cloud sync + const cloudService = detectCloudSyncPath(process.env.CCS_HOME); + if (!isCompletionCommand && cloudService) { + console.error(warn(`CCS directory is under ${cloudService}.`)); + console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); + console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); + } + } + + if (isCompletionCommand) { + const { tryHandleRootCommand } = await import('../commands/root-command-router'); + await tryHandleRootCommand(args); + return { args, isCompletionCommand, browserLaunchOverride: undefined, exitNow: true }; + } + + args = normalizeLegacyCursorArgs(args); + let browserLaunchOverride: BrowserLaunchOverride | undefined; + try { + const browserLaunchFlags = resolveBrowserLaunchFlagResolution(args); + browserLaunchOverride = browserLaunchFlags.override; + args = browserLaunchFlags.argsWithoutFlags; + } catch (error) { + console.error(fail((error as Error).message)); + process.exit(1); + // process.exit never returns but TypeScript needs the unreachable return + return { args, isCompletionCommand, browserLaunchOverride: undefined, exitNow: true }; + } + + if (shouldPassthroughNativeCodexFlagCommand(args)) { + const { execNativeCodexFlagCommand } = await import('./target-executor'); + execNativeCodexFlagCommand(args); + return { args, isCompletionCommand, browserLaunchOverride: undefined, exitNow: true }; + } + + return { args, isCompletionCommand, browserLaunchOverride, exitNow: false }; +} + +// ========== Interfaces ========== + +export interface DetectedProfile { + profile: string; + remainingArgs: string[]; +} + +export interface RuntimeReasoningResolution { + argsWithoutReasoningFlags: string[]; + reasoningOverride: string | number | undefined; + reasoningSource: 'flag' | 'env' | undefined; + sourceDisplay: string | undefined; +} + +// ========== Constants ========== + +export const CODEX_RUNTIME_REASONING_LEVELS = new Set([ + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', +]); + +export const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']); + +export const NATIVE_CLAUDE_EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const; + +export const NATIVE_CLAUDE_EFFORT_LEVEL_SET = new Set(NATIVE_CLAUDE_EFFORT_LEVELS); + +// ========== Profile Detection ========== + +/** + * Smart profile detection — first non-flag arg is the profile name. + */ +export function detectProfile(args: string[]): DetectedProfile { + if (args.length === 0 || args[0].startsWith('-')) { + // No args or first arg is a flag → use default profile + return { profile: 'default', remainingArgs: args }; + } else { + // First arg doesn't start with '-' → treat as profile name + return { profile: args[0], remainingArgs: args.slice(1) }; + } +} + +export function normalizeLegacyCursorArgs(args: string[]): string[] { + if (args[0] === 'legacy' && args[1] === 'cursor') { + return [LEGACY_CURSOR_PROFILE_NAME, ...args.slice(2)]; + } + + return args; +} + +export function printCursorLegacySubcommandDeprecation(subcommand: string): void { + console.error( + warn(`\`ccs cursor ${subcommand}\` is deprecated for the legacy Cursor IDE bridge.`) + ); + console.error( + warn( + `Use \`ccs legacy cursor ${subcommand}\` for the old bridge, or \`ccs cursor --auth|--accounts|--config\` for the CLIProxy provider.` + ) + ); + console.error(''); +} + +// ========== Runtime Reasoning Flags ========== + +export function resolveRuntimeReasoningFlags( + args: string[], + envThinkingValue: string | undefined +): RuntimeReasoningResolution { + const runtime = resolveDroidReasoningRuntime(args, envThinkingValue); + + if (runtime.duplicateDisplays.length > 0) { + console.error( + warn( + `[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || ''}` + ) + ); + } + + return { + argsWithoutReasoningFlags: runtime.argsWithoutReasoningFlags, + reasoningOverride: runtime.reasoningOverride, + reasoningSource: runtime.sourceFlag + ? 'flag' + : runtime.reasoningOverride !== undefined + ? 'env' + : undefined, + sourceDisplay: runtime.sourceDisplay, + }; +} + +export function normalizeCodexRuntimeReasoningOverride( + value: string | number | undefined +): string | undefined { + return typeof value === 'string' && CODEX_RUNTIME_REASONING_LEVELS.has(value) ? value : undefined; +} + +export function exitWithRuntimeReasoningFlagError( + message: string, + options: { + codexAliasLevels: string; + includeDroidExecExample?: boolean; + } +): never { + console.error(fail(message)); + console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); + console.error(` Codex alias: --effort ${options.codexAliasLevels}`); + if (options.includeDroidExecExample) { + console.error(' Droid exec: --reasoning-effort high'); + } + process.exit(1); +} + +// ========== Native Claude Effort Normalization ========== + +export function normalizeNativeClaudeEffortArgs(args: string[]): string[] { + const normalizedArgs: string[] = []; + const allowedValues = NATIVE_CLAUDE_EFFORT_LEVELS.join(', '); + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--effort') { + const rawValue = args[i + 1]; + if (!rawValue || rawValue.startsWith('-') || !rawValue.trim()) { + throw new Error(`--effort requires a value: ${allowedValues}`); + } + const value = rawValue.toLowerCase(); + if (!NATIVE_CLAUDE_EFFORT_LEVEL_SET.has(value)) { + throw new Error(`Invalid --effort value: ${rawValue}. Expected one of: ${allowedValues}.`); + } + normalizedArgs.push(arg, value); + i++; + continue; + } + + if (arg.startsWith('--effort=')) { + const rawValue = arg.slice('--effort='.length); + if (!rawValue.trim()) { + throw new Error(`--effort requires a value: ${allowedValues}`); + } + const value = rawValue.toLowerCase(); + if (!NATIVE_CLAUDE_EFFORT_LEVEL_SET.has(value)) { + throw new Error(`Invalid --effort value: ${rawValue}. Expected one of: ${allowedValues}.`); + } + normalizedArgs.push(`--effort=${value}`); + continue; + } + + normalizedArgs.push(arg); + } + + return normalizedArgs; +} + +export function shouldNormalizeNativeClaudeEffort( + profileType: ProfileDetectionResult['type'] +): boolean { + return profileType === 'default' || profileType === 'account' || profileType === 'settings'; +} + +// ========== Native Codex Passthrough ========== + +export function shouldPassthroughNativeCodexFlagCommand(args: string[]): boolean { + return getNativeCodexPassthroughArgs(args) !== null; +} + +export function getNativeCodexPassthroughArgs(args: string[]): string[] | null { + const targetArgs = stripTargetFlag(args); + if (resolveTargetType(args) !== 'codex' || targetArgs.length === 0) { + return null; + } + + const firstArg = targetArgs[0] || ''; + if (CODEX_NATIVE_PASSTHROUGH_FLAGS.has(firstArg)) { + return targetArgs; + } + + const secondArg = targetArgs[1] || ''; + if (firstArg === 'codex' && CODEX_NATIVE_PASSTHROUGH_FLAGS.has(secondArg)) { + return targetArgs.slice(1); + } + + return null; +} diff --git a/src/dispatcher/dispatcher-context.ts b/src/dispatcher/dispatcher-context.ts new file mode 100644 index 00000000..b65dbe70 --- /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 '../config/config-loader-facade'; +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/environment-builder.ts b/src/dispatcher/environment-builder.ts new file mode 100644 index 00000000..564a7386 --- /dev/null +++ b/src/dispatcher/environment-builder.ts @@ -0,0 +1,125 @@ +/** + * Runtime environment and launch-args builders. + * + * Extracted from src/ccs.ts (lines 146-163, 300-327, 329-369). + * Covers update-cache helpers and launch-arg resolution for native Claude/Codex targets. + */ + +import { warn } from '../utils/ui'; +import { resolveBrowserExposure } from '../utils/browser'; +import { getBrowserConfig, getOfficialChannelsConfig } from '../config/config-loader-facade'; +import { + buildOfficialChannelsArgs, + getOfficialChannelsEnvironmentStatus, + officialChannelRequiresMacOS, + resolveOfficialChannelsLaunchPlan, +} from '../channels/official-channels-runtime'; +import { getOfficialChannelReadiness } from '../channels/official-channels-store'; +import { buildCodexBrowserMcpOverrides } from '../utils/browser-codex-overrides'; +import { getVersion } from '../utils/version'; +import { + checkForUpdates, + showUpdateNotification, + checkCachedUpdate, +} from '../utils/update-checker'; +import { resolveTargetType } from '../targets/target-resolver'; +import type { BrowserLaunchOverride } from '../utils/browser'; + +// ========== Codex Runtime Config ========== + +export function resolveCodexRuntimeConfigOverrides( + target: ReturnType, + browserLaunchOverride: BrowserLaunchOverride | undefined +): string[] { + if (target !== 'codex') { + return []; + } + + const codexBrowserExposure = resolveBrowserExposure( + getBrowserConfig().codex, + browserLaunchOverride + ); + if (!codexBrowserExposure.exposeForLaunch) { + return []; + } + + return buildCodexBrowserMcpOverrides(); +} + +// ========== Update Cache Helpers ========== + +/** + * Perform background update check (refreshes cache, no notification). + */ +export async function refreshUpdateCache(): Promise { + try { + const currentVersion = getVersion(); + // npm is now the only supported installation method + await checkForUpdates(currentVersion, true, 'npm'); + } catch (_e) { + // Silently fail - update check shouldn't crash main CLI + } +} + +/** + * Show update notification if cached result indicates update available. + * Returns true if notification was shown. + */ +export async function showCachedUpdateNotification(): Promise { + try { + const currentVersion = getVersion(); + const updateInfo = checkCachedUpdate(currentVersion); + + if (updateInfo) { + await showUpdateNotification(updateInfo); + return true; + } + } catch (_e) { + // Silently fail + } + return false; +} + +// ========== Native Claude Launch Args ========== + +export function resolveNativeClaudeLaunchArgs( + args: string[], + profileType: 'default' | 'account', + targetConfigDir?: string +): string[] { + const config = getOfficialChannelsConfig(); + const environment = getOfficialChannelsEnvironmentStatus( + targetConfigDir ? { CLAUDE_CONFIG_DIR: targetConfigDir } : undefined + ); + const channelReadiness = { + telegram: getOfficialChannelReadiness('telegram'), + discord: getOfficialChannelReadiness('discord'), + imessage: !officialChannelRequiresMacOS('imessage') || process.platform === 'darwin', + }; + const plan = resolveOfficialChannelsLaunchPlan({ + args, + config, + target: 'claude', + profileType, + environment, + channelReadiness, + }); + + for (const message of plan.skippedMessages) { + console.error(warn(message)); + } + + if ( + config.selected.length > 0 && + environment.auth.state === 'eligible' && + environment.auth.orgRequirementMessage + ) { + console.error(warn(environment.auth.orgRequirementMessage)); + } + + if (!plan.applied) { + return args; + } + + return buildOfficialChannelsArgs(args, plan.appliedChannels, plan.wantsPermissionBypass); +} 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..f54bbb41 --- /dev/null +++ b/src/dispatcher/flows/settings-flow.ts @@ -0,0 +1,401 @@ +/** + * 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 } from '../../utils/config-manager'; +import { loadSettings } from '../../config/config-loader-facade'; +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/config-loader-facade'; +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..3a20fc19 --- /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 '../../config/config-loader-facade'; +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/pre-dispatch.ts b/src/dispatcher/pre-dispatch.ts new file mode 100644 index 00000000..725f2240 --- /dev/null +++ b/src/dispatcher/pre-dispatch.ts @@ -0,0 +1,155 @@ +/** + * Pre-dispatch side-effect handlers. + * + * Extracted from src/ccs.ts (Phase B, lines 145-254 after Phase A extraction). + * Each handler may short-circuit the dispatch by returning true. + * + * Handles: update check, auto-migrate, recovery-manager, root-command router, + * provider help shortcut, copilot/cursor subcommand routing, first-time install hint. + */ + +import { info } from '../utils/ui'; +import { isCLIProxyProvider } from '../cliproxy/provider-capabilities'; +import { isCopilotSubcommandToken } from '../copilot/constants'; +import { isCursorSubcommandToken, LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants'; +import { isCacheStale } from '../utils/update-checker'; +import { tryHandleRootCommand } from '../commands/root-command-router'; +import { refreshUpdateCache, showCachedUpdateNotification } from './environment-builder'; +import { printCursorLegacySubcommandDeprecation } from './cli-argument-parser'; +import type { Logger } from '../services/logging/logger'; + +// ========== Pre-Dispatch Context ========== + +export interface PreDispatchContext { + args: string[]; + cliLogger: Logger; +} + +// ========== Pre-Dispatch Runner ========== + +/** + * Run all pre-dispatch side-effect handlers in sequence. + * Returns true if a handler consumed the command (caller should return immediately). + * Returns false if dispatch should continue normally. + * + * All process.exit calls and dynamic imports are preserved at original call sites. + */ +export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise { + const { args, cliLogger } = ctx; + const firstArg = args[0] as string | undefined; + + // Update check: show cached notification + refresh background cache + const skipUpdateCheck = [ + 'version', + '--version', + '-v', + 'help', + '--help', + '-h', + 'update', + '--update', + ]; + if (process.stdout.isTTY && !process.env['CI'] && !skipUpdateCheck.includes(firstArg ?? '')) { + // 1. Show cached update notification (async for proper UI) + await showCachedUpdateNotification(); + + // 2. Refresh cache in background if stale (non-blocking) + if (isCacheStale()) { + refreshUpdateCache(); + } + } + + // Auto-migrate to unified config format (silent if already migrated) + // Skip if user is explicitly running migrate command + if (firstArg !== 'migrate') { + const { autoMigrate } = await import('../config/migration-manager'); + await autoMigrate(); + } + + // Auto-recovery for missing configuration (BEFORE any early-exit commands) + // Recovery is safe to run early - it only creates missing files with safe defaults + // Wrapped in try-catch to prevent blocking --version/--help on permission errors + try { + const RecoveryManagerModule = await import('../management/recovery-manager'); + const RecoveryManager = RecoveryManagerModule.default; + const recovery = new RecoveryManager(); + const recovered = recovery.recoverAll(); + + if (recovered) { + recovery.showRecoveryHints(); + } + } catch (err) { + cliLogger.warn('recovery.failed', 'Auto-recovery failed during CLI startup', { + message: (err as Error).message, + }); + // Recovery is best-effort - don't block basic CLI functionality + console.warn('[!] Recovery failed:', (err as Error).message); + } + + // Root command router (handles --help, --version, config, doctor, etc.) + if (await tryHandleRootCommand(args)) { + return true; + } + + // Provider help shortcut: `ccs gemini --help` → provider-specific help page + if ( + typeof firstArg === 'string' && + isCLIProxyProvider(firstArg) && + args.length > 1 && + (args.includes('--help') || args.includes('-h')) + ) { + const { showProviderShortcutHelp } = await import('../commands/help-command'); + await showProviderShortcutHelp(firstArg); + return true; + } + + // Special case: copilot command (GitHub Copilot integration) + // Route known subcommands to command handler, keep all other args as profile passthrough. + if (firstArg === 'copilot' && args.length > 1) { + const copilotToken = args[1]; + const shouldRouteToCopilotCommand = isCopilotSubcommandToken(copilotToken); + + if (shouldRouteToCopilotCommand) { + const { handleCopilotCommand } = await import('../commands/copilot-command'); + const exitCode = await handleCopilotCommand(args.slice(1)); + process.exit(exitCode); + } + } + + // Special case: explicit legacy Cursor bridge namespace. + if (firstArg === LEGACY_CURSOR_PROFILE_NAME && args.length > 1) { + const { handleCursorCommand } = await import('../commands/cursor-command'); + const cursorToken = args[1]; + + if (isCursorSubcommandToken(cursorToken)) { + const exitCode = await handleCursorCommand(args.slice(1)); + process.exit(exitCode); + } + } + + // Compatibility shim: old `ccs cursor ` still forwards to the legacy bridge + // for one migration window, but bare/positional `ccs cursor` now belongs to CLIProxy. + if (firstArg === 'cursor' && args.length > 1) { + const { handleCursorCommand } = await import('../commands/cursor-command'); + const cursorToken = args[1]; + + if (isCursorSubcommandToken(cursorToken) && cursorToken !== '--help' && cursorToken !== '-h') { + printCursorLegacySubcommandDeprecation(cursorToken); + const exitCode = await handleCursorCommand(args.slice(1)); + process.exit(exitCode); + } + } + + // First-time install: offer setup wizard for interactive users + // Check independently of recovery status (user may have empty config.yaml) + // Skip if headless, CI, or non-TTY environment + const { isFirstTimeInstall } = await import('../commands/setup-command'); + if (process.stdout.isTTY && !process.env['CI'] && isFirstTimeInstall()) { + console.log(''); + console.log(info('First-time install detected. Run `ccs setup` for guided configuration.')); + console.log(' Or use `ccs config` for the web dashboard.'); + console.log(''); + } + + return false; +} diff --git a/src/dispatcher/profile-resolver.ts b/src/dispatcher/profile-resolver.ts new file mode 100644 index 00000000..bff9cbd0 --- /dev/null +++ b/src/dispatcher/profile-resolver.ts @@ -0,0 +1,359 @@ +/** + * 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 } from '../utils/config-manager'; +import { loadSettings } from '../config/config-loader-facade'; +import { expandPath } from '../utils/helpers'; +import { fail, info, warn } from '../utils/ui'; +import { ErrorManager } from '../utils/error-manager'; +import { getBrowserConfig } from '../config/config-loader-facade'; +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, + }; +} diff --git a/src/dispatcher/target-executor.ts b/src/dispatcher/target-executor.ts new file mode 100644 index 00000000..7016caf2 --- /dev/null +++ b/src/dispatcher/target-executor.ts @@ -0,0 +1,94 @@ +/** + * 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 ========== + +export interface ProfileError extends Error { + profileName?: string; + availableProfiles?: string; + suggestions?: string[]; +} + +// ========== Native Codex Flag Command Executor ========== + +export function execNativeCodexFlagCommand(args: string[]): void { + const adapter = getTarget('codex'); + if (!adapter) { + console.error(fail('Target adapter not found for "codex"')); + process.exit(1); + } + + const binaryInfo = adapter.detectBinary(); + if (!binaryInfo) { + console.error(fail('Codex CLI not found.')); + console.error(info('Install a recent @openai/codex build, then retry.')); + process.exit(1); + } + + const targetArgs = getNativeCodexPassthroughArgs(args); + if (!targetArgs) { + console.error(fail('Native Codex passthrough args could not be resolved.')); + process.exit(1); + } + const creds: TargetCredentials = { + profile: 'default', + baseUrl: '', + apiKey: '', + }; + + const builtArgs = adapter.buildArgs('default', targetArgs, { + creds, + profileType: 'default', + binaryInfo, + }); + 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); + } +}