From f759cfeaaaef26591060a2746319cc5156287da8 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 2 May 2026 22:49:45 -0400 Subject: [PATCH 1/4] refactor(dispatcher): scaffold src/dispatcher/ and extract pure helpers from ccs.ts Phase 1 of #1165. Pure code-motion: moves top-level helpers out of the 1775-LOC CLI entry point into focused dispatcher modules. Zero behavior change, zero main() changes. - src/dispatcher/cli-argument-parser.ts (199 LOC): DetectedProfile, RuntimeReasoningResolution, NATIVE_CLAUDE_EFFORT constants, detectProfile, normalizeLegacyCursorArgs, printCursorLegacySubcommandDeprecation, resolveRuntimeReasoningFlags, normalizeCodexRuntimeReasoningOverride, exitWithRuntimeReasoningFlagError, normalizeNativeClaudeEffortArgs, shouldNormalizeNativeClaudeEffort, shouldPassthroughNativeCodexFlagCommand, getNativeCodexPassthroughArgs. - src/dispatcher/environment-builder.ts (125 LOC): resolveCodexRuntimeConfigOverrides, refreshUpdateCache, showCachedUpdateNotification, resolveNativeClaudeLaunchArgs. - src/dispatcher/target-executor.ts (56 LOC): ProfileError, execNativeCodexFlagCommand. ccs.ts: 1775 -> 1475 LOC (-300). Cleaned up 8 now-orphaned imports. Behavior unchanged; full suite passes 1824/1824. Refs #1165 --- src/ccs.ts | 344 ++------------------------ src/dispatcher/cli-argument-parser.ts | 199 +++++++++++++++ src/dispatcher/environment-builder.ts | 125 ++++++++++ src/dispatcher/target-executor.ts | 56 +++++ 4 files changed, 402 insertions(+), 322 deletions(-) create mode 100644 src/dispatcher/cli-argument-parser.ts create mode 100644 src/dispatcher/environment-builder.ts create mode 100644 src/dispatcher/target-executor.ts diff --git a/src/ccs.ts b/src/ccs.ts index 0fde15ea..10b8b94e 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -46,11 +46,7 @@ import { resolveOptionalBrowserAttachRuntime, syncBrowserMcpToConfigDir, } from './utils/browser'; -import { - getBrowserConfig, - getGlobalEnvConfig, - getOfficialChannelsConfig, -} from './config/unified-config-loader'; +import { getBrowserConfig, getGlobalEnvConfig } from './config/unified-config-loader'; import { ensureProfileHooks as ensureImageAnalyzerHooks, removeImageAnalysisProfileHook, @@ -65,13 +61,6 @@ import { } 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'; @@ -85,7 +74,6 @@ import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils import { createOpenAICompatLaunchSettings } from './utils/openai-compat-launch-settings'; import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning'; import { createLogger, runWithRequestId } from './services/logging'; -import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides'; import type { ProfileDetectionResult } from './auth/profile-detector'; import type { BrowserLaunchOverride } from './utils/browser'; @@ -102,10 +90,7 @@ import { type TargetCredentials, } from './targets'; import { resolveTargetType, stripTargetFlag } from './targets/target-resolver'; -import { - DroidReasoningFlagError, - resolveDroidReasoningRuntime, -} from './targets/droid-reasoning-runtime'; +import { DroidReasoningFlagError } from './targets/droid-reasoning-runtime'; import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router'; import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge'; import { @@ -115,316 +100,31 @@ import { } from './proxy'; // Version and Update check utilities -import { getVersion } from './utils/version'; -import { - checkForUpdates, - showUpdateNotification, - checkCachedUpdate, - isCacheStale, -} from './utils/update-checker'; +import { isCacheStale } from './utils/update-checker'; // 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 { + detectProfile, + normalizeLegacyCursorArgs, + printCursorLegacySubcommandDeprecation, + resolveRuntimeReasoningFlags, + normalizeCodexRuntimeReasoningOverride, + exitWithRuntimeReasoningFlagError, + normalizeNativeClaudeEffortArgs, + shouldNormalizeNativeClaudeEffort, + shouldPassthroughNativeCodexFlagCommand, +} from './dispatcher/cli-argument-parser'; +import { + resolveCodexRuntimeConfigOverrides, + refreshUpdateCache, + showCachedUpdateNotification, + resolveNativeClaudeLaunchArgs, +} from './dispatcher/environment-builder'; +import { type ProfileError, execNativeCodexFlagCommand } from './dispatcher/target-executor'; // ========== 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 registerTarget(new ClaudeAdapter()); diff --git a/src/dispatcher/cli-argument-parser.ts b/src/dispatcher/cli-argument-parser.ts new file mode 100644 index 00000000..44db38fc --- /dev/null +++ b/src/dispatcher/cli-argument-parser.ts @@ -0,0 +1,199 @@ +/** + * 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. + */ + +import { fail, warn } 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'; + +// ========== 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/environment-builder.ts b/src/dispatcher/environment-builder.ts new file mode 100644 index 00000000..3bc8c522 --- /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/unified-config-loader'; +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/target-executor.ts b/src/dispatcher/target-executor.ts new file mode 100644 index 00000000..a123746f --- /dev/null +++ b/src/dispatcher/target-executor.ts @@ -0,0 +1,56 @@ +/** + * Native target execution — short-circuit dispatch for passthrough flag commands. + * + * 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. + */ + +import { fail, info } from '../utils/ui'; +import { getTarget } from '../targets'; +import { getNativeCodexPassthroughArgs } from './cli-argument-parser'; +import type { TargetCredentials } from '../targets'; + +// ========== 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 }); +} From 0cf4ad7b48b644c30d29dbce7fdfd78400320df4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 2 May 2026 23:27:05 -0400 Subject: [PATCH 2/4] refactor(dispatcher): extract bootstrap and pre-dispatch handlers from ccs.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phases 2+3 of #1165. Extracts main() bootstrap + pre-dispatch handler chain into focused dispatcher modules: - src/dispatcher/cli-argument-parser.ts (+138 LOC): bootstrapAndParseEarlyCli + DispatcherBootstrap. Encapsulates UI init, --config-dir parse/validate, cloud-sync warnings, completion command short-circuit, legacy cursor arg normalization, browser launch flag resolution, and native codex passthrough escape. - src/dispatcher/pre-dispatch.ts (155 LOC): runPreDispatchHandlers. Wraps update check, root command router, provider help shortcut, copilot/cursor subcommand routing, first-time install hint. Returns boolean consumed signal. - New tests: 12 + 7 covering both modules. Adapter registration intentionally stays in main() — singleton wiring with no arg dependency; folding it into bootstrap would conflate concerns. ccs.ts: 1475 -> 1262 LOC (-213). Behavior unchanged; full suite passes 1824/1824. Refs #1165 --- src/ccs.ts | 239 +----------------- .../cli-argument-parser-bootstrap.test.ts | 152 +++++++++++ .../__tests__/pre-dispatch-handlers.test.ts | 150 +++++++++++ src/dispatcher/cli-argument-parser.ts | 141 ++++++++++- src/dispatcher/pre-dispatch.ts | 155 ++++++++++++ 5 files changed, 610 insertions(+), 227 deletions(-) create mode 100644 src/dispatcher/__tests__/cli-argument-parser-bootstrap.test.ts create mode 100644 src/dispatcher/__tests__/pre-dispatch-handlers.test.ts create mode 100644 src/dispatcher/pre-dispatch.ts diff --git a/src/ccs.ts b/src/ccs.ts index 10b8b94e..ba572f57 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -1,13 +1,7 @@ import './utils/fetch-proxy-setup'; -import * as fs from 'fs'; import { detectClaudeCli } from './utils/claude-detector'; -import { - getSettingsPath, - loadSettings, - setGlobalConfigDir, - detectCloudSyncPath, -} from './utils/config-manager'; +import { getSettingsPath, loadSettings } from './utils/config-manager'; import { expandPath } from './utils/helpers'; import { validateGlmKey, @@ -42,7 +36,6 @@ import { getBlockedBrowserOverrideWarning, getEffectiveClaudeBrowserAttachConfig, resolveBrowserExposure, - resolveBrowserLaunchFlagResolution, resolveOptionalBrowserAttachRuntime, syncBrowserMcpToConfigDir, } from './utils/browser'; @@ -60,13 +53,8 @@ import { resolveImageAnalysisRuntimeStatus, } from './utils/hooks'; import { fail, info, warn } from './utils/ui'; -import { isCopilotSubcommandToken } from './copilot/constants'; -import { isCursorSubcommandToken, LEGACY_CURSOR_PROFILE_NAME } from './cursor/constants'; -import { isCLIProxyProvider } from './cliproxy/provider-capabilities'; - // 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'; @@ -75,7 +63,6 @@ import { createOpenAICompatLaunchSettings } from './utils/openai-compat-launch-s import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning'; import { createLogger, runWithRequestId } from './services/logging'; import type { ProfileDetectionResult } from './auth/profile-detector'; -import type { BrowserLaunchOverride } from './utils/browser'; // Import target adapter system import { @@ -99,126 +86,40 @@ import { startOpenAICompatProxy, } from './proxy'; -// Version and Update check utilities -import { isCacheStale } from './utils/update-checker'; -// Note: npm is now the only supported installation method - // Import extracted dispatcher modules import { detectProfile, - normalizeLegacyCursorArgs, - printCursorLegacySubcommandDeprecation, resolveRuntimeReasoningFlags, normalizeCodexRuntimeReasoningOverride, exitWithRuntimeReasoningFlagError, normalizeNativeClaudeEffortArgs, shouldNormalizeNativeClaudeEffort, - shouldPassthroughNativeCodexFlagCommand, + bootstrapAndParseEarlyCli, } from './dispatcher/cli-argument-parser'; import { resolveCodexRuntimeConfigOverrides, - refreshUpdateCache, - showCachedUpdateNotification, resolveNativeClaudeLaunchArgs, } from './dispatcher/environment-builder'; -import { type ProfileError, execNativeCodexFlagCommand } from './dispatcher/target-executor'; +import { type ProfileError } from './dispatcher/target-executor'; +import { runPreDispatchHandlers } from './dispatcher/pre-dispatch'; // ========== Main Execution ========== 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', @@ -226,126 +127,12 @@ 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]; - - // 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; 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/cli-argument-parser.ts b/src/dispatcher/cli-argument-parser.ts index 44db38fc..2a11a199 100644 --- a/src/dispatcher/cli-argument-parser.ts +++ b/src/dispatcher/cli-argument-parser.ts @@ -3,13 +3,152 @@ * * 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 { fail, warn } from '../utils/ui'; +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 ========== 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; +} From a807de6f4cc3d328349075699b5fb27efd3e6e80 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 2 May 2026 23:41:48 -0400 Subject: [PATCH 3/4] refactor(dispatcher): extract profile and target detection from ccs.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of #1165. Extracts the largest remaining concern in main(): profile detection, target resolution, Claude CLI detection, adapter lookup, compatibility preflight, binary detection, droid prune, and per-target arg normalization. - src/dispatcher/profile-resolver.ts (358 LOC): resolveProfileAndTarget + ProfileResolutionContext / ResolvedProfile types. Includes droid + codex per-target arg normalization (cohesive with profile resolution; splitting would create incomplete extraction). - 7 unit tests covering default, gemini, codex, unknown profile, droid, and compatibility preflight runs. Compatibility preflight duplication between this phase and the settings flow (Phase E) is preserved exactly per plan risk note 5 — do not dedupe in this PR. Module is 358 LOC (over 300 ideal) — kept whole because the profile resolution + per-target normalization form a single dependency chain. ccs.ts: 1262 -> 1040 LOC (-222). Behavior unchanged; full suite passes 1824/1824. Refs #1165 --- src/ccs.ts | 292 ++----------- .../__tests__/profile-resolver.test.ts | 387 ++++++++++++++++++ src/dispatcher/profile-resolver.ts | 358 ++++++++++++++++ 3 files changed, 780 insertions(+), 257 deletions(-) create mode 100644 src/dispatcher/__tests__/profile-resolver.test.ts create mode 100644 src/dispatcher/profile-resolver.ts diff --git a/src/ccs.ts b/src/ccs.ts index ba572f57..d1a27bfc 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -1,6 +1,5 @@ import './utils/fetch-proxy-setup'; -import { detectClaudeCli } from './utils/claude-detector'; import { getSettingsPath, loadSettings } from './utils/config-manager'; import { expandPath } from './utils/helpers'; import { @@ -33,13 +32,10 @@ import { import { appendBrowserToolArgs, ensureBrowserMcpOrThrow, - getBlockedBrowserOverrideWarning, - getEffectiveClaudeBrowserAttachConfig, - resolveBrowserExposure, resolveOptionalBrowserAttachRuntime, syncBrowserMcpToConfigDir, } from './utils/browser'; -import { getBrowserConfig, getGlobalEnvConfig } from './config/unified-config-loader'; +import { getGlobalEnvConfig } from './config/unified-config-loader'; import { ensureProfileHooks as ensureImageAnalyzerHooks, removeImageAnalysisProfileHook, @@ -62,23 +58,16 @@ import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils import { createOpenAICompatLaunchSettings } from './utils/openai-compat-launch-settings'; import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning'; import { createLogger, runWithRequestId } from './services/logging'; -import type { ProfileDetectionResult } from './auth/profile-detector'; - // Import target adapter system import { registerTarget, - getTarget, ClaudeAdapter, DroidAdapter, CodexAdapter, evaluateTargetRuntimeCompatibility, - pruneOrphanedModels, resolveDroidProvider, type TargetCredentials, } from './targets'; -import { resolveTargetType, stripTargetFlag } from './targets/target-resolver'; -import { DroidReasoningFlagError } from './targets/droid-reasoning-runtime'; -import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router'; import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge'; import { buildOpenAICompatProxyEnv, @@ -87,21 +76,11 @@ import { } from './proxy'; // Import extracted dispatcher modules -import { - detectProfile, - resolveRuntimeReasoningFlags, - normalizeCodexRuntimeReasoningOverride, - exitWithRuntimeReasoningFlagError, - normalizeNativeClaudeEffortArgs, - shouldNormalizeNativeClaudeEffort, - bootstrapAndParseEarlyCli, -} from './dispatcher/cli-argument-parser'; -import { - resolveCodexRuntimeConfigOverrides, - resolveNativeClaudeLaunchArgs, -} from './dispatcher/environment-builder'; +import { bootstrapAndParseEarlyCli } from './dispatcher/cli-argument-parser'; +import { resolveNativeClaudeLaunchArgs } from './dispatcher/environment-builder'; import { type ProfileError } from './dispatcher/target-executor'; import { runPreDispatchHandlers } from './dispatcher/pre-dispatch'; +import { resolveProfileAndTarget } from './dispatcher/profile-resolver'; // ========== Main Execution ========== @@ -133,9 +112,37 @@ async function main(): Promise { return; } - // Use ProfileDetector to determine profile type - const ProfileDetectorModule = await import('./auth/profile-detector'); - const ProfileDetector = ProfileDetectorModule.default; + // Phase C: profile + target detection (extracted to dispatcher/profile-resolver.ts) + const { + profile, + remainingArgs, + profileInfo, + resolvedTarget, + claudeCli, + targetAdapter, + targetBinaryInfo, + resolvedSettingsPath: initialResolvedSettingsPath, + resolvedSettings: initialResolvedSettings, + resolvedCliproxyBridge: initialResolvedCliproxyBridge, + targetRemainingArgs: initialTargetRemainingArgs, + nativeClaudeRemainingArgs: initialNativeClaudeRemainingArgs, + runtimeReasoningOverride: initialRuntimeReasoningOverride, + codexRuntimeConfigOverrides, + claudeBrowserExposure, + codexBrowserExposure: _codexBrowserExposure, + claudeAttachConfig, + detector: _detector, + } = await resolveProfileAndTarget({ args, browserLaunchOverride, cliLogger }); + + // Re-assign mutable state that Phase E flows may update + const resolvedSettingsPath = initialResolvedSettingsPath; + const resolvedSettings = initialResolvedSettings; + const resolvedCliproxyBridge = initialResolvedCliproxyBridge; + const targetRemainingArgs = initialTargetRemainingArgs; + const nativeClaudeRemainingArgs = initialNativeClaudeRemainingArgs; + const runtimeReasoningOverride = initialRuntimeReasoningOverride; + + // Re-import dynamic modules needed by Phase E flows (preserving original ordering). const InstanceManagerModule = await import('./management/instance-manager'); const InstanceManager = InstanceManagerModule.default; const ProfileRegistryModule = await import('./auth/profile-registry'); @@ -145,236 +152,7 @@ async function main(): Promise { const ProfileContinuityModule = await import('./auth/profile-continuity-inheritance'); const { resolveProfileContinuityInheritance } = ProfileContinuityModule; - const detector = new ProfileDetector(); - try { - // Detect profile (strip --target flags before profile detection) - const cleanArgs = stripTargetFlag(args); - const { profile, remainingArgs } = detectProfile(cleanArgs); - const profileInfo: ProfileDetectionResult = detector.detectProfileType(profile); - let resolvedTarget: ReturnType; - try { - resolvedTarget = resolveTargetType( - args, - profileInfo.target ? { target: profileInfo.target } : undefined - ); - } catch (error) { - console.error(fail((error as Error).message)); - process.exit(1); - return; - } - - // Detect Claude CLI (needed for claude target and all CLIProxy-derived flows) - const claudeCliRaw = detectClaudeCli(); - if (resolvedTarget === 'claude' && !claudeCliRaw) { - await ErrorManager.showClaudeNotFound(); - process.exit(1); - } - const claudeCli = claudeCliRaw || ''; - - // Resolve non-claude target adapter once. - const targetAdapter = resolvedTarget !== 'claude' ? getTarget(resolvedTarget) : null; - let resolvedSettingsPath: string | undefined; - let resolvedSettings: ReturnType | undefined; - let resolvedCliproxyBridge: ReturnType | undefined; - - // Preflight unsupported profile/target combinations BEFORE binary detection, - // so users get the most actionable error even when the target CLI is not installed. - if (resolvedTarget !== 'claude') { - if (!targetAdapter) { - console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); - process.exit(1); - } - - if (profileInfo.type === 'settings') { - resolvedSettingsPath = profileInfo.settingsPath - ? expandPath(profileInfo.settingsPath) - : getSettingsPath(profileInfo.name); - resolvedSettings = loadSettings(resolvedSettingsPath); - resolvedCliproxyBridge = resolveCliproxyBridgeMetadata(resolvedSettings); - const compatibility = evaluateTargetRuntimeCompatibility({ - target: resolvedTarget, - profileType: profileInfo.type, - cliproxyBridgeProvider: resolvedCliproxyBridge?.provider ?? null, - }); - if (!compatibility.supported) { - console.error( - fail( - compatibility.reason || `${targetAdapter.displayName} does not support this profile.` - ) - ); - if (compatibility.suggestion) { - console.error(info(compatibility.suggestion)); - } - process.exit(1); - } - } else { - const compatibility = evaluateTargetRuntimeCompatibility({ - target: resolvedTarget, - profileType: profileInfo.type, - cliproxyProvider: profileInfo.type === 'cliproxy' ? profileInfo.provider : undefined, - isComposite: - profileInfo.type === 'cliproxy' ? Boolean(profileInfo.isComposite) : undefined, - }); - if (!compatibility.supported) { - console.error( - fail( - compatibility.reason || `${targetAdapter.displayName} does not support this profile.` - ) - ); - if (compatibility.suggestion) { - console.error(info(compatibility.suggestion)); - } - process.exit(1); - } - } - - if (profileInfo.type === 'default') { - if (!targetAdapter.supportsProfileType('default')) { - console.error(fail(`${targetAdapter.displayName} does not support default profile mode`)); - process.exit(1); - } - - // For default mode, Droid requires explicit credentials from environment. - if (resolvedTarget === 'droid') { - const baseUrl = process.env['ANTHROPIC_BASE_URL'] || ''; - const apiKey = process.env['ANTHROPIC_AUTH_TOKEN'] || ''; - if (!baseUrl.trim() || !apiKey.trim()) { - console.error( - fail( - `${targetAdapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN` - ) - ); - console.error(info('Use a settings-based profile instead: ccs glm --target droid')); - process.exit(1); - } - } - } - } - - // For non-claude targets, verify target binary exists once and pass it through. - const targetBinaryInfo = targetAdapter?.detectBinary() ?? null; - const browserConfig = getBrowserConfig(); - const claudeAttachConfig = - resolvedTarget === 'claude' - ? getEffectiveClaudeBrowserAttachConfig(browserConfig) - : undefined; - const codexRuntimeConfigOverrides = resolveCodexRuntimeConfigOverrides( - resolvedTarget, - browserLaunchOverride - ); - const claudeBrowserExposure = - resolvedTarget === 'claude' - ? resolveBrowserExposure( - { - enabled: claudeAttachConfig?.enabled ?? browserConfig.claude.enabled, - policy: browserConfig.claude.policy, - }, - browserLaunchOverride - ) - : undefined; - const codexBrowserExposure = - resolvedTarget === 'codex' - ? resolveBrowserExposure(browserConfig.codex, browserLaunchOverride) - : undefined; - const blockedBrowserOverrideWarning = - resolvedTarget === 'claude' && claudeBrowserExposure - ? getBlockedBrowserOverrideWarning('Claude Browser Attach', claudeBrowserExposure) - : resolvedTarget === 'codex' && codexBrowserExposure - ? getBlockedBrowserOverrideWarning('Codex Browser Tools', codexBrowserExposure) - : undefined; - if (blockedBrowserOverrideWarning) { - console.error(warn(blockedBrowserOverrideWarning)); - } - if (resolvedTarget !== 'claude' && !targetBinaryInfo) { - const displayName = targetAdapter?.displayName || resolvedTarget; - console.error(fail(`${displayName} CLI not found.`)); - if (resolvedTarget === 'droid') { - console.error(info('Install: npm i -g @factory/cli')); - } else if (resolvedTarget === 'codex') { - console.error(info('Install a recent @openai/codex build, then retry.')); - } - process.exit(1); - } - - // Best-effort: prune stale Droid model entries at runtime so settings.json stays clean. - if (resolvedTarget === 'droid') { - try { - const allProfiles = detector.getAllProfiles(); - const activeProfiles = allProfiles.settings.filter((name) => - /^[a-zA-Z0-9._-]+$/.test(name) - ); - await pruneOrphanedModels(activeProfiles); - } catch (error) { - console.error(warn(`[!] Droid prune skipped: ${(error as Error).message}`)); - } - } - - let targetRemainingArgs = remainingArgs; - let runtimeReasoningOverride: string | number | undefined; - let nativeClaudeRemainingArgs = remainingArgs; - if (resolvedTarget === 'droid') { - try { - const droidRoute = routeDroidCommandArgs(remainingArgs); - targetRemainingArgs = droidRoute.argsForDroid; - - if (droidRoute.mode === 'interactive') { - const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env.CCS_THINKING); - targetRemainingArgs = runtime.argsWithoutReasoningFlags; - runtimeReasoningOverride = runtime.reasoningOverride; - } else { - if (droidRoute.duplicateReasoningDisplays.length > 0) { - console.error( - warn( - `[!] Multiple reasoning flags detected. Using first occurrence: ${droidRoute.reasoningSourceDisplay || ''}` - ) - ); - } - if (droidRoute.autoPrependedExec && process.stdout.isTTY) { - console.error( - info('Detected Droid exec-only flags. Routing as: droid exec [prompt]') - ); - } - } - } catch (error) { - if (error instanceof DroidReasoningFlagError || error instanceof DroidCommandRouterError) { - exitWithRuntimeReasoningFlagError(error.message, { - codexAliasLevels: 'minimal|low|medium|high|xhigh', - includeDroidExecExample: true, - }); - } - throw error; - } - } else if (resolvedTarget === 'codex') { - try { - const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env.CCS_THINKING); - targetRemainingArgs = runtime.argsWithoutReasoningFlags; - const normalizedReasoning = normalizeCodexRuntimeReasoningOverride( - runtime.reasoningOverride - ); - if (runtime.reasoningOverride !== undefined && !normalizedReasoning) { - if (runtime.reasoningSource === 'flag') { - throw new DroidReasoningFlagError( - 'Codex target supports reasoning levels only: minimal, low, medium, high, xhigh.', - '--effort' - ); - } - runtimeReasoningOverride = undefined; - } else { - runtimeReasoningOverride = normalizedReasoning; - } - } catch (error) { - if (error instanceof DroidReasoningFlagError) { - exitWithRuntimeReasoningFlagError(error.message, { - codexAliasLevels: 'minimal|low|medium|high|xhigh', - }); - } - throw error; - } - } else if (resolvedTarget === 'claude' && shouldNormalizeNativeClaudeEffort(profileInfo.type)) { - nativeClaudeRemainingArgs = normalizeNativeClaudeEffortArgs(remainingArgs); - } - // Special case: headless delegation (-p/--prompt) // Keep existing behavior for Claude targets only; non-claude targets must continue // through normal adapter dispatch logic. diff --git a/src/dispatcher/__tests__/profile-resolver.test.ts b/src/dispatcher/__tests__/profile-resolver.test.ts new file mode 100644 index 00000000..3200f9e3 --- /dev/null +++ b/src/dispatcher/__tests__/profile-resolver.test.ts @@ -0,0 +1,387 @@ +/** + * Tests for resolveProfileAndTarget() — Phase C extraction. + * + * The function has deep dynamic-import dependencies (ProfileDetector, targets, + * browser config, etc.). Strategy: mock the heavy boundary modules and verify + * the control-flow contract and shape of the returned ResolvedProfile object. + * + * Coverage goals: + * - Default 'claude' profile resolves with null targetAdapter + * - CLIProxy profile (gemini) resolves correctly + * - Settings profile (glm) resolves with settings loaded + compatibility check + * - Unknown profile triggers ProfileError (thrown, caught by main's try/catch) + * - --target droid resolves droid adapter + binary + * - Compatibility check runs for non-settings profiles (duplication preserved) + */ + +import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; +import type { Logger } from '../../services/logging/logger'; + +// ========== Stub Logger ========== + +function makeStubLogger(): Logger { + return { + info: mock(() => {}), + warn: mock(() => {}), + error: mock(() => {}), + debug: mock(() => {}), + stage: mock(() => {}), + } as unknown as Logger; +} + +// ========== Module mocks ========== + +// We need to mock heavy dependencies at the module level before importing the SUT. +// Bun's module mock API replaces modules in the registry for the current test file. + +// Mock ProfileDetector — most tests override detectProfileType per-case +const mockDetectProfileType = mock((_profile: string) => ({ + type: 'default' as const, + name: 'default', + target: undefined, +})); +const mockGetAllProfiles = mock(() => ({ + settings: [] as string[], + cliproxy: [], + cliproxyVariants: [], +})); + +mock.module('../../auth/profile-detector', () => ({ + default: class MockProfileDetector { + detectProfileType = mockDetectProfileType; + getAllProfiles = mockGetAllProfiles; + }, +})); + +// Mock management / registry / context modules (dynamically imported, no-op for these tests) +mock.module('../../management/instance-manager', () => ({ default: class {} })); +mock.module('../../auth/profile-registry', () => ({ default: class {} })); +mock.module('../../auth/account-context', () => ({ + resolveAccountContextPolicy: mock(() => 'default'), + isAccountContextMetadata: mock(() => false), +})); +mock.module('../../auth/profile-continuity-inheritance', () => ({ + resolveProfileContinuityInheritance: mock(() => null), +})); + +// Mock target-resolver +const mockResolveTargetType = mock((_args: string[]) => 'claude' as const); +const mockStripTargetFlag = mock((args: string[]) => args); +mock.module('../../targets/target-resolver', () => ({ + resolveTargetType: mockResolveTargetType, + stripTargetFlag: mockStripTargetFlag, +})); + +// Mock targets registry +const mockGetTarget = mock((_name: string) => null); +const mockEvaluateTargetRuntimeCompatibility = mock(() => ({ supported: true })); +const mockPruneOrphanedModels = mock(async () => {}); +mock.module('../../targets', () => ({ + getTarget: mockGetTarget, + evaluateTargetRuntimeCompatibility: mockEvaluateTargetRuntimeCompatibility, + pruneOrphanedModels: mockPruneOrphanedModels, +})); + +// Mock claude-detector +const mockDetectClaudeCli = mock(() => '/usr/local/bin/claude'); +mock.module('../../utils/claude-detector', () => ({ + detectClaudeCli: mockDetectClaudeCli, +})); + +// Mock ErrorManager +mock.module('../../utils/error-manager', () => ({ + ErrorManager: { showClaudeNotFound: mock(async () => {}) }, +})); + +// Mock config-manager +mock.module('../../utils/config-manager', () => ({ + getSettingsPath: mock((name: string) => `/tmp/.ccs/profiles/${name}/settings.json`), + loadSettings: mock(() => ({ env: {} })), +})); + +// Mock helpers +mock.module('../../utils/helpers', () => ({ + expandPath: mock((p: string) => p), +})); + +// Mock browser config +mock.module('../../config/unified-config-loader', () => ({ + getBrowserConfig: mock(() => ({ + claude: { enabled: false, policy: 'never' }, + codex: { enabled: false, policy: 'never' }, + })), +})); + +// Mock browser utilities +mock.module('../../utils/browser', () => ({ + getEffectiveClaudeBrowserAttachConfig: mock(() => undefined), + resolveBrowserExposure: mock(() => ({ enabled: false })), + getBlockedBrowserOverrideWarning: mock(() => undefined), +})); + +// Mock cliproxy bridge +mock.module('../../api/services/cliproxy-profile-bridge', () => ({ + resolveCliproxyBridgeMetadata: mock(() => undefined), +})); + +// Mock environment-builder (only resolveCodexRuntimeConfigOverrides is called) +mock.module('../environment-builder', () => ({ + resolveCodexRuntimeConfigOverrides: mock(() => [] as string[]), + resolveNativeClaudeLaunchArgs: mock((a: string[]) => a), + refreshUpdateCache: mock(() => {}), + showCachedUpdateNotification: mock(async () => {}), +})); + +// Mock cli-argument-parser (detectProfile + normalization helpers) +const mockDetectProfile = mock((args: string[]) => ({ + profile: args[0] || 'default', + remainingArgs: args.slice(1), +})); +mock.module('../cli-argument-parser', () => ({ + detectProfile: mockDetectProfile, + resolveRuntimeReasoningFlags: mock((args: string[]) => ({ + argsWithoutReasoningFlags: args, + reasoningOverride: undefined, + reasoningSource: undefined, + })), + normalizeCodexRuntimeReasoningOverride: mock(() => undefined), + exitWithRuntimeReasoningFlagError: mock(() => {}), + normalizeNativeClaudeEffortArgs: mock((a: string[]) => a), + shouldNormalizeNativeClaudeEffort: mock(() => false), + bootstrapAndParseEarlyCli: mock(async () => ({ + exitNow: false, + args: [], + browserLaunchOverride: undefined, + })), +})); + +// Mock droid command/reasoning modules +mock.module('../../targets/droid-command-router', () => ({ + DroidCommandRouterError: class DroidCommandRouterError extends Error {}, + routeDroidCommandArgs: mock((args: string[]) => ({ + mode: 'interactive', + argsForDroid: args, + duplicateReasoningDisplays: [], + reasoningSourceDisplay: undefined, + autoPrependedExec: false, + })), +})); +mock.module('../../targets/droid-reasoning-runtime', () => ({ + DroidReasoningFlagError: class DroidReasoningFlagError extends Error { + constructor(msg: string, _flag?: string) { + super(msg); + } + }, +})); + +// ========== Import SUT after mocks are registered ========== + +import { resolveProfileAndTarget } from '../profile-resolver'; + +// ========== Tests ========== + +describe('resolveProfileAndTarget', () => { + let exitSpy: ReturnType; + let consoleErrorSpy: ReturnType; + const stubLogger = makeStubLogger(); + + beforeEach(() => { + exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as () => never); + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + process.env['CI'] = '1'; + process.env['CCS_HOME'] = '/tmp/ccs-test-profile-resolver'; + // Reset relevant mocks to defaults + mockDetectProfileType.mockImplementation((_profile: string) => ({ + type: 'default' as const, + name: 'default', + target: undefined, + })); + mockResolveTargetType.mockImplementation((_args: string[]) => 'claude' as const); + mockStripTargetFlag.mockImplementation((args: string[]) => args); + mockGetTarget.mockImplementation((_name: string) => null); + mockDetectClaudeCli.mockImplementation(() => '/usr/local/bin/claude'); + mockEvaluateTargetRuntimeCompatibility.mockImplementation(() => ({ supported: true })); + }); + + afterEach(() => { + exitSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + delete process.env['CI']; + delete process.env['CCS_HOME']; + }); + + it('resolves default claude profile with null targetAdapter', async () => { + const result = await resolveProfileAndTarget({ + args: [], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + expect(result.resolvedTarget).toBe('claude'); + expect(result.targetAdapter).toBeNull(); + expect(result.claudeCli).toBe('/usr/local/bin/claude'); + expect(result.profileInfo.type).toBe('default'); + expect(result.targetBinaryInfo).toBeNull(); + expect(result.targetRemainingArgs).toEqual([]); + expect(result.nativeClaudeRemainingArgs).toEqual([]); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('resolves gemini cliproxy profile', async () => { + mockDetectProfileType.mockImplementation((_profile: string) => ({ + type: 'cliproxy' as const, + name: 'gemini', + provider: 'gemini', + target: undefined, + })); + mockDetectProfile.mockImplementation((args: string[]) => ({ + profile: 'gemini', + remainingArgs: args.slice(1), + })); + + const result = await resolveProfileAndTarget({ + args: ['gemini'], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + expect(result.profile).toBe('gemini'); + expect(result.profileInfo.type).toBe('cliproxy'); + expect(result.resolvedTarget).toBe('claude'); + expect(result.targetAdapter).toBeNull(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('resolves settings profile (glm) with --target droid and loads settings', async () => { + // Settings loading only runs in the non-claude preflight block (resolvedTarget !== 'claude'). + const mockDroidAdapter = { + displayName: 'Factory Droid', + detectBinary: mock(() => ({ path: '/usr/local/bin/droid', version: '1.0.0' })), + supportsProfileType: mock(() => true), + }; + mockGetTarget.mockImplementation((_name: string) => mockDroidAdapter); + mockResolveTargetType.mockImplementation((_args: string[]) => 'droid' as const); + mockDetectProfileType.mockImplementation((_profile: string) => ({ + type: 'settings' as const, + name: 'glm', + settingsPath: undefined, + target: undefined, + })); + mockDetectProfile.mockImplementation((args: string[]) => ({ + profile: 'glm', + remainingArgs: args.slice(1), + })); + + const result = await resolveProfileAndTarget({ + args: ['glm', '--target', 'droid'], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + expect(result.profile).toBe('glm'); + expect(result.profileInfo.type).toBe('settings'); + expect(result.resolvedTarget).toBe('droid'); + // Settings are loaded inside the non-claude preflight block + expect(result.resolvedSettingsPath).toBeDefined(); + expect(result.resolvedSettings).toBeDefined(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('calls process.exit(1) for unknown profile when claude CLI not found', async () => { + // Simulate claude not found for claude target + mockDetectClaudeCli.mockImplementation(() => null); + + await resolveProfileAndTarget({ + args: [], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('resolves --target droid with droid adapter and binary', async () => { + const mockBinaryInfo = { path: '/usr/local/bin/droid', version: '1.0.0' }; + const mockDroidAdapter = { + displayName: 'Factory Droid', + detectBinary: mock(() => mockBinaryInfo), + supportsProfileType: mock(() => true), + }; + mockGetTarget.mockImplementation((_name: string) => mockDroidAdapter); + mockResolveTargetType.mockImplementation((_args: string[]) => 'droid' as const); + mockDetectProfileType.mockImplementation((_profile: string) => ({ + type: 'settings' as const, + name: 'glm', + settingsPath: undefined, + target: undefined, + })); + mockDetectProfile.mockImplementation((args: string[]) => ({ + profile: 'glm', + remainingArgs: args.slice(1), + })); + + const result = await resolveProfileAndTarget({ + args: ['glm', '--target', 'droid'], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + expect(result.resolvedTarget).toBe('droid'); + expect(result.targetAdapter).toBe(mockDroidAdapter); + expect(result.targetBinaryInfo).toBe(mockBinaryInfo); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('calls process.exit(1) when droid binary not found', async () => { + const mockDroidAdapter = { + displayName: 'Factory Droid', + detectBinary: mock(() => null), + supportsProfileType: mock(() => true), + }; + mockGetTarget.mockImplementation((_name: string) => mockDroidAdapter); + mockResolveTargetType.mockImplementation((_args: string[]) => 'droid' as const); + mockDetectProfileType.mockImplementation((_profile: string) => ({ + type: 'default' as const, + name: 'default', + target: undefined, + })); + + await resolveProfileAndTarget({ + args: ['--target', 'droid'], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('runs compatibility check for non-settings profiles (duplication preserved)', async () => { + const mockDroidAdapter = { + displayName: 'Factory Droid', + detectBinary: mock(() => ({ path: '/usr/local/bin/droid', version: '1.0.0' })), + supportsProfileType: mock(() => true), + }; + mockGetTarget.mockImplementation((_name: string) => mockDroidAdapter); + mockResolveTargetType.mockImplementation((_args: string[]) => 'droid' as const); + mockDetectProfileType.mockImplementation((_profile: string) => ({ + type: 'cliproxy' as const, + name: 'gemini', + provider: 'gemini', + target: undefined, + })); + mockDetectProfile.mockImplementation((args: string[]) => ({ + profile: 'gemini', + remainingArgs: args.slice(1), + })); + + await resolveProfileAndTarget({ + args: ['gemini', '--target', 'droid'], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + // Compatibility check must have been called (the duplication from Phase C) + expect(mockEvaluateTargetRuntimeCompatibility).toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/dispatcher/profile-resolver.ts b/src/dispatcher/profile-resolver.ts new file mode 100644 index 00000000..0a692667 --- /dev/null +++ b/src/dispatcher/profile-resolver.ts @@ -0,0 +1,358 @@ +/** + * Profile and target resolution — Phase C extraction from src/ccs.ts. + * + * Extracted from main() lines ~136–313 (after Phase B pre-dispatch). + * Responsible for: dynamic imports of auth modules, profile detection, target type + * resolution, Claude CLI detection, target adapter resolution, compatibility preflight + * checks (preserved with duplication per plan risk note 5), binary detection, and + * per-target argument normalization (droid routing + reasoning flag stripping, + * codex reasoning normalization, native Claude effort normalization). + * + * Outputs a ResolvedProfile context object consumed by Phase D (environment-builder + * argument normalization) and Phase E (per-profile dispatch flows). + */ + +import { detectClaudeCli } from '../utils/claude-detector'; +import { getSettingsPath, loadSettings } from '../utils/config-manager'; +import { expandPath } from '../utils/helpers'; +import { fail, info, warn } from '../utils/ui'; +import { ErrorManager } from '../utils/error-manager'; +import { getBrowserConfig } from '../config/unified-config-loader'; +import { + getEffectiveClaudeBrowserAttachConfig, + resolveBrowserExposure, + getBlockedBrowserOverrideWarning, +} from '../utils/browser'; +import { getTarget, evaluateTargetRuntimeCompatibility, pruneOrphanedModels } from '../targets'; +import { resolveTargetType, stripTargetFlag } from '../targets/target-resolver'; +import { DroidReasoningFlagError } from '../targets/droid-reasoning-runtime'; +import { DroidCommandRouterError, routeDroidCommandArgs } from '../targets/droid-command-router'; +import { resolveCliproxyBridgeMetadata } from '../api/services/cliproxy-profile-bridge'; +import { resolveCodexRuntimeConfigOverrides } from './environment-builder'; +import { + detectProfile, + resolveRuntimeReasoningFlags, + normalizeCodexRuntimeReasoningOverride, + exitWithRuntimeReasoningFlagError, + normalizeNativeClaudeEffortArgs, + shouldNormalizeNativeClaudeEffort, +} from './cli-argument-parser'; +import type ProfileDetector from '../auth/profile-detector'; +import type { ProfileDetectionResult } from '../auth/profile-detector'; +import type { BrowserLaunchOverride } from '../utils/browser'; +import type { ResolvedBrowserExposure } from '../utils/browser/browser-policy'; +import type { Logger } from '../services/logging/logger'; +import type { TargetAdapter, TargetBinaryInfo } from '../targets'; + +// ========== Interfaces ========== + +export interface ProfileResolutionContext { + args: string[]; + browserLaunchOverride: BrowserLaunchOverride | undefined; + cliLogger: Logger; +} + +export interface ResolvedProfile { + /** Detected profile name (first positional arg or 'default') */ + profile: string; + /** Args remaining after stripping profile token and --target flags */ + remainingArgs: string[]; + profileInfo: ProfileDetectionResult; + resolvedTarget: ReturnType; + claudeCli: string; + targetAdapter: TargetAdapter | null; + targetBinaryInfo: TargetBinaryInfo | null; + resolvedSettingsPath: string | undefined; + resolvedSettings: ReturnType | undefined; + resolvedCliproxyBridge: ReturnType | undefined; + /** Per-target normalized args (droid/codex routing applied) */ + targetRemainingArgs: string[]; + /** Claude-specific args with effort normalization applied */ + nativeClaudeRemainingArgs: string[]; + runtimeReasoningOverride: string | number | undefined; + codexRuntimeConfigOverrides: string[]; + claudeBrowserExposure: ResolvedBrowserExposure | undefined; + codexBrowserExposure: ResolvedBrowserExposure | undefined; + claudeAttachConfig: ReturnType | undefined; + /** ProfileDetector instance — Phase E account/settings flows need getAllProfiles() */ + detector: InstanceType; +} + +// ========== Profile and Target Resolver ========== + +/** + * Resolve profile detection, target type, adapter, binary, and per-target arg normalization. + * + * Throws ProfileError (caught by main() try/catch) for unknown profiles. + * Calls process.exit(1) for hard-stop conditions (adapter not found, binary not found, etc.). + * + * NOTE: The compatibility preflight block for non-settings profiles (else branch) is + * INTENTIONALLY duplicated between here (Phase C) and the settings flow (Phase E). + * Per plan risk note 5, do NOT dedupe in this PR. + */ +export async function resolveProfileAndTarget( + ctx: ProfileResolutionContext +): Promise { + const { args, browserLaunchOverride } = ctx; + + // Dynamic imports — preserved at original call sites for latency/circular-dep avoidance. + // InstanceManager, ProfileRegistry, AccountContext, ProfileContinuity are loaded here + // to match original ordering; their resolved values flow into Phase E via the detector + // instance or are re-imported inside flow handlers. + const ProfileDetectorModule = await import('../auth/profile-detector'); + const ProfileDetectorClass = ProfileDetectorModule.default; + await import('../management/instance-manager'); + await import('../auth/profile-registry'); + await import('../auth/account-context'); + await import('../auth/profile-continuity-inheritance'); + + const detector = new ProfileDetectorClass(); + + // Detect profile (strip --target flags before profile detection) + const cleanArgs = stripTargetFlag(args); + const { profile, remainingArgs } = detectProfile(cleanArgs); + const profileInfo: ProfileDetectionResult = detector.detectProfileType(profile); + + let resolvedTarget: ReturnType; + try { + resolvedTarget = resolveTargetType( + args, + profileInfo.target ? { target: profileInfo.target } : undefined + ); + } catch (error) { + console.error(fail((error as Error).message)); + process.exit(1); + // Unreachable; needed so TS knows resolvedTarget is always assigned below + throw error; + } + + // Detect Claude CLI (needed for claude target and all CLIProxy-derived flows) + const claudeCliRaw = detectClaudeCli(); + if (resolvedTarget === 'claude' && !claudeCliRaw) { + await ErrorManager.showClaudeNotFound(); + process.exit(1); + } + const claudeCli = claudeCliRaw || ''; + + // Resolve non-claude target adapter once. + const targetAdapter: TargetAdapter | null = + resolvedTarget !== 'claude' ? (getTarget(resolvedTarget) ?? null) : null; + let resolvedSettingsPath: string | undefined; + let resolvedSettings: ReturnType | undefined; + let resolvedCliproxyBridge: ReturnType | undefined; + + // Preflight unsupported profile/target combinations BEFORE binary detection, + // so users get the most actionable error even when the target CLI is not installed. + if (resolvedTarget !== 'claude') { + if (!targetAdapter) { + console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); + process.exit(1); + } + + if (profileInfo.type === 'settings') { + resolvedSettingsPath = profileInfo.settingsPath + ? expandPath(profileInfo.settingsPath) + : getSettingsPath(profileInfo.name); + resolvedSettings = loadSettings(resolvedSettingsPath); + resolvedCliproxyBridge = resolveCliproxyBridgeMetadata(resolvedSettings); + const compatibility = evaluateTargetRuntimeCompatibility({ + target: resolvedTarget, + profileType: profileInfo.type, + cliproxyBridgeProvider: resolvedCliproxyBridge?.provider ?? null, + }); + if (!compatibility.supported) { + console.error( + fail( + compatibility.reason || `${targetAdapter.displayName} does not support this profile.` + ) + ); + if (compatibility.suggestion) { + console.error(info(compatibility.suggestion)); + } + process.exit(1); + } + } else { + // NOTE: Intentional duplication — Phase E settings flow re-runs this check post-load. + // Per plan risk note 5, preserve verbatim; do NOT dedupe. + const compatibility = evaluateTargetRuntimeCompatibility({ + target: resolvedTarget, + profileType: profileInfo.type, + cliproxyProvider: profileInfo.type === 'cliproxy' ? profileInfo.provider : undefined, + isComposite: profileInfo.type === 'cliproxy' ? Boolean(profileInfo.isComposite) : undefined, + }); + if (!compatibility.supported) { + console.error( + fail( + compatibility.reason || `${targetAdapter.displayName} does not support this profile.` + ) + ); + if (compatibility.suggestion) { + console.error(info(compatibility.suggestion)); + } + process.exit(1); + } + } + + if (profileInfo.type === 'default') { + if (!targetAdapter.supportsProfileType('default')) { + console.error(fail(`${targetAdapter.displayName} does not support default profile mode`)); + process.exit(1); + } + + // For default mode, Droid requires explicit credentials from environment. + if (resolvedTarget === 'droid') { + const baseUrl = process.env['ANTHROPIC_BASE_URL'] || ''; + const apiKey = process.env['ANTHROPIC_AUTH_TOKEN'] || ''; + if (!baseUrl.trim() || !apiKey.trim()) { + console.error( + fail( + `${targetAdapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN` + ) + ); + console.error(info('Use a settings-based profile instead: ccs glm --target droid')); + process.exit(1); + } + } + } + } + + // For non-claude targets, verify target binary exists once and pass it through. + const targetBinaryInfo: TargetBinaryInfo | null = targetAdapter?.detectBinary() ?? null; + const browserConfig = getBrowserConfig(); + const claudeAttachConfig = + resolvedTarget === 'claude' ? getEffectiveClaudeBrowserAttachConfig(browserConfig) : undefined; + const codexRuntimeConfigOverrides = resolveCodexRuntimeConfigOverrides( + resolvedTarget, + browserLaunchOverride + ); + const claudeBrowserExposure: ResolvedBrowserExposure | undefined = + resolvedTarget === 'claude' + ? resolveBrowserExposure( + { + enabled: claudeAttachConfig?.enabled ?? browserConfig.claude.enabled, + policy: browserConfig.claude.policy, + }, + browserLaunchOverride + ) + : undefined; + const codexBrowserExposure: ResolvedBrowserExposure | undefined = + resolvedTarget === 'codex' + ? resolveBrowserExposure(browserConfig.codex, browserLaunchOverride) + : undefined; + const blockedBrowserOverrideWarning = + resolvedTarget === 'claude' && claudeBrowserExposure + ? getBlockedBrowserOverrideWarning('Claude Browser Attach', claudeBrowserExposure) + : resolvedTarget === 'codex' && codexBrowserExposure + ? getBlockedBrowserOverrideWarning('Codex Browser Tools', codexBrowserExposure) + : undefined; + if (blockedBrowserOverrideWarning) { + console.error(warn(blockedBrowserOverrideWarning)); + } + if (resolvedTarget !== 'claude' && !targetBinaryInfo) { + const displayName = targetAdapter?.displayName || resolvedTarget; + console.error(fail(`${displayName} CLI not found.`)); + if (resolvedTarget === 'droid') { + console.error(info('Install: npm i -g @factory/cli')); + } else if (resolvedTarget === 'codex') { + console.error(info('Install a recent @openai/codex build, then retry.')); + } + process.exit(1); + } + + // Best-effort: prune stale Droid model entries at runtime so settings.json stays clean. + if (resolvedTarget === 'droid') { + try { + const allProfiles = detector.getAllProfiles(); + const activeProfiles = allProfiles.settings.filter((name) => /^[a-zA-Z0-9._-]+$/.test(name)); + await pruneOrphanedModels(activeProfiles); + } catch (error) { + console.error(warn(`[!] Droid prune skipped: ${(error as Error).message}`)); + } + } + + // Per-target argument normalization + let targetRemainingArgs = remainingArgs; + let runtimeReasoningOverride: string | number | undefined; + let nativeClaudeRemainingArgs = remainingArgs; + + if (resolvedTarget === 'droid') { + try { + const droidRoute = routeDroidCommandArgs(remainingArgs); + targetRemainingArgs = droidRoute.argsForDroid; + + if (droidRoute.mode === 'interactive') { + const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env['CCS_THINKING']); + targetRemainingArgs = runtime.argsWithoutReasoningFlags; + runtimeReasoningOverride = runtime.reasoningOverride; + } else { + if (droidRoute.duplicateReasoningDisplays.length > 0) { + console.error( + warn( + `[!] Multiple reasoning flags detected. Using first occurrence: ${droidRoute.reasoningSourceDisplay || ''}` + ) + ); + } + if (droidRoute.autoPrependedExec && process.stdout.isTTY) { + console.error( + info('Detected Droid exec-only flags. Routing as: droid exec [prompt]') + ); + } + } + } catch (error) { + if (error instanceof DroidReasoningFlagError || error instanceof DroidCommandRouterError) { + exitWithRuntimeReasoningFlagError(error.message, { + codexAliasLevels: 'minimal|low|medium|high|xhigh', + includeDroidExecExample: true, + }); + } + throw error; + } + } else if (resolvedTarget === 'codex') { + try { + const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env['CCS_THINKING']); + targetRemainingArgs = runtime.argsWithoutReasoningFlags; + const normalizedReasoning = normalizeCodexRuntimeReasoningOverride(runtime.reasoningOverride); + if (runtime.reasoningOverride !== undefined && !normalizedReasoning) { + if (runtime.reasoningSource === 'flag') { + throw new DroidReasoningFlagError( + 'Codex target supports reasoning levels only: minimal, low, medium, high, xhigh.', + '--effort' + ); + } + runtimeReasoningOverride = undefined; + } else { + runtimeReasoningOverride = normalizedReasoning; + } + } catch (error) { + if (error instanceof DroidReasoningFlagError) { + exitWithRuntimeReasoningFlagError(error.message, { + codexAliasLevels: 'minimal|low|medium|high|xhigh', + }); + } + throw error; + } + } else if (resolvedTarget === 'claude' && shouldNormalizeNativeClaudeEffort(profileInfo.type)) { + nativeClaudeRemainingArgs = normalizeNativeClaudeEffortArgs(remainingArgs); + } + + return { + profile, + remainingArgs, + profileInfo, + resolvedTarget, + claudeCli, + targetAdapter, + targetBinaryInfo, + resolvedSettingsPath, + resolvedSettings, + resolvedCliproxyBridge, + targetRemainingArgs, + nativeClaudeRemainingArgs, + runtimeReasoningOverride, + codexRuntimeConfigOverrides, + claudeBrowserExposure, + codexBrowserExposure, + claudeAttachConfig, + detector, + }; +} From 0910a75850c149f29493d4a5db0c09486a8ac443 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 2 May 2026 23:52:25 -0400 Subject: [PATCH 4/4] refactor(dispatcher): extract per-profile flows from ccs.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phases 5+6 of #1165. Final big extraction: collapses the 6-branch profileInfo.type switch in main() into a single dispatchProfile call. - src/dispatcher/dispatcher-context.ts (40 LOC): ProfileDispatchContext type - src/dispatcher/flows/cliproxy-flow.ts (211 LOC) - src/dispatcher/flows/copilot-flow.ts (66 LOC) - src/dispatcher/flows/cursor-flow.ts (54 LOC) - src/dispatcher/flows/settings-flow.ts (400 LOC) - src/dispatcher/flows/settings-image-analysis-prep.ts (126 LOC): split out per plan to keep settings-flow control flow whole - src/dispatcher/flows/account-flow.ts (63 LOC) - src/dispatcher/flows/default-flow.ts (136 LOC) - src/dispatcher/target-executor.ts (+38 LOC): dispatchProfile switch settings-flow stays at 400 LOC — flow control (3 pre-flight blocks + env construction + 2 dispatch paths) is maximally cohesive. Further splitting would fragment dispatch logic per plan note. process.exit parity preserved exactly (cliproxy non-claude path uses process.exitCode=1; return; others use process.exit(1)). ccs.ts: 1040 -> 170 LOC (-870). Full reduction across phases 1-6: 1775 -> 170 (-1605, -90%). main() body now ~100 LOC of awaited phase calls. Behavior unchanged; full suite passes 1824/1824. Refs #1165 --- src/ccs.ts | 906 +----------------- src/dispatcher/dispatcher-context.ts | 40 + src/dispatcher/flows/account-flow.ts | 63 ++ src/dispatcher/flows/cliproxy-flow.ts | 211 ++++ src/dispatcher/flows/copilot-flow.ts | 66 ++ src/dispatcher/flows/cursor-flow.ts | 54 ++ src/dispatcher/flows/default-flow.ts | 136 +++ src/dispatcher/flows/settings-flow.ts | 400 ++++++++ .../flows/settings-image-analysis-prep.ts | 126 +++ src/dispatcher/target-executor.ts | 40 +- 10 files changed, 1153 insertions(+), 889 deletions(-) create mode 100644 src/dispatcher/dispatcher-context.ts create mode 100644 src/dispatcher/flows/account-flow.ts create mode 100644 src/dispatcher/flows/cliproxy-flow.ts create mode 100644 src/dispatcher/flows/copilot-flow.ts create mode 100644 src/dispatcher/flows/cursor-flow.ts create mode 100644 src/dispatcher/flows/default-flow.ts create mode 100644 src/dispatcher/flows/settings-flow.ts create mode 100644 src/dispatcher/flows/settings-image-analysis-prep.ts diff --git a/src/ccs.ts b/src/ccs.ts index d1a27bfc..b1163244 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -1,84 +1,17 @@ import './utils/fetch-proxy-setup'; -import { getSettingsPath, loadSettings } from './utils/config-manager'; -import { expandPath } from './utils/helpers'; -import { - validateGlmKey, - validateMiniMaxKey, - validateAnthropicKey, -} from './utils/api-key-validator'; import { ErrorManager } from './utils/error-manager'; -import { - execClaudeWithCLIProxy, - CLIProxyProvider, - ensureCliproxyService, - isAuthenticated, -} from './cliproxy'; -import { getEffectiveEnvVars, getCompositeEnvVars } from './cliproxy/config/env-builder'; -import { CLIPROXY_DEFAULT_PORT } from './cliproxy/config/port-manager'; -import { - ensureWebSearchMcpOrThrow, - displayWebSearchStatus, - getWebSearchHookEnv, - syncWebSearchMcpToConfigDir, - appendThirdPartyWebSearchToolArgs, - createWebSearchTraceContext, -} from './utils/websearch-manager'; -import { - ensureImageAnalysisMcpOrThrow, - syncImageAnalysisMcpToConfigDir, - appendThirdPartyImageAnalysisToolArgs, -} from './utils/image-analysis'; -import { - appendBrowserToolArgs, - ensureBrowserMcpOrThrow, - resolveOptionalBrowserAttachRuntime, - syncBrowserMcpToConfigDir, -} from './utils/browser'; -import { getGlobalEnvConfig } from './config/unified-config-loader'; -import { - ensureProfileHooks as ensureImageAnalyzerHooks, - removeImageAnalysisProfileHook, -} from './utils/hooks/image-analyzer-profile-hook-injector'; -import { - applyImageAnalysisRuntimeOverrides, - getImageAnalysisHookEnv, - installImageAnalyzerHook, - prepareImageAnalysisFallbackHook, - resolveImageAnalysisRuntimeConnection, - resolveImageAnalysisRuntimeStatus, -} from './utils/hooks'; -import { fail, info, warn } from './utils/ui'; +import { fail } from './utils/ui'; // Import centralized error handling import { handleError, runCleanup } from './errors'; -// Import extracted utility functions -import { execClaude, stripAnthropicRoutingEnv, stripBrowserEnv } from './utils/shell-executor'; -import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation'; -import { createOpenAICompatLaunchSettings } from './utils/openai-compat-launch-settings'; -import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning'; import { createLogger, runWithRequestId } from './services/logging'; // Import target adapter system -import { - registerTarget, - ClaudeAdapter, - DroidAdapter, - CodexAdapter, - evaluateTargetRuntimeCompatibility, - resolveDroidProvider, - type TargetCredentials, -} from './targets'; -import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge'; -import { - buildOpenAICompatProxyEnv, - resolveOpenAICompatProfileConfig, - startOpenAICompatProxy, -} from './proxy'; +import { registerTarget, ClaudeAdapter, DroidAdapter, CodexAdapter } from './targets'; // Import extracted dispatcher modules import { bootstrapAndParseEarlyCli } from './dispatcher/cli-argument-parser'; -import { resolveNativeClaudeLaunchArgs } from './dispatcher/environment-builder'; -import { type ProfileError } from './dispatcher/target-executor'; +import { type ProfileError, dispatchProfile } from './dispatcher/target-executor'; import { runPreDispatchHandlers } from './dispatcher/pre-dispatch'; import { resolveProfileAndTarget } from './dispatcher/profile-resolver'; @@ -113,36 +46,10 @@ async function main(): Promise { } // Phase C: profile + target detection (extracted to dispatcher/profile-resolver.ts) - const { - profile, - remainingArgs, - profileInfo, - resolvedTarget, - claudeCli, - targetAdapter, - targetBinaryInfo, - resolvedSettingsPath: initialResolvedSettingsPath, - resolvedSettings: initialResolvedSettings, - resolvedCliproxyBridge: initialResolvedCliproxyBridge, - targetRemainingArgs: initialTargetRemainingArgs, - nativeClaudeRemainingArgs: initialNativeClaudeRemainingArgs, - runtimeReasoningOverride: initialRuntimeReasoningOverride, - codexRuntimeConfigOverrides, - claudeBrowserExposure, - codexBrowserExposure: _codexBrowserExposure, - claudeAttachConfig, - detector: _detector, - } = await resolveProfileAndTarget({ args, browserLaunchOverride, cliLogger }); + const resolvedProfile = await resolveProfileAndTarget({ args, browserLaunchOverride, cliLogger }); + const { profile, profileInfo, resolvedTarget, nativeClaudeRemainingArgs } = resolvedProfile; - // Re-assign mutable state that Phase E flows may update - const resolvedSettingsPath = initialResolvedSettingsPath; - const resolvedSettings = initialResolvedSettings; - const resolvedCliproxyBridge = initialResolvedCliproxyBridge; - const targetRemainingArgs = initialTargetRemainingArgs; - const nativeClaudeRemainingArgs = initialNativeClaudeRemainingArgs; - const runtimeReasoningOverride = initialRuntimeReasoningOverride; - - // Re-import dynamic modules needed by Phase E flows (preserving original ordering). + // Dynamic imports needed by Phase E flows — preserve original load ordering. const InstanceManagerModule = await import('./management/instance-manager'); const InstanceManager = InstanceManagerModule.default; const ProfileRegistryModule = await import('./auth/profile-registry'); @@ -152,6 +59,16 @@ async function main(): Promise { const ProfileContinuityModule = await import('./auth/profile-continuity-inheritance'); const { resolveProfileContinuityInheritance } = ProfileContinuityModule; + // Build full dispatch context (Phase E) + const dispatchCtx = { + ...resolvedProfile, + InstanceManager, + ProfileRegistry, + resolveAccountContextPolicy, + isAccountContextMetadata, + resolveProfileContinuityInheritance, + }; + try { // Special case: headless delegation (-p/--prompt) // Keep existing behavior for Claude targets only; non-claude targets must continue @@ -166,795 +83,8 @@ async function main(): Promise { } } - if (profileInfo.type === 'cliproxy') { - // CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants - const imageAnalysisMcpReady = - resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true; - if (resolvedTarget === 'claude') { - ensureWebSearchMcpOrThrow(); - } - const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider); - const expandedCliproxySettingsPath = profileInfo.settingsPath - ? expandPath(profileInfo.settingsPath) - : undefined; - if (resolvedTarget === 'claude') { - if (imageAnalysisMcpReady) { - removeImageAnalysisProfileHook(profileInfo.name, expandedCliproxySettingsPath); - } else { - const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); - ensureImageAnalyzerHooks({ - profileName: profileInfo.name, - profileType: profileInfo.type, - cliproxyProvider: provider, - isComposite: profileInfo.isComposite, - settingsPath: expandedCliproxySettingsPath, - sharedHookInstalled: imageAnalysisFallbackHookReady, - }); - } - } - const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles - const variantPort = profileInfo.port; // variant-specific port for isolation - const cliproxyPort = variantPort || CLIPROXY_DEFAULT_PORT; - - if (resolvedTarget !== 'claude') { - const adapter = targetAdapter; - if (!adapter) { - console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); - process.exitCode = 1; - return; - } - if (!adapter.supportsProfileType('cliproxy')) { - console.error(fail(`${adapter.displayName} does not support CLIProxy profiles`)); - process.exitCode = 1; - return; - } - - // Keep CLIProxy management/auth flags on Claude flow only. - const unsupportedCliproxyFlags = [ - '--auth', - '--logout', - '--accounts', - '--add', - '--use', - '--config', - '--headless', - '--paste-callback', - '--port-forward', - '--nickname', - '--kiro-auth-method', - '--kiro-idc-start-url', - '--kiro-idc-region', - '--kiro-idc-flow', - '--backend', - '--proxy-host', - '--proxy-port', - '--proxy-protocol', - '--proxy-auth-token', - '--proxy-timeout', - '--local-proxy', - '--remote-only', - '--no-fallback', - '--allow-self-signed', - '--1m', - '--no-1m', - ]; - const providedUnsupportedFlag = unsupportedCliproxyFlags.find( - (flag) => - targetRemainingArgs.includes(flag) || - targetRemainingArgs.some((arg) => arg.startsWith(`${flag}=`)) - ); - if (providedUnsupportedFlag) { - console.error( - fail( - `${providedUnsupportedFlag} is only supported when running CLIProxy profiles on Claude target` - ) - ); - console.error( - info(`Run with Claude target: ccs ${profileInfo.name} --target claude ...`) - ); - process.exitCode = 1; - return; - } - - // For Droid execution path, require existing OAuth auth and running local proxy. - if (profileInfo.isComposite && profileInfo.compositeTiers) { - const compositeProviders = [ - ...new Set(Object.values(profileInfo.compositeTiers).map((tier) => tier.provider)), - ] as CLIProxyProvider[]; - const missingProvider = compositeProviders.find((p) => !isAuthenticated(p)); - if (missingProvider) { - console.error( - fail(`Missing OAuth auth for composite tier provider: ${missingProvider}`) - ); - console.error(info(`Authenticate first: ccs ${missingProvider} --auth`)); - process.exitCode = 1; - return; - } - } else if (!isAuthenticated(provider)) { - console.error(fail(`No OAuth authentication found for provider: ${provider}`)); - console.error(info(`Authenticate first: ccs ${provider} --auth`)); - process.exitCode = 1; - return; - } - - const ensureServiceResult = await ensureCliproxyService( - cliproxyPort, - targetRemainingArgs.includes('--verbose') || targetRemainingArgs.includes('-v') - ); - if (!ensureServiceResult.started) { - console.error( - fail(ensureServiceResult.error || 'Failed to start local CLIProxy service') - ); - process.exitCode = 1; - return; - } - - const envVars = - profileInfo.isComposite && profileInfo.compositeTiers && profileInfo.compositeDefaultTier - ? getCompositeEnvVars( - profileInfo.compositeTiers, - profileInfo.compositeDefaultTier, - cliproxyPort, - customSettingsPath - ) - : getEffectiveEnvVars(provider, cliproxyPort, customSettingsPath); - - const creds: TargetCredentials = { - profile: profileInfo.name, - baseUrl: envVars['ANTHROPIC_BASE_URL'] || '', - apiKey: envVars['ANTHROPIC_AUTH_TOKEN'] || '', - model: envVars['ANTHROPIC_MODEL'] || undefined, - provider: resolveDroidProvider({ - provider: envVars['CCS_DROID_PROVIDER'] || envVars['DROID_PROVIDER'], - baseUrl: envVars['ANTHROPIC_BASE_URL'], - model: envVars['ANTHROPIC_MODEL'], - }), - reasoningOverride: runtimeReasoningOverride, - runtimeConfigOverrides: codexRuntimeConfigOverrides, - envVars, - }; - - if (!creds.baseUrl || !creds.apiKey) { - console.error( - fail( - `Missing CLIProxy runtime credentials for ${profileInfo.name} (ANTHROPIC_BASE_URL/AUTH_TOKEN)` - ) - ); - console.error( - info('Reconfigure with: ccs config > CLIProxy, or run ccs --config') - ); - process.exitCode = 1; - return; - } - - await adapter.prepareCredentials(creds); - const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs, { - creds, - profileType: profileInfo.type, - binaryInfo: targetBinaryInfo || undefined, - }); - const targetEnv = adapter.buildEnv(creds, profileInfo.type); - adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); - return; - } - - await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, { - customSettingsPath, - port: cliproxyPort, - isComposite: profileInfo.isComposite, - compositeTiers: profileInfo.compositeTiers, - compositeDefaultTier: profileInfo.compositeDefaultTier, - profileName: profileInfo.name, - }); - } else if (profileInfo.type === 'copilot') { - // COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy - ensureWebSearchMcpOrThrow(); - const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); - if (resolvedTarget === 'claude') { - if (imageAnalysisMcpReady) { - removeImageAnalysisProfileHook(profileInfo.name); - } else { - const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); - ensureImageAnalyzerHooks({ - profileName: profileInfo.name, - profileType: profileInfo.type, - sharedHookInstalled: imageAnalysisFallbackHookReady, - }); - } - } - - const { executeCopilotProfile } = await import('./copilot'); - const copilotConfig = profileInfo.copilotConfig; - if (!copilotConfig) { - console.error(fail('Copilot configuration not found')); - process.exit(1); - } - const continuityInheritance = await resolveProfileContinuityInheritance({ - profileName: profileInfo.name, - profileType: profileInfo.type, - target: resolvedTarget, - }); - if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { - console.error( - info( - `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` - ) - ); - } - const exitCode = await executeCopilotProfile( - copilotConfig, - remainingArgs, - continuityInheritance.claudeConfigDir, - claudeCli - ); - process.exit(exitCode); - } else if (profileInfo.type === 'cursor') { - // CURSOR FLOW: local Cursor daemon profile - ensureWebSearchMcpOrThrow(); - installImageAnalyzerHook(); - ensureImageAnalyzerHooks({ - profileName: profileInfo.name, - profileType: profileInfo.type, - }); - - const { executeCursorProfile } = await import('./cursor'); - const cursorConfig = profileInfo.cursorConfig; - if (!cursorConfig) { - console.error(fail('Cursor configuration not found')); - process.exit(1); - } - const continuityInheritance = await resolveProfileContinuityInheritance({ - profileName: profileInfo.name, - profileType: profileInfo.type, - target: resolvedTarget, - }); - if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { - console.error( - info( - `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` - ) - ); - } - const exitCode = await executeCursorProfile( - cursorConfig, - remainingArgs, - continuityInheritance.claudeConfigDir, - claudeCli - ); - process.exit(exitCode); - } else if (profileInfo.type === 'settings') { - // Settings-based profiles (glm, glmt) are third-party providers - const imageAnalysisMcpReady = - resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true; - const browserAttachRuntime = - resolvedTarget === 'claude' && - claudeBrowserExposure?.exposeForLaunch && - claudeAttachConfig?.enabled - ? await resolveOptionalBrowserAttachRuntime(claudeAttachConfig) - : undefined; - const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; - if (browserAttachRuntime?.warning) { - process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`); - } - if (resolvedTarget === 'claude') { - ensureWebSearchMcpOrThrow(); - if (browserRuntimeEnv) { - ensureBrowserMcpOrThrow(); - } - } - - // Display WebSearch status (single line, equilibrium UX) - displayWebSearchStatus(); - - const continuityInheritance = - resolvedTarget === 'claude' - ? await resolveProfileContinuityInheritance({ - profileName: profileInfo.name, - profileType: profileInfo.type, - target: resolvedTarget, - }) - : {}; - if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { - console.error( - info( - `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` - ) - ); - } - const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir; - syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); - syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); - if ( - browserRuntimeEnv && - inheritedClaudeConfigDir && - !syncBrowserMcpToConfigDir(inheritedClaudeConfigDir) - ) { - throw new Error( - 'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.' - ); - } - const expandedSettingsPath = - resolvedSettingsPath ?? - (profileInfo.settingsPath - ? expandPath(profileInfo.settingsPath) - : getSettingsPath(profileInfo.name)); - const settings = resolvedSettings ?? loadSettings(expandedSettingsPath); - const cliproxyBridge = resolvedCliproxyBridge ?? resolveCliproxyBridgeMetadata(settings); - - let imageAnalysisFallbackHookReady: boolean | undefined; - if (resolvedTarget === 'claude') { - if (imageAnalysisMcpReady) { - removeImageAnalysisProfileHook(profileInfo.name, expandedSettingsPath); - } else { - imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); - ensureImageAnalyzerHooks({ - profileName: profileInfo.name, - profileType: profileInfo.type, - settingsPath: expandedSettingsPath, - settings, - cliproxyBridge, - sharedHookInstalled: imageAnalysisFallbackHookReady, - }); - } - } - if (resolvedTarget !== 'claude') { - const compatibility = evaluateTargetRuntimeCompatibility({ - target: resolvedTarget, - profileType: profileInfo.type, - cliproxyBridgeProvider: cliproxyBridge?.provider ?? null, - }); - if (!compatibility.supported) { - console.error( - fail( - compatibility.reason || - `${targetAdapter?.displayName || resolvedTarget} does not support this profile.` - ) - ); - if (compatibility.suggestion) { - console.error(info(compatibility.suggestion)); - } - process.exit(1); - } - } - const rawSettingsEnv = profileInfo.env ?? settings.env ?? {}; - const isDeprecatedGlmtProfile = isDeprecatedGlmtProfileName(profileInfo.name); - const glmtNormalization = isDeprecatedGlmtProfile - ? normalizeDeprecatedGlmtEnv(rawSettingsEnv) - : null; - const settingsEnv = glmtNormalization?.env ?? rawSettingsEnv; - - if (glmtNormalization) { - for (const message of glmtNormalization.warnings) { - console.error(warn(message)); - } - } - - // Pre-flight validation for Z.AI-compatible profiles. - if (profileInfo.name === 'glm' || isDeprecatedGlmtProfile) { - const apiKey = settingsEnv['ANTHROPIC_AUTH_TOKEN']; - - if (apiKey) { - const validation = await validateGlmKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']); - - if (!validation.valid) { - console.error(''); - console.error(fail(validation.error || 'API key validation failed')); - if (validation.suggestion) { - console.error(''); - console.error(validation.suggestion); - } - console.error(''); - console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs glm "prompt"')); - process.exit(1); - } - } - } - - if (profileInfo.name === 'mm') { - const apiKey = settingsEnv['ANTHROPIC_AUTH_TOKEN']; - - if (apiKey) { - const validation = await validateMiniMaxKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']); - - if (!validation.valid) { - console.error(''); - console.error(fail(validation.error || 'API key validation failed')); - if (validation.suggestion) { - console.error(''); - console.error(validation.suggestion); - } - console.error(''); - console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs mm "prompt"')); - process.exit(1); - } - } - } - - // Pre-flight validation for Anthropic direct profiles (ANTHROPIC_API_KEY + no BASE_URL) - { - const anthropicApiKey = settingsEnv['ANTHROPIC_API_KEY']; - const hasBaseUrl = !!settingsEnv['ANTHROPIC_BASE_URL']; - if (anthropicApiKey && !hasBaseUrl) { - const validation = await validateAnthropicKey(anthropicApiKey); - if (!validation.valid) { - console.error(''); - console.error(fail(validation.error || 'API key validation failed')); - if (validation.suggestion) { - console.error(''); - console.error(validation.suggestion); - } - console.error(''); - console.error( - info(`To skip validation: CCS_SKIP_PREFLIGHT=1 ccs ${profileInfo.name} "prompt"`) - ); - process.exit(1); - } - } - } - - const webSearchEnv = getWebSearchHookEnv(); - const imageAnalysisStatus = await resolveImageAnalysisRuntimeStatus({ - profileName: profileInfo.name, - profileType: profileInfo.type, - settings, - cliproxyBridge, - sharedHookInstalled: imageAnalysisFallbackHookReady, - }); - const runtimeConnection = resolveImageAnalysisRuntimeConnection(); - let imageAnalysisEnv = getImageAnalysisHookEnv({ - profileName: profileInfo.name, - profileType: profileInfo.type, - settings, - cliproxyBridge, - }); - imageAnalysisEnv = applyImageAnalysisRuntimeOverrides(imageAnalysisEnv, { - backendId: imageAnalysisStatus.backendId, - model: imageAnalysisStatus.model, - runtimePath: imageAnalysisStatus.runtimePath, - baseUrl: runtimeConnection.baseUrl, - apiKey: runtimeConnection.apiKey, - allowSelfSigned: runtimeConnection.allowSelfSigned, - }); - imageAnalysisEnv = { - ...imageAnalysisEnv, - CCS_IMAGE_ANALYSIS_SKIP_HOOK: - resolvedTarget === 'claude' && imageAnalysisMcpReady ? '1' : '0', - }; - - const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER']; - if ( - resolvedTarget === 'claude' && - imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' && - imageAnalysisProvider - ) { - const verboseProxyLaunch = - remainingArgs.includes('--verbose') || - remainingArgs.includes('-v') || - targetRemainingArgs.includes('--verbose') || - targetRemainingArgs.includes('-v'); - - if (imageAnalysisStatus.effectiveRuntimeMode === 'native-read') { - console.error( - info( - `${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This session will use native Read.` - ) - ); - imageAnalysisEnv = { - ...imageAnalysisEnv, - CCS_CURRENT_PROVIDER: '', - CCS_IMAGE_ANALYSIS_SKIP: '1', - CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '', - CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: '', - CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: '', - CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED: '0', - }; - } else if (imageAnalysisStatus.proxyReadiness === 'stopped') { - const ensureServiceResult = await ensureCliproxyService( - CLIPROXY_DEFAULT_PORT, - verboseProxyLaunch - ); - if (!ensureServiceResult.started) { - console.error( - warn( - `Image analysis via ${imageAnalysisProvider} is unavailable because CCS could not start the local CLIProxy service. This session will use native Read.` - ) - ); - imageAnalysisEnv = { - ...imageAnalysisEnv, - CCS_CURRENT_PROVIDER: '', - CCS_IMAGE_ANALYSIS_SKIP: '1', - }; - } - } - } - // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles - const globalEnvConfig = getGlobalEnvConfig(); - const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; - - // Log global env injection for visibility (debug mode only) - if (globalEnvConfig.enabled && Object.keys(globalEnv).length > 0 && process.env.CCS_DEBUG) { - const envNames = Object.keys(globalEnv).join(', '); - console.error(info(`Global env: ${envNames}`)); - } - - // For Claude target launches that already pass `--settings`, keep runtime - // env free of ANTHROPIC routing/auth while preserving non-routing profile - // env so nested Team/subagent sessions can still inherit model intent and - // other profile-scoped runtime flags. - const settingsRuntimeEnv = stripBrowserEnv({ ...globalEnv, ...settingsEnv }); - const claudeRuntimeEnvVars: NodeJS.ProcessEnv = { - ...stripAnthropicRoutingEnv(settingsRuntimeEnv), - ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), - ...webSearchEnv, - ...imageAnalysisEnv, - ...(browserRuntimeEnv || {}), - CCS_PROFILE_TYPE: 'settings', - CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1', - }; - - // Non-Claude targets still need effective credentials injected directly. - const envVars: NodeJS.ProcessEnv = { - ...settingsRuntimeEnv, - ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), - ...webSearchEnv, - ...imageAnalysisEnv, - ...(browserRuntimeEnv || {}), - CCS_PROFILE_TYPE: 'settings', - }; - - // Dispatch through target adapter for non-claude targets - if (resolvedTarget !== 'claude') { - const adapter = targetAdapter; - if (!adapter) { - console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); - process.exit(1); - } - const directAnthropicBaseUrl = - settingsEnv['ANTHROPIC_BASE_URL'] || - (settingsEnv['ANTHROPIC_API_KEY'] ? 'https://api.anthropic.com' : ''); - const creds: TargetCredentials = { - profile: profileInfo.name, - baseUrl: directAnthropicBaseUrl, - apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || settingsEnv['ANTHROPIC_API_KEY'] || '', - model: settingsEnv['ANTHROPIC_MODEL'], - provider: resolveDroidProvider({ - provider: settingsEnv['CCS_DROID_PROVIDER'] || settingsEnv['DROID_PROVIDER'], - baseUrl: directAnthropicBaseUrl, - model: settingsEnv['ANTHROPIC_MODEL'], - }), - reasoningOverride: runtimeReasoningOverride, - runtimeConfigOverrides: codexRuntimeConfigOverrides, - envVars, - }; - await adapter.prepareCredentials(creds); - const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs, { - creds, - profileType: profileInfo.type, - binaryInfo: targetBinaryInfo || undefined, - }); - const targetEnv = adapter.buildEnv(creds, profileInfo.type); - adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); - return; - } - - const imageAnalysisArgs = imageAnalysisMcpReady - ? appendThirdPartyImageAnalysisToolArgs(nativeClaudeRemainingArgs) - : nativeClaudeRemainingArgs; - const browserArgs = browserRuntimeEnv - ? appendBrowserToolArgs(imageAnalysisArgs) - : imageAnalysisArgs; - const openAICompatProfile = resolveOpenAICompatProfileConfig( - profileInfo.name, - expandedSettingsPath, - settingsEnv - ); - if (openAICompatProfile) { - const proxyStart = await startOpenAICompatProxy(openAICompatProfile, { - insecure: openAICompatProfile.insecure, - }); - if (!proxyStart.success) { - console.error(fail(proxyStart.error || 'Failed to start local OpenAI-compatible proxy')); - process.exit(1); - } - - console.error( - info( - `Using local OpenAI-compatible proxy for "${profileInfo.name}" on port ${proxyStart.port}` - ) - ); - - const proxyEnv = { - ...envVars, - ...buildOpenAICompatProxyEnv( - openAICompatProfile, - proxyStart.port, - proxyStart.authToken || '', - inheritedClaudeConfigDir - ), - }; - delete proxyEnv.ANTHROPIC_API_KEY; - const launchSettings = createOpenAICompatLaunchSettings(expandedSettingsPath, settings); - - const launchArgs = [ - '--settings', - launchSettings.settingsPath, - ...appendThirdPartyWebSearchToolArgs(browserArgs), - ]; - const traceEnv = createWebSearchTraceContext({ - launcher: 'ccs.settings-profile.proxy', - args: launchArgs, - profile: profileInfo.name, - profileType: profileInfo.type, - settingsPath: expandedSettingsPath, - }); - - execClaude(claudeCli, launchArgs, { ...proxyEnv, ...traceEnv }, launchSettings.cleanup); - return; - } - const launchArgs = [ - '--settings', - expandedSettingsPath, - ...appendThirdPartyWebSearchToolArgs(browserArgs), - ]; - const traceEnv = createWebSearchTraceContext({ - launcher: 'ccs.settings-profile', - args: launchArgs, - profile: profileInfo.name, - profileType: profileInfo.type, - settingsPath: expandedSettingsPath, - }); - - execClaude(claudeCli, launchArgs, { ...claudeRuntimeEnvVars, ...traceEnv }); - } else if (profileInfo.type === 'account') { - // NEW FLOW: Account-based profile (work, personal) - // All platforms: Use instance isolation with CLAUDE_CONFIG_DIR - const registry = new ProfileRegistry(); - const instanceMgr = new InstanceManager(); - const accountMetadata = isAccountContextMetadata(profileInfo.profile) - ? profileInfo.profile - : undefined; - const isBareProfile = - typeof profileInfo.profile === 'object' && - profileInfo.profile !== null && - (profileInfo.profile as { bare?: unknown }).bare === true; - const contextPolicy = resolveAccountContextPolicy(accountMetadata); - - // Ensure instance exists (lazy init if needed) - const instancePath = await instanceMgr.ensureInstance(profileInfo.name, contextPolicy, { - bare: isBareProfile, - }); - - // Update last_used timestamp (check unified config first, fallback to legacy) - if (registry.hasAccountUnified(profileInfo.name)) { - registry.touchAccountUnified(profileInfo.name); - } else { - registry.touchProfile(profileInfo.name); - } - - // Execute Claude with instance isolation - // Skip WebSearch hook - account profiles use native server-side WebSearch - // Skip Image Analyzer hook - account profiles have native vision support - const envVars: NodeJS.ProcessEnv = { - CLAUDE_CONFIG_DIR: instancePath, - CCS_PROFILE_TYPE: 'account', - CCS_WEBSEARCH_SKIP: '1', - CCS_IMAGE_ANALYSIS_SKIP: '1', - }; - await maybeWarnAboutResumeLaneMismatch( - profileInfo.name, - instancePath, - nativeClaudeRemainingArgs - ); - const launchArgs = resolveNativeClaudeLaunchArgs( - nativeClaudeRemainingArgs, - 'account', - instancePath - ); - execClaude(claudeCli, launchArgs, envVars); - } else { - // DEFAULT: No profile configured, use Claude's own defaults - // Skip WebSearch hook - native Claude has server-side WebSearch - // Skip Image Analyzer hook - native Claude has native vision support - const envVars: NodeJS.ProcessEnv = { - CCS_PROFILE_TYPE: 'default', - CCS_WEBSEARCH_SKIP: '1', - CCS_IMAGE_ANALYSIS_SKIP: '1', - }; - const browserAttachRuntime = - resolvedTarget === 'claude' && - claudeBrowserExposure?.exposeForLaunch && - claudeAttachConfig?.enabled - ? await resolveOptionalBrowserAttachRuntime(claudeAttachConfig) - : undefined; - const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; - if (browserAttachRuntime?.warning) { - process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`); - } - - if (resolvedTarget === 'claude') { - if (browserRuntimeEnv) { - ensureBrowserMcpOrThrow(); - Object.assign(envVars, browserRuntimeEnv); - } - const defaultContinuityInheritance = await resolveProfileContinuityInheritance({ - profileName: profileInfo.name, - profileType: profileInfo.type, - target: resolvedTarget, - }); - if (defaultContinuityInheritance.sourceAccount && process.env.CCS_DEBUG) { - console.error( - info( - `Continuity inheritance active: profile "${profileInfo.name}" -> account "${defaultContinuityInheritance.sourceAccount}"` - ) - ); - } - if (defaultContinuityInheritance.claudeConfigDir) { - envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir; - if ( - browserRuntimeEnv && - !syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir) - ) { - throw new Error( - 'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.' - ); - } - } - } - - // Dispatch through target adapter for non-claude targets - if (resolvedTarget !== 'claude') { - const adapter = targetAdapter; - if (!adapter) { - console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); - process.exit(1); - } - if (!adapter.supportsProfileType('default')) { - console.error(fail(`${adapter.displayName} does not support default profile mode`)); - process.exit(1); - } - const creds: TargetCredentials = { - profile: 'default', - baseUrl: process.env['ANTHROPIC_BASE_URL'] || '', - apiKey: process.env['ANTHROPIC_AUTH_TOKEN'] || '', - model: process.env['ANTHROPIC_MODEL'], - provider: resolveDroidProvider({ - provider: process.env['CCS_DROID_PROVIDER'] || process.env['DROID_PROVIDER'], - baseUrl: process.env['ANTHROPIC_BASE_URL'], - model: process.env['ANTHROPIC_MODEL'], - }), - reasoningOverride: runtimeReasoningOverride, - runtimeConfigOverrides: codexRuntimeConfigOverrides, - browserRuntimeEnv, - }; - if (resolvedTarget === 'droid' && (!creds.baseUrl || !creds.apiKey)) { - console.error( - fail( - `${adapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN` - ) - ); - console.error(info('Use a settings-based profile instead: ccs glm --target droid')); - process.exit(1); - } - await adapter.prepareCredentials(creds); - const targetArgs = adapter.buildArgs('default', targetRemainingArgs, { - creds, - profileType: 'default', - binaryInfo: targetBinaryInfo || undefined, - }); - const targetEnv = adapter.buildEnv(creds, 'default'); - adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); - return; - } - - const launchArgs = resolveNativeClaudeLaunchArgs( - browserRuntimeEnv - ? appendBrowserToolArgs(nativeClaudeRemainingArgs) - : nativeClaudeRemainingArgs, - 'default', - envVars.CLAUDE_CONFIG_DIR - ); - execClaude(claudeCli, launchArgs, envVars); - } + // Phase E: dispatch to per-profile-type flow (all 6 branches now live in flows/) + await dispatchProfile(dispatchCtx); } catch (error) { const err = error as ProfileError; // Check if this is a profile not found error with suggestions diff --git a/src/dispatcher/dispatcher-context.ts b/src/dispatcher/dispatcher-context.ts new file mode 100644 index 00000000..b637bdc0 --- /dev/null +++ b/src/dispatcher/dispatcher-context.ts @@ -0,0 +1,40 @@ +/** + * Shared context type for Phase E profile dispatch flows. + * + * ProfileDispatchContext extends ResolvedProfile with the dynamic imports + * needed by all flows (InstanceManager, ProfileRegistry, AccountContext, + * ProfileContinuity), and the browser-exposure fields already computed by + * Phase C (profile-resolver.ts). + */ + +import type { ResolvedProfile } from './profile-resolver'; +import type { loadSettings } from '../utils/config-manager'; +import type { getEffectiveClaudeBrowserAttachConfig } from '../utils/browser'; +import type { resolveCliproxyBridgeMetadata } from '../api/services/cliproxy-profile-bridge'; +import type { resolveTargetType } from '../targets/target-resolver'; + +// Re-export ResolvedProfile fields as ProfileDispatchContext — +// all browser-exposure fields are already present in ResolvedProfile. +export type ProfileDispatchContext = ResolvedProfile & { + /** Dynamic-imported modules needed by account/settings flows */ + InstanceManager: Awaited['default']; + ProfileRegistry: Awaited['default']; + resolveAccountContextPolicy: Awaited< + typeof import('../auth/account-context') + >['resolveAccountContextPolicy']; + isAccountContextMetadata: Awaited< + typeof import('../auth/account-context') + >['isAccountContextMetadata']; + resolveProfileContinuityInheritance: Awaited< + typeof import('../auth/profile-continuity-inheritance') + >['resolveProfileContinuityInheritance']; +}; + +// Re-export for convenience +export type { ResolvedProfile }; +export type { + loadSettings, + getEffectiveClaudeBrowserAttachConfig, + resolveCliproxyBridgeMetadata, + resolveTargetType, +}; diff --git a/src/dispatcher/flows/account-flow.ts b/src/dispatcher/flows/account-flow.ts new file mode 100644 index 00000000..6fec0073 --- /dev/null +++ b/src/dispatcher/flows/account-flow.ts @@ -0,0 +1,63 @@ +/** + * Account dispatch flow — account-based profile (work, personal) with instance isolation. + * + * Extracted from src/ccs.ts main() profileInfo.type === 'account' branch. + * Uses CLAUDE_CONFIG_DIR for per-profile instance isolation on all platforms. + */ + +import { execClaude } from '../../utils/shell-executor'; +import { maybeWarnAboutResumeLaneMismatch } from '../../auth/resume-lane-warning'; +import { resolveNativeClaudeLaunchArgs } from '../environment-builder'; +import type { ProfileDispatchContext } from '../dispatcher-context'; + +export async function runAccountFlow(ctx: ProfileDispatchContext): Promise { + const { + profileInfo, + claudeCli, + nativeClaudeRemainingArgs, + InstanceManager, + ProfileRegistry, + resolveAccountContextPolicy, + isAccountContextMetadata, + } = ctx; + + const registry = new ProfileRegistry(); + const instanceMgr = new InstanceManager(); + const accountMetadata = isAccountContextMetadata(profileInfo.profile) + ? profileInfo.profile + : undefined; + const isBareProfile = + typeof profileInfo.profile === 'object' && + profileInfo.profile !== null && + (profileInfo.profile as { bare?: unknown }).bare === true; + const contextPolicy = resolveAccountContextPolicy(accountMetadata); + + // Ensure instance exists (lazy init if needed) + const instancePath = await instanceMgr.ensureInstance(profileInfo.name, contextPolicy, { + bare: isBareProfile, + }); + + // Update last_used timestamp (check unified config first, fallback to legacy) + if (registry.hasAccountUnified(profileInfo.name)) { + registry.touchAccountUnified(profileInfo.name); + } else { + registry.touchProfile(profileInfo.name); + } + + // Execute Claude with instance isolation. + // Skip WebSearch hook — account profiles use native server-side WebSearch. + // Skip Image Analyzer hook — account profiles have native vision support. + const envVars: NodeJS.ProcessEnv = { + CLAUDE_CONFIG_DIR: instancePath, + CCS_PROFILE_TYPE: 'account', + CCS_WEBSEARCH_SKIP: '1', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + await maybeWarnAboutResumeLaneMismatch(profileInfo.name, instancePath, nativeClaudeRemainingArgs); + const launchArgs = resolveNativeClaudeLaunchArgs( + nativeClaudeRemainingArgs, + 'account', + instancePath + ); + execClaude(claudeCli, launchArgs, envVars); +} diff --git a/src/dispatcher/flows/cliproxy-flow.ts b/src/dispatcher/flows/cliproxy-flow.ts new file mode 100644 index 00000000..577fd020 --- /dev/null +++ b/src/dispatcher/flows/cliproxy-flow.ts @@ -0,0 +1,211 @@ +/** + * CLIProxy dispatch flow — OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants. + * + * Extracted from src/ccs.ts main() profileInfo.type === 'cliproxy' branch. + */ + +import { expandPath } from '../../utils/helpers'; +import { fail, info } from '../../utils/ui'; +import { + execClaudeWithCLIProxy, + type CLIProxyProvider, + ensureCliproxyService, + isAuthenticated, +} from '../../cliproxy'; +import { getEffectiveEnvVars, getCompositeEnvVars } from '../../cliproxy/config/env-builder'; +import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager'; +import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; +import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; +import { + ensureProfileHooks as ensureImageAnalyzerHooks, + removeImageAnalysisProfileHook, +} from '../../utils/hooks/image-analyzer-profile-hook-injector'; +import { prepareImageAnalysisFallbackHook } from '../../utils/hooks'; +import { resolveDroidProvider, type TargetCredentials } from '../../targets'; +import type { ProfileDispatchContext } from '../dispatcher-context'; + +export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise { + const { + profileInfo, + resolvedTarget, + claudeCli, + targetAdapter, + targetRemainingArgs, + runtimeReasoningOverride, + codexRuntimeConfigOverrides, + remainingArgs, + } = ctx; + + const imageAnalysisMcpReady = + resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true; + if (resolvedTarget === 'claude') { + ensureWebSearchMcpOrThrow(); + } + const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider); + const expandedCliproxySettingsPath = profileInfo.settingsPath + ? expandPath(profileInfo.settingsPath) + : undefined; + if (resolvedTarget === 'claude') { + if (imageAnalysisMcpReady) { + removeImageAnalysisProfileHook(profileInfo.name, expandedCliproxySettingsPath); + } else { + const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); + ensureImageAnalyzerHooks({ + profileName: profileInfo.name, + profileType: profileInfo.type, + cliproxyProvider: provider, + isComposite: profileInfo.isComposite, + settingsPath: expandedCliproxySettingsPath, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + } + } + const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles + const variantPort = profileInfo.port; // variant-specific port for isolation + const cliproxyPort = variantPort || CLIPROXY_DEFAULT_PORT; + + if (resolvedTarget !== 'claude') { + const adapter = targetAdapter; + if (!adapter) { + console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); + process.exitCode = 1; + return; + } + if (!adapter.supportsProfileType('cliproxy')) { + console.error(fail(`${adapter.displayName} does not support CLIProxy profiles`)); + process.exitCode = 1; + return; + } + + // Keep CLIProxy management/auth flags on Claude flow only. + const unsupportedCliproxyFlags = [ + '--auth', + '--logout', + '--accounts', + '--add', + '--use', + '--config', + '--headless', + '--paste-callback', + '--port-forward', + '--nickname', + '--kiro-auth-method', + '--kiro-idc-start-url', + '--kiro-idc-region', + '--kiro-idc-flow', + '--backend', + '--proxy-host', + '--proxy-port', + '--proxy-protocol', + '--proxy-auth-token', + '--proxy-timeout', + '--local-proxy', + '--remote-only', + '--no-fallback', + '--allow-self-signed', + '--1m', + '--no-1m', + ]; + const providedUnsupportedFlag = unsupportedCliproxyFlags.find( + (flag) => + targetRemainingArgs.includes(flag) || + targetRemainingArgs.some((arg) => arg.startsWith(`${flag}=`)) + ); + if (providedUnsupportedFlag) { + console.error( + fail( + `${providedUnsupportedFlag} is only supported when running CLIProxy profiles on Claude target` + ) + ); + console.error(info(`Run with Claude target: ccs ${profileInfo.name} --target claude ...`)); + process.exitCode = 1; + return; + } + + // For Droid execution path, require existing OAuth auth and running local proxy. + if (profileInfo.isComposite && profileInfo.compositeTiers) { + const compositeProviders = [ + ...new Set(Object.values(profileInfo.compositeTiers).map((tier) => tier.provider)), + ] as CLIProxyProvider[]; + const missingProvider = compositeProviders.find((p) => !isAuthenticated(p)); + if (missingProvider) { + console.error(fail(`Missing OAuth auth for composite tier provider: ${missingProvider}`)); + console.error(info(`Authenticate first: ccs ${missingProvider} --auth`)); + process.exitCode = 1; + return; + } + } else if (!isAuthenticated(provider)) { + console.error(fail(`No OAuth authentication found for provider: ${provider}`)); + console.error(info(`Authenticate first: ccs ${provider} --auth`)); + process.exitCode = 1; + return; + } + + const ensureServiceResult = await ensureCliproxyService( + cliproxyPort, + targetRemainingArgs.includes('--verbose') || targetRemainingArgs.includes('-v') + ); + if (!ensureServiceResult.started) { + console.error(fail(ensureServiceResult.error || 'Failed to start local CLIProxy service')); + process.exitCode = 1; + return; + } + + const envVars = + profileInfo.isComposite && profileInfo.compositeTiers && profileInfo.compositeDefaultTier + ? getCompositeEnvVars( + profileInfo.compositeTiers, + profileInfo.compositeDefaultTier, + cliproxyPort, + customSettingsPath + ) + : getEffectiveEnvVars(provider, cliproxyPort, customSettingsPath); + + const creds: TargetCredentials = { + profile: profileInfo.name, + baseUrl: envVars['ANTHROPIC_BASE_URL'] || '', + apiKey: envVars['ANTHROPIC_AUTH_TOKEN'] || '', + model: envVars['ANTHROPIC_MODEL'] || undefined, + provider: resolveDroidProvider({ + provider: envVars['CCS_DROID_PROVIDER'] || envVars['DROID_PROVIDER'], + baseUrl: envVars['ANTHROPIC_BASE_URL'], + model: envVars['ANTHROPIC_MODEL'], + }), + reasoningOverride: runtimeReasoningOverride, + runtimeConfigOverrides: codexRuntimeConfigOverrides, + envVars, + }; + + if (!creds.baseUrl || !creds.apiKey) { + console.error( + fail( + `Missing CLIProxy runtime credentials for ${profileInfo.name} (ANTHROPIC_BASE_URL/AUTH_TOKEN)` + ) + ); + console.error( + info('Reconfigure with: ccs config > CLIProxy, or run ccs --config') + ); + process.exitCode = 1; + return; + } + + await adapter.prepareCredentials(creds); + const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs, { + creds, + profileType: profileInfo.type, + binaryInfo: ctx.targetBinaryInfo || undefined, + }); + const targetEnv = adapter.buildEnv(creds, profileInfo.type); + adapter.exec(targetArgs, targetEnv, { binaryInfo: ctx.targetBinaryInfo || undefined }); + return; + } + + await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, { + customSettingsPath, + port: cliproxyPort, + isComposite: profileInfo.isComposite, + compositeTiers: profileInfo.compositeTiers, + compositeDefaultTier: profileInfo.compositeDefaultTier, + profileName: profileInfo.name, + }); +} diff --git a/src/dispatcher/flows/copilot-flow.ts b/src/dispatcher/flows/copilot-flow.ts new file mode 100644 index 00000000..a294c5a7 --- /dev/null +++ b/src/dispatcher/flows/copilot-flow.ts @@ -0,0 +1,66 @@ +/** + * Copilot dispatch flow — GitHub Copilot subscription via copilot-api proxy. + * + * Extracted from src/ccs.ts main() profileInfo.type === 'copilot' branch. + */ + +import { fail, info } from '../../utils/ui'; +import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; +import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; +import { + ensureProfileHooks as ensureImageAnalyzerHooks, + removeImageAnalysisProfileHook, +} from '../../utils/hooks/image-analyzer-profile-hook-injector'; +import { prepareImageAnalysisFallbackHook } from '../../utils/hooks'; +import type { ProfileDispatchContext } from '../dispatcher-context'; + +export async function runCopilotFlow(ctx: ProfileDispatchContext): Promise { + const { + profileInfo, + resolvedTarget, + claudeCli, + remainingArgs, + resolveProfileContinuityInheritance, + } = ctx; + + ensureWebSearchMcpOrThrow(); + const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); + if (resolvedTarget === 'claude') { + if (imageAnalysisMcpReady) { + removeImageAnalysisProfileHook(profileInfo.name); + } else { + const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); + ensureImageAnalyzerHooks({ + profileName: profileInfo.name, + profileType: profileInfo.type, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + } + } + + const { executeCopilotProfile } = await import('../../copilot'); + const copilotConfig = profileInfo.copilotConfig; + if (!copilotConfig) { + console.error(fail('Copilot configuration not found')); + process.exit(1); + } + const continuityInheritance = await resolveProfileContinuityInheritance({ + profileName: profileInfo.name, + profileType: profileInfo.type, + target: resolvedTarget, + }); + if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { + console.error( + info( + `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` + ) + ); + } + const exitCode = await executeCopilotProfile( + copilotConfig, + remainingArgs, + continuityInheritance.claudeConfigDir, + claudeCli + ); + process.exit(exitCode); +} diff --git a/src/dispatcher/flows/cursor-flow.ts b/src/dispatcher/flows/cursor-flow.ts new file mode 100644 index 00000000..8a98c0b8 --- /dev/null +++ b/src/dispatcher/flows/cursor-flow.ts @@ -0,0 +1,54 @@ +/** + * Cursor dispatch flow — local Cursor daemon profile. + * + * Extracted from src/ccs.ts main() profileInfo.type === 'cursor' branch. + */ + +import { fail, info } from '../../utils/ui'; +import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; +import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector'; +import { installImageAnalyzerHook } from '../../utils/hooks'; +import type { ProfileDispatchContext } from '../dispatcher-context'; + +export async function runCursorFlow(ctx: ProfileDispatchContext): Promise { + const { + profileInfo, + resolvedTarget, + claudeCli, + remainingArgs, + resolveProfileContinuityInheritance, + } = ctx; + + ensureWebSearchMcpOrThrow(); + installImageAnalyzerHook(); + ensureImageAnalyzerHooks({ + profileName: profileInfo.name, + profileType: profileInfo.type, + }); + + const { executeCursorProfile } = await import('../../cursor'); + const cursorConfig = profileInfo.cursorConfig; + if (!cursorConfig) { + console.error(fail('Cursor configuration not found')); + process.exit(1); + } + const continuityInheritance = await resolveProfileContinuityInheritance({ + profileName: profileInfo.name, + profileType: profileInfo.type, + target: resolvedTarget, + }); + if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { + console.error( + info( + `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` + ) + ); + } + const exitCode = await executeCursorProfile( + cursorConfig, + remainingArgs, + continuityInheritance.claudeConfigDir, + claudeCli + ); + process.exit(exitCode); +} diff --git a/src/dispatcher/flows/default-flow.ts b/src/dispatcher/flows/default-flow.ts new file mode 100644 index 00000000..b782bfb7 --- /dev/null +++ b/src/dispatcher/flows/default-flow.ts @@ -0,0 +1,136 @@ +/** + * Default dispatch flow — no profile configured, use Claude's own defaults. + * + * Extracted from src/ccs.ts main() else branch (profileInfo.type === 'default'). + * Skip WebSearch hook — native Claude has server-side WebSearch. + * Skip Image Analyzer hook — native Claude has native vision support. + */ + +import { fail, info, warn } from '../../utils/ui'; +import { + appendBrowserToolArgs, + ensureBrowserMcpOrThrow, + resolveOptionalBrowserAttachRuntime, + syncBrowserMcpToConfigDir, +} from '../../utils/browser'; +import { execClaude } from '../../utils/shell-executor'; +import { resolveDroidProvider, type TargetCredentials } from '../../targets'; +import { resolveNativeClaudeLaunchArgs } from '../environment-builder'; +import type { ProfileDispatchContext } from '../dispatcher-context'; + +export async function runDefaultFlow(ctx: ProfileDispatchContext): Promise { + const { + profileInfo, + resolvedTarget, + claudeCli, + targetAdapter, + targetBinaryInfo, + targetRemainingArgs, + nativeClaudeRemainingArgs, + runtimeReasoningOverride, + codexRuntimeConfigOverrides, + claudeBrowserExposure, + claudeAttachConfig, + resolveProfileContinuityInheritance, + } = ctx; + + const envVars: NodeJS.ProcessEnv = { + CCS_PROFILE_TYPE: 'default', + CCS_WEBSEARCH_SKIP: '1', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + const browserAttachRuntime = + resolvedTarget === 'claude' && + claudeBrowserExposure?.exposeForLaunch && + claudeAttachConfig?.enabled + ? await resolveOptionalBrowserAttachRuntime(claudeAttachConfig) + : undefined; + const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; + if (browserAttachRuntime?.warning) { + process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`); + } + + if (resolvedTarget === 'claude') { + if (browserRuntimeEnv) { + ensureBrowserMcpOrThrow(); + Object.assign(envVars, browserRuntimeEnv); + } + const defaultContinuityInheritance = await resolveProfileContinuityInheritance({ + profileName: profileInfo.name, + profileType: profileInfo.type, + target: resolvedTarget, + }); + if (defaultContinuityInheritance.sourceAccount && process.env.CCS_DEBUG) { + console.error( + info( + `Continuity inheritance active: profile "${profileInfo.name}" -> account "${defaultContinuityInheritance.sourceAccount}"` + ) + ); + } + if (defaultContinuityInheritance.claudeConfigDir) { + envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir; + if ( + browserRuntimeEnv && + !syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir) + ) { + throw new Error( + 'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.' + ); + } + } + } + + // Dispatch through target adapter for non-claude targets + if (resolvedTarget !== 'claude') { + const adapter = targetAdapter; + if (!adapter) { + console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); + process.exit(1); + } + if (!adapter.supportsProfileType('default')) { + console.error(fail(`${adapter.displayName} does not support default profile mode`)); + process.exit(1); + } + const creds: TargetCredentials = { + profile: 'default', + baseUrl: process.env['ANTHROPIC_BASE_URL'] || '', + apiKey: process.env['ANTHROPIC_AUTH_TOKEN'] || '', + model: process.env['ANTHROPIC_MODEL'], + provider: resolveDroidProvider({ + provider: process.env['CCS_DROID_PROVIDER'] || process.env['DROID_PROVIDER'], + baseUrl: process.env['ANTHROPIC_BASE_URL'], + model: process.env['ANTHROPIC_MODEL'], + }), + reasoningOverride: runtimeReasoningOverride, + runtimeConfigOverrides: codexRuntimeConfigOverrides, + browserRuntimeEnv, + }; + if (resolvedTarget === 'droid' && (!creds.baseUrl || !creds.apiKey)) { + console.error( + fail( + `${adapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN` + ) + ); + console.error(info('Use a settings-based profile instead: ccs glm --target droid')); + process.exit(1); + } + await adapter.prepareCredentials(creds); + const targetArgs = adapter.buildArgs('default', targetRemainingArgs, { + creds, + profileType: 'default', + binaryInfo: targetBinaryInfo || undefined, + }); + const targetEnv = adapter.buildEnv(creds, 'default'); + adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); + return; + } + + const launchArgs = resolveNativeClaudeLaunchArgs( + browserRuntimeEnv + ? appendBrowserToolArgs(nativeClaudeRemainingArgs) + : nativeClaudeRemainingArgs, + 'default', + envVars.CLAUDE_CONFIG_DIR + ); + execClaude(claudeCli, launchArgs, envVars); +} diff --git a/src/dispatcher/flows/settings-flow.ts b/src/dispatcher/flows/settings-flow.ts new file mode 100644 index 00000000..5da4094c --- /dev/null +++ b/src/dispatcher/flows/settings-flow.ts @@ -0,0 +1,400 @@ +/** + * Settings dispatch flow — settings-based profiles (glm, glmt, kimi, etc.). + * + * Extracted from src/ccs.ts main() profileInfo.type === 'settings' branch. + * Image-analysis prep is large enough to split; see settings-image-analysis-prep.ts. + */ + +import { getSettingsPath, loadSettings } from '../../utils/config-manager'; +import { expandPath } from '../../utils/helpers'; +import { + validateGlmKey, + validateMiniMaxKey, + validateAnthropicKey, +} from '../../utils/api-key-validator'; +import { + ensureWebSearchMcpOrThrow, + displayWebSearchStatus, + getWebSearchHookEnv, + syncWebSearchMcpToConfigDir, + appendThirdPartyWebSearchToolArgs, + createWebSearchTraceContext, +} from '../../utils/websearch-manager'; +import { + ensureImageAnalysisMcpOrThrow, + syncImageAnalysisMcpToConfigDir, + appendThirdPartyImageAnalysisToolArgs, +} from '../../utils/image-analysis'; +import { + appendBrowserToolArgs, + ensureBrowserMcpOrThrow, + resolveOptionalBrowserAttachRuntime, + syncBrowserMcpToConfigDir, +} from '../../utils/browser'; +import { getGlobalEnvConfig } from '../../config/unified-config-loader'; +import { + ensureProfileHooks as ensureImageAnalyzerHooks, + removeImageAnalysisProfileHook, +} from '../../utils/hooks/image-analyzer-profile-hook-injector'; +import { fail, info, warn } from '../../utils/ui'; +import { execClaude, stripAnthropicRoutingEnv, stripBrowserEnv } from '../../utils/shell-executor'; +import { + isDeprecatedGlmtProfileName, + normalizeDeprecatedGlmtEnv, +} from '../../utils/glmt-deprecation'; +import { createOpenAICompatLaunchSettings } from '../../utils/openai-compat-launch-settings'; +import { + resolveDroidProvider, + evaluateTargetRuntimeCompatibility, + type TargetCredentials, +} from '../../targets'; +import { resolveCliproxyBridgeMetadata } from '../../api/services/cliproxy-profile-bridge'; +import { + buildOpenAICompatProxyEnv, + resolveOpenAICompatProfileConfig, + startOpenAICompatProxy, +} from '../../proxy'; +import { resolveSettingsImageAnalysisEnv } from './settings-image-analysis-prep'; +import type { ProfileDispatchContext } from '../dispatcher-context'; + +export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise { + const { + profileInfo, + resolvedTarget, + claudeCli, + targetAdapter, + targetBinaryInfo, + targetRemainingArgs, + nativeClaudeRemainingArgs, + runtimeReasoningOverride, + codexRuntimeConfigOverrides, + claudeBrowserExposure, + claudeAttachConfig, + resolvedSettingsPath: preResolvedSettingsPath, + resolvedSettings: preResolvedSettings, + resolvedCliproxyBridge: preResolvedCliproxyBridge, + resolveProfileContinuityInheritance, + remainingArgs, + } = ctx; + + const imageAnalysisMcpReady = + resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true; + const browserAttachRuntime = + resolvedTarget === 'claude' && + claudeBrowserExposure?.exposeForLaunch && + claudeAttachConfig?.enabled + ? await resolveOptionalBrowserAttachRuntime(claudeAttachConfig) + : undefined; + const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; + if (browserAttachRuntime?.warning) { + process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`); + } + if (resolvedTarget === 'claude') { + ensureWebSearchMcpOrThrow(); + if (browserRuntimeEnv) { + ensureBrowserMcpOrThrow(); + } + } + + // Display WebSearch status (single line, equilibrium UX) + displayWebSearchStatus(); + + const continuityInheritance = + resolvedTarget === 'claude' + ? await resolveProfileContinuityInheritance({ + profileName: profileInfo.name, + profileType: profileInfo.type, + target: resolvedTarget, + }) + : {}; + if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { + console.error( + info( + `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` + ) + ); + } + const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir; + syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); + syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); + if ( + browserRuntimeEnv && + inheritedClaudeConfigDir && + !syncBrowserMcpToConfigDir(inheritedClaudeConfigDir) + ) { + throw new Error( + 'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.' + ); + } + + const expandedSettingsPath = + preResolvedSettingsPath ?? + (profileInfo.settingsPath + ? expandPath(profileInfo.settingsPath) + : getSettingsPath(profileInfo.name)); + const settings = preResolvedSettings ?? loadSettings(expandedSettingsPath); + const cliproxyBridge = preResolvedCliproxyBridge ?? resolveCliproxyBridgeMetadata(settings); + + let imageAnalysisFallbackHookReady: boolean | undefined; + if (resolvedTarget === 'claude') { + if (imageAnalysisMcpReady) { + removeImageAnalysisProfileHook(profileInfo.name, expandedSettingsPath); + } else { + imageAnalysisFallbackHookReady = ( + await import('../../utils/hooks') + ).prepareImageAnalysisFallbackHook(); + ensureImageAnalyzerHooks({ + profileName: profileInfo.name, + profileType: profileInfo.type, + settingsPath: expandedSettingsPath, + settings, + cliproxyBridge, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + } + } + if (resolvedTarget !== 'claude') { + const compatibility = evaluateTargetRuntimeCompatibility({ + target: resolvedTarget, + profileType: profileInfo.type, + cliproxyBridgeProvider: cliproxyBridge?.provider ?? null, + }); + if (!compatibility.supported) { + console.error( + fail( + compatibility.reason || + `${targetAdapter?.displayName || resolvedTarget} does not support this profile.` + ) + ); + if (compatibility.suggestion) { + console.error(info(compatibility.suggestion)); + } + process.exit(1); + } + } + + const rawSettingsEnv = profileInfo.env ?? settings.env ?? {}; + const isDeprecatedGlmtProfile = isDeprecatedGlmtProfileName(profileInfo.name); + const glmtNormalization = isDeprecatedGlmtProfile + ? normalizeDeprecatedGlmtEnv(rawSettingsEnv) + : null; + const settingsEnv = glmtNormalization?.env ?? rawSettingsEnv; + + if (glmtNormalization) { + for (const message of glmtNormalization.warnings) { + console.error(warn(message)); + } + } + + // Pre-flight validation for Z.AI-compatible profiles. + if (profileInfo.name === 'glm' || isDeprecatedGlmtProfile) { + const apiKey = settingsEnv['ANTHROPIC_AUTH_TOKEN']; + if (apiKey) { + const validation = await validateGlmKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']); + if (!validation.valid) { + console.error(''); + console.error(fail(validation.error || 'API key validation failed')); + if (validation.suggestion) { + console.error(''); + console.error(validation.suggestion); + } + console.error(''); + console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs glm "prompt"')); + process.exit(1); + } + } + } + + if (profileInfo.name === 'mm') { + const apiKey = settingsEnv['ANTHROPIC_AUTH_TOKEN']; + if (apiKey) { + const validation = await validateMiniMaxKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']); + if (!validation.valid) { + console.error(''); + console.error(fail(validation.error || 'API key validation failed')); + if (validation.suggestion) { + console.error(''); + console.error(validation.suggestion); + } + console.error(''); + console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs mm "prompt"')); + process.exit(1); + } + } + } + + // Pre-flight validation for Anthropic direct profiles (ANTHROPIC_API_KEY + no BASE_URL) + { + const anthropicApiKey = settingsEnv['ANTHROPIC_API_KEY']; + const hasBaseUrl = !!settingsEnv['ANTHROPIC_BASE_URL']; + if (anthropicApiKey && !hasBaseUrl) { + const validation = await validateAnthropicKey(anthropicApiKey); + if (!validation.valid) { + console.error(''); + console.error(fail(validation.error || 'API key validation failed')); + if (validation.suggestion) { + console.error(''); + console.error(validation.suggestion); + } + console.error(''); + console.error( + info(`To skip validation: CCS_SKIP_PREFLIGHT=1 ccs ${profileInfo.name} "prompt"`) + ); + process.exit(1); + } + } + } + + // Image analysis env resolution (split into sibling helper to stay under LOC budget) + const imageAnalysisEnv = await resolveSettingsImageAnalysisEnv({ + profileInfo, + resolvedTarget, + settings, + cliproxyBridge, + imageAnalysisMcpReady, + imageAnalysisFallbackHookReady, + remainingArgs, + targetRemainingArgs, + }); + + const webSearchEnv = getWebSearchHookEnv(); + + // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles + const globalEnvConfig = getGlobalEnvConfig(); + const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; + + // Log global env injection for visibility (debug mode only) + if (globalEnvConfig.enabled && Object.keys(globalEnv).length > 0 && process.env.CCS_DEBUG) { + const envNames = Object.keys(globalEnv).join(', '); + console.error(info(`Global env: ${envNames}`)); + } + + // For Claude target launches that already pass `--settings`, keep runtime env free of + // ANTHROPIC routing/auth while preserving non-routing profile env so nested Team/subagent + // sessions can still inherit model intent and other profile-scoped runtime flags. + const settingsRuntimeEnv = stripBrowserEnv({ ...globalEnv, ...settingsEnv }); + const claudeRuntimeEnvVars: NodeJS.ProcessEnv = { + ...stripAnthropicRoutingEnv(settingsRuntimeEnv), + ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), + ...webSearchEnv, + ...imageAnalysisEnv, + ...(browserRuntimeEnv || {}), + CCS_PROFILE_TYPE: 'settings', + CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1', + }; + + // Non-Claude targets still need effective credentials injected directly. + const envVars: NodeJS.ProcessEnv = { + ...settingsRuntimeEnv, + ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), + ...webSearchEnv, + ...imageAnalysisEnv, + ...(browserRuntimeEnv || {}), + CCS_PROFILE_TYPE: 'settings', + }; + + // Dispatch through target adapter for non-claude targets + if (resolvedTarget !== 'claude') { + const adapter = targetAdapter; + if (!adapter) { + console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); + process.exit(1); + } + const directAnthropicBaseUrl = + settingsEnv['ANTHROPIC_BASE_URL'] || + (settingsEnv['ANTHROPIC_API_KEY'] ? 'https://api.anthropic.com' : ''); + const creds: TargetCredentials = { + profile: profileInfo.name, + baseUrl: directAnthropicBaseUrl, + apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || settingsEnv['ANTHROPIC_API_KEY'] || '', + model: settingsEnv['ANTHROPIC_MODEL'], + provider: resolveDroidProvider({ + provider: settingsEnv['CCS_DROID_PROVIDER'] || settingsEnv['DROID_PROVIDER'], + baseUrl: directAnthropicBaseUrl, + model: settingsEnv['ANTHROPIC_MODEL'], + }), + reasoningOverride: runtimeReasoningOverride, + runtimeConfigOverrides: codexRuntimeConfigOverrides, + envVars, + }; + await adapter.prepareCredentials(creds); + const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs, { + creds, + profileType: profileInfo.type, + binaryInfo: targetBinaryInfo || undefined, + }); + const targetEnv = adapter.buildEnv(creds, profileInfo.type); + adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); + return; + } + + const imageAnalysisArgs = imageAnalysisMcpReady + ? appendThirdPartyImageAnalysisToolArgs(nativeClaudeRemainingArgs) + : nativeClaudeRemainingArgs; + const browserArgs = browserRuntimeEnv + ? appendBrowserToolArgs(imageAnalysisArgs) + : imageAnalysisArgs; + const openAICompatProfile = resolveOpenAICompatProfileConfig( + profileInfo.name, + expandedSettingsPath, + settingsEnv + ); + if (openAICompatProfile) { + const proxyStart = await startOpenAICompatProxy(openAICompatProfile, { + insecure: openAICompatProfile.insecure, + }); + if (!proxyStart.success) { + console.error(fail(proxyStart.error || 'Failed to start local OpenAI-compatible proxy')); + process.exit(1); + } + + console.error( + info( + `Using local OpenAI-compatible proxy for "${profileInfo.name}" on port ${proxyStart.port}` + ) + ); + + const proxyEnv = { + ...envVars, + ...buildOpenAICompatProxyEnv( + openAICompatProfile, + proxyStart.port, + proxyStart.authToken || '', + inheritedClaudeConfigDir + ), + }; + delete proxyEnv.ANTHROPIC_API_KEY; + const launchSettings = createOpenAICompatLaunchSettings(expandedSettingsPath, settings); + + const launchArgs = [ + '--settings', + launchSettings.settingsPath, + ...appendThirdPartyWebSearchToolArgs(browserArgs), + ]; + const traceEnv = createWebSearchTraceContext({ + launcher: 'ccs.settings-profile.proxy', + args: launchArgs, + profile: profileInfo.name, + profileType: profileInfo.type, + settingsPath: expandedSettingsPath, + }); + + execClaude(claudeCli, launchArgs, { ...proxyEnv, ...traceEnv }, launchSettings.cleanup); + return; + } + + const launchArgs = [ + '--settings', + expandedSettingsPath, + ...appendThirdPartyWebSearchToolArgs(browserArgs), + ]; + const traceEnv = createWebSearchTraceContext({ + launcher: 'ccs.settings-profile', + args: launchArgs, + profile: profileInfo.name, + profileType: profileInfo.type, + settingsPath: expandedSettingsPath, + }); + + execClaude(claudeCli, launchArgs, { ...claudeRuntimeEnvVars, ...traceEnv }); +} diff --git a/src/dispatcher/flows/settings-image-analysis-prep.ts b/src/dispatcher/flows/settings-image-analysis-prep.ts new file mode 100644 index 00000000..9e841807 --- /dev/null +++ b/src/dispatcher/flows/settings-image-analysis-prep.ts @@ -0,0 +1,126 @@ +/** + * Image analysis environment preparation for settings-based profiles. + * + * Split from settings-flow.ts to keep that file under the 300 LOC budget. + * Resolves runtime status, connection, and env overrides for image analysis MCP. + */ + +import { warn, info } from '../../utils/ui'; +import { + applyImageAnalysisRuntimeOverrides, + getImageAnalysisHookEnv, + resolveImageAnalysisRuntimeConnection, + resolveImageAnalysisRuntimeStatus, +} from '../../utils/hooks'; +import { ensureCliproxyService } from '../../cliproxy'; +import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager'; +import type { ProfileDetectionResult } from '../../auth/profile-detector'; +import type { loadSettings } from '../../utils/config-manager'; +import type { resolveCliproxyBridgeMetadata } from '../../api/services/cliproxy-profile-bridge'; +import type { resolveTargetType } from '../../targets/target-resolver'; + +export interface SettingsImageAnalysisPrepContext { + profileInfo: ProfileDetectionResult; + resolvedTarget: ReturnType; + settings: ReturnType; + cliproxyBridge: ReturnType | undefined; + imageAnalysisMcpReady: boolean; + imageAnalysisFallbackHookReady: boolean | undefined; + remainingArgs: string[]; + targetRemainingArgs: string[]; +} + +/** + * Resolve the complete image analysis env vars for a settings-based profile launch. + * Handles native-read fallback and proxy-stopped recovery. + */ +export async function resolveSettingsImageAnalysisEnv( + ctx: SettingsImageAnalysisPrepContext +): Promise { + const { + profileInfo, + resolvedTarget, + settings, + cliproxyBridge, + imageAnalysisMcpReady, + imageAnalysisFallbackHookReady, + remainingArgs, + targetRemainingArgs, + } = ctx; + + const imageAnalysisStatus = await resolveImageAnalysisRuntimeStatus({ + profileName: profileInfo.name, + profileType: profileInfo.type, + settings, + cliproxyBridge, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + const runtimeConnection = resolveImageAnalysisRuntimeConnection(); + let imageAnalysisEnv = getImageAnalysisHookEnv({ + profileName: profileInfo.name, + profileType: profileInfo.type, + settings, + cliproxyBridge, + }); + imageAnalysisEnv = applyImageAnalysisRuntimeOverrides(imageAnalysisEnv, { + backendId: imageAnalysisStatus.backendId, + model: imageAnalysisStatus.model, + runtimePath: imageAnalysisStatus.runtimePath, + baseUrl: runtimeConnection.baseUrl, + apiKey: runtimeConnection.apiKey, + allowSelfSigned: runtimeConnection.allowSelfSigned, + }); + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_IMAGE_ANALYSIS_SKIP_HOOK: resolvedTarget === 'claude' && imageAnalysisMcpReady ? '1' : '0', + }; + + const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER']; + if ( + resolvedTarget === 'claude' && + imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' && + imageAnalysisProvider + ) { + const verboseProxyLaunch = + remainingArgs.includes('--verbose') || + remainingArgs.includes('-v') || + targetRemainingArgs.includes('--verbose') || + targetRemainingArgs.includes('-v'); + + if (imageAnalysisStatus.effectiveRuntimeMode === 'native-read') { + console.error( + info( + `${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This session will use native Read.` + ) + ); + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '', + CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: '', + CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: '', + CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED: '0', + }; + } else if (imageAnalysisStatus.proxyReadiness === 'stopped') { + const ensureServiceResult = await ensureCliproxyService( + CLIPROXY_DEFAULT_PORT, + verboseProxyLaunch + ); + if (!ensureServiceResult.started) { + console.error( + warn( + `Image analysis via ${imageAnalysisProvider} is unavailable because CCS could not start the local CLIProxy service. This session will use native Read.` + ) + ); + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + } + } + } + + return imageAnalysisEnv; +} diff --git a/src/dispatcher/target-executor.ts b/src/dispatcher/target-executor.ts index a123746f..7016caf2 100644 --- a/src/dispatcher/target-executor.ts +++ b/src/dispatcher/target-executor.ts @@ -1,15 +1,25 @@ /** - * Native target execution — short-circuit dispatch for passthrough flag commands. + * Native target execution — short-circuit dispatch for passthrough flag commands, + * and top-level profile dispatcher (Phase E switch). * * Extracted from src/ccs.ts (lines 291-295, 394-426). * Handles direct execution of native Codex flag passthrough (--help, --version, etc.) * before the main profile dispatch loop runs. + * + * dispatchProfile() collapses the 6-branch switch in main() to a single call. */ import { fail, info } from '../utils/ui'; import { getTarget } from '../targets'; import { getNativeCodexPassthroughArgs } from './cli-argument-parser'; +import { runCliproxyFlow } from './flows/cliproxy-flow'; +import { runCopilotFlow } from './flows/copilot-flow'; +import { runCursorFlow } from './flows/cursor-flow'; +import { runSettingsFlow } from './flows/settings-flow'; +import { runAccountFlow } from './flows/account-flow'; +import { runDefaultFlow } from './flows/default-flow'; import type { TargetCredentials } from '../targets'; +import type { ProfileDispatchContext } from './dispatcher-context'; // ========== Interfaces ========== @@ -54,3 +64,31 @@ export function execNativeCodexFlagCommand(args: string[]): void { const targetEnv = adapter.buildEnv(creds, 'default'); adapter.exec(builtArgs, targetEnv, { binaryInfo }); } + +// ========== Profile Dispatcher ========== + +/** + * Dispatch to the correct per-profile-type flow. + * + * Collapses the 6-branch if/else-if switch that previously lived in main() into a + * single call site. The headless -p delegation short-circuit is handled in main() + * before this function is called. + */ +export async function dispatchProfile(ctx: ProfileDispatchContext): Promise { + const { profileInfo } = ctx; + + switch (profileInfo.type) { + case 'cliproxy': + return runCliproxyFlow(ctx); + case 'copilot': + return runCopilotFlow(ctx); + case 'cursor': + return runCursorFlow(ctx); + case 'settings': + return runSettingsFlow(ctx); + case 'account': + return runAccountFlow(ctx); + default: + return runDefaultFlow(ctx); + } +}