diff --git a/src/api/services/cliproxy-profile-bridge.ts b/src/api/services/cliproxy-profile-bridge.ts index dc936c23..25ab7d21 100644 --- a/src/api/services/cliproxy-profile-bridge.ts +++ b/src/api/services/cliproxy-profile-bridge.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import { getCcsDir, loadConfigSafe } from '../../utils/config-manager'; + import { buildProxyUrl, getProxyTarget } from '../../cliproxy/proxy/proxy-target-resolver'; import { getEffectiveApiKey } from '../../cliproxy/auth/auth-token-manager'; import { getModelMappingFromConfig } from '../../cliproxy/config/base-config-loader'; @@ -11,7 +11,7 @@ import { mapExternalProviderName, } from '../../cliproxy/provider-capabilities'; import { extractProviderFromPathname } from '../../cliproxy/ai-providers/model-id-normalizer'; -import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; + import type { TargetType } from '../../targets/target-adapter'; import type { Settings } from '../../types/config'; import type { CLIProxyProvider } from '../../cliproxy/types'; @@ -21,6 +21,12 @@ import type { ModelMapping, ResolvedCliproxyBridgeProfile, } from './profile-types'; +import { + getCcsDir, + isUnifiedMode, + loadConfigSafe, + loadOrCreateUnifiedConfig, +} from '../../config/config-loader-facade'; const DEFAULT_PROFILE_SUFFIX = '-api'; diff --git a/src/api/services/openrouter-catalog.ts b/src/api/services/openrouter-catalog.ts index bfcb784e..f13147bd 100644 --- a/src/api/services/openrouter-catalog.ts +++ b/src/api/services/openrouter-catalog.ts @@ -5,7 +5,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { getCcsDir } from '../../utils/config-manager'; +import { getCcsDir } from '../../config/config-loader-facade'; const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/models'; function getCacheFile() { diff --git a/src/api/services/profile-lifecycle-service.ts b/src/api/services/profile-lifecycle-service.ts index 28d43d11..39c6cf82 100644 --- a/src/api/services/profile-lifecycle-service.ts +++ b/src/api/services/profile-lifecycle-service.ts @@ -9,12 +9,12 @@ import * as path from 'path'; import type { Config, Settings } from '../../types'; import type { TargetType } from '../../targets/target-adapter'; import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata'; -import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager'; +import { getConfigPath } from '../../utils/config-manager'; import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; import { isSensitiveKey } from '../../utils/sensitive-keys'; import { isReservedName } from '../../config/reserved-names'; -import { isUnifiedMode, mutateUnifiedConfig } from '../../config/unified-config-loader'; + import { validateApiName } from './validation-service'; import { listApiProfiles } from './profile-reader'; import { validateApiProfileSettingsPayload } from './profile-lifecycle-validation'; @@ -26,6 +26,12 @@ import type { ImportApiProfileResult, RegisterApiProfileOrphansResult, } from './profile-types'; +import { + getCcsDir, + isUnifiedMode, + loadConfigSafe, + mutateConfig, +} from '../../config/config-loader-facade'; const SETTINGS_FILE_SUFFIX = '.settings.json'; const REDACTED_TOKEN_SENTINEL = '__CCS_REDACTED__'; @@ -62,7 +68,7 @@ function writeJsonObjectAtomically(filePath: string, value: unknown): void { function registerApiProfileInConfig(name: string, target: TargetType, force = false): void { if (isUnifiedMode()) { - mutateUnifiedConfig((config) => { + mutateConfig((config) => { if (config.profiles[name] && !force) { throw new Error(`API profile already exists: ${name}`); } diff --git a/src/api/services/profile-reader.ts b/src/api/services/profile-reader.ts index a18f04c2..cb3b5a75 100644 --- a/src/api/services/profile-reader.ts +++ b/src/api/services/profile-reader.ts @@ -6,14 +6,18 @@ */ import * as fs from 'fs'; -import { loadConfigSafe } from '../../utils/config-manager'; -import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader'; + import { expandPath } from '../../utils/helpers'; import type { TargetType } from '../../targets/target-adapter'; import { isPersistedTargetType } from '../../targets/target-metadata'; import type { Settings } from '../../types/config'; import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types'; import { resolveCliproxyBridgeMetadata } from './cliproxy-profile-bridge'; +import { + isUnifiedMode, + loadConfigSafe, + loadOrCreateUnifiedConfig, +} from '../../config/config-loader-facade'; function sanitizeTarget(target: unknown): TargetType { if (isPersistedTargetType(target)) { diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index 0e267160..2ef17caa 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -4,10 +4,10 @@ */ import * as fs from 'fs'; import * as path from 'path'; -import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager'; +import { getConfigPath } from '../../utils/config-manager'; import { expandPath } from '../../utils/helpers'; import { validateApiName } from './validation-service'; -import { mutateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader'; + import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; import type { TargetType } from '../../targets/target-adapter'; @@ -31,6 +31,12 @@ import { resolveCliproxyBridgeMetadata, resolveCliproxyBridgeProfile, } from './cliproxy-profile-bridge'; +import { + getCcsDir, + isUnifiedMode, + loadConfigSafe, + mutateConfig, +} from '../../config/config-loader-facade'; /** Check if URL is an OpenRouter endpoint */ function isOpenRouterUrl(baseUrl: string): boolean { @@ -232,7 +238,7 @@ function createApiProfileUnified( throw error; } - mutateUnifiedConfig((config) => { + mutateConfig((config) => { config.profiles[name] = { type: 'api', settings: `~/.ccs/${settingsFile}`, @@ -343,7 +349,7 @@ export function updateApiProfileTarget( ): UpdateApiProfileTargetResult { try { if (isUnifiedMode()) { - mutateUnifiedConfig((config) => { + mutateConfig((config) => { if (!config.profiles[name]) { throw new Error(`API profile not found: ${name}`); } @@ -392,7 +398,7 @@ export function updateApiProfileTarget( /** Remove API profile from unified config */ function removeApiProfileUnified(name: string): void { - mutateUnifiedConfig((config) => { + mutateConfig((config) => { const profile = config.profiles[name]; if (!profile) { diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index c70b2c6c..f071e001 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -12,7 +12,7 @@ import { getWindowsEscapedCommandShell, stripClaudeCodeEnv, } from '../../utils/shell-executor'; -import { isUnifiedMode } from '../../config/unified-config-loader'; + import { ProfileMetadata } from '../../types'; import { resolveCreateAccountContext, @@ -25,6 +25,7 @@ import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; import { CommandContext, parseArgs } from './types'; import { stripAmbientProviderCredentials } from './create-command-env'; +import { isUnifiedMode } from '../../config/config-loader-facade'; function sanitizeProfileNameForInstance(name: string): string { return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); diff --git a/src/auth/commands/default-command.ts b/src/auth/commands/default-command.ts index 7964a60f..2800f931 100644 --- a/src/auth/commands/default-command.ts +++ b/src/auth/commands/default-command.ts @@ -5,10 +5,11 @@ */ import { initUI, color, dim, ok, fail } from '../../utils/ui'; -import { isUnifiedMode } from '../../config/unified-config-loader'; + import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; import { CommandContext, parseArgs } from './types'; +import { isUnifiedMode } from '../../config/config-loader-facade'; /** * Handle the default command (set default profile) diff --git a/src/auth/commands/remove-command.ts b/src/auth/commands/remove-command.ts index b7df32fa..9302d1b0 100644 --- a/src/auth/commands/remove-command.ts +++ b/src/auth/commands/remove-command.ts @@ -8,10 +8,11 @@ import * as fs from 'fs'; import * as path from 'path'; import { initUI, color, ok, fail, info } from '../../utils/ui'; import { InteractivePrompt } from '../../utils/prompt'; -import { isUnifiedMode } from '../../config/unified-config-loader'; + import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; import { CommandContext, parseArgs } from './types'; +import { isUnifiedMode } from '../../config/config-loader-facade'; /** * Handle the remove command diff --git a/src/auth/profile-continuity-inheritance.ts b/src/auth/profile-continuity-inheritance.ts index b2a775b3..1af11922 100644 --- a/src/auth/profile-continuity-inheritance.ts +++ b/src/auth/profile-continuity-inheritance.ts @@ -1,15 +1,16 @@ import * as fs from 'fs'; -import { - getConfigJsonPath, - getContinuityInheritanceMap, - isUnifiedMode, -} from '../config/unified-config-loader'; + import { warn } from '../utils/ui'; import InstanceManager from '../management/instance-manager'; import ProfileRegistry from './profile-registry'; import { isAccountContextMetadata, resolveAccountContextPolicy } from './account-context'; import type { ProfileType } from '../types/profile'; import { getProfileLookupCandidates, resolveAliasToCanonical } from '../utils/profile-compat'; +import { + getConfigJsonPath, + getContinuityInheritanceMap, + isUnifiedMode, +} from '../config/config-loader-facade'; export interface ProfileContinuityInheritanceInput { profileName: string; diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index b0c8d1ca..f577c367 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -21,8 +21,7 @@ import { CompositeVariantConfig, CompositeTierConfig, } from '../config/unified-config-types'; -import { loadUnifiedConfig, isUnifiedMode, getCursorConfig } from '../config/unified-config-loader'; -import { getCcsDir } from '../utils/config-manager'; + import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat'; import type { CLIProxyProvider } from '../cliproxy/types'; import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities'; @@ -30,6 +29,12 @@ import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants'; import { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer'; import type { TargetType } from '../targets/target-adapter'; import type { ProfileType } from '../types/profile'; +import { + getCcsDir, + getCursorConfig, + isUnifiedMode, + loadUnifiedConfig, +} from '../config/config-loader-facade'; export type { ProfileType } from '../types/profile'; /** CLIProxy profile names (OAuth-based, zero config) */ diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts index 35d17193..31c5482d 100644 --- a/src/auth/profile-registry.ts +++ b/src/auth/profile-registry.ts @@ -1,15 +1,17 @@ import * as fs from 'fs'; import * as path from 'path'; import { ProfileMetadata } from '../types'; -import { - loadOrCreateUnifiedConfig, - mutateUnifiedConfig, - isUnifiedMode, -} from '../config/unified-config-loader'; + import type { AccountConfig } from '../config/unified-config-types'; -import { getCcsDir } from '../utils/config-manager'; + import { isValidContextGroupName, normalizeContextGroupName } from './account-context'; import { createLogger } from '../services/logging'; +import { + getCcsDir, + isUnifiedMode, + loadOrCreateUnifiedConfig, + mutateConfig, +} from '../config/config-loader-facade'; const logger = createLogger('auth:profile-registry'); @@ -323,7 +325,7 @@ export class ProfileRegistry { * Create account in unified config (config.yaml) */ createAccountUnified(name: string, metadata: CreateMetadata = {}): void { - mutateUnifiedConfig((config) => { + mutateConfig((config) => { if (config.accounts[name]) { throw new Error(`Account already exists: ${name}`); } @@ -342,7 +344,7 @@ export class ProfileRegistry { * Update account metadata in unified config */ updateAccountUnified(name: string, updates: Partial): void { - mutateUnifiedConfig((config) => { + mutateConfig((config) => { if (!config.accounts[name]) { throw new Error(`Account not found: ${name}`); } @@ -357,7 +359,7 @@ export class ProfileRegistry { * Remove account from unified config */ removeAccountUnified(name: string): void { - mutateUnifiedConfig((config) => { + mutateConfig((config) => { if (!config.accounts[name]) { throw new Error(`Account not found: ${name}`); } @@ -372,7 +374,7 @@ export class ProfileRegistry { * Set default profile in unified config */ setDefaultUnified(name: string): void { - mutateUnifiedConfig((config) => { + mutateConfig((config) => { const exists = config.accounts[name] || config.profiles[name] || config.cliproxy?.variants?.[name]; if (!exists) { @@ -386,7 +388,7 @@ export class ProfileRegistry { * Clear default profile in unified config (restore original CCS behavior) */ clearDefaultUnified(): void { - mutateUnifiedConfig((config) => { + mutateConfig((config) => { config.default = undefined; }); } @@ -426,7 +428,7 @@ export class ProfileRegistry { * Update account last_used in unified config */ touchAccountUnified(name: string): void { - mutateUnifiedConfig((config) => { + mutateConfig((config) => { if (!config.accounts[name]) { throw new Error(`Account not found: ${name}`); } diff --git a/src/auth/resume-lane-diagnostics.ts b/src/auth/resume-lane-diagnostics.ts index 4919f157..ba3e3b95 100644 --- a/src/auth/resume-lane-diagnostics.ts +++ b/src/auth/resume-lane-diagnostics.ts @@ -1,11 +1,12 @@ import * as fs from 'fs'; import * as path from 'path'; import { getDefaultClaudeConfigDir } from '../utils/claude-config-path'; -import { getCcsDir } from '../utils/config-manager'; + import InstanceManager from '../management/instance-manager'; import ProfileDetector from './profile-detector'; import { resolveConfiguredContinuitySourceAccount } from './profile-continuity-inheritance'; import type { ProfileType } from '../types/profile'; +import { getCcsDir } from '../config/config-loader-facade'; export type ResumeLaneKind = | 'native' diff --git a/src/ccs.ts b/src/ccs.ts index 0fde15ea..b1163244 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -1,524 +1,37 @@ 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 { expandPath } from './utils/helpers'; -import { - validateGlmKey, - validateMiniMaxKey, - validateAnthropicKey, -} from './utils/api-key-validator'; import { ErrorManager } from './utils/error-manager'; -import { - execClaudeWithCLIProxy, - CLIProxyProvider, - ensureCliproxyService, - isAuthenticated, -} from './cliproxy'; -import { getEffectiveEnvVars, getCompositeEnvVars } from './cliproxy/config/env-builder'; -import { CLIPROXY_DEFAULT_PORT } from './cliproxy/config/port-manager'; -import { - ensureWebSearchMcpOrThrow, - displayWebSearchStatus, - getWebSearchHookEnv, - syncWebSearchMcpToConfigDir, - appendThirdPartyWebSearchToolArgs, - createWebSearchTraceContext, -} from './utils/websearch-manager'; -import { - ensureImageAnalysisMcpOrThrow, - syncImageAnalysisMcpToConfigDir, - appendThirdPartyImageAnalysisToolArgs, -} from './utils/image-analysis'; -import { - appendBrowserToolArgs, - ensureBrowserMcpOrThrow, - getBlockedBrowserOverrideWarning, - getEffectiveClaudeBrowserAttachConfig, - resolveBrowserExposure, - resolveBrowserLaunchFlagResolution, - resolveOptionalBrowserAttachRuntime, - syncBrowserMcpToConfigDir, -} from './utils/browser'; -import { - getBrowserConfig, - getGlobalEnvConfig, - getOfficialChannelsConfig, -} 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 { isCopilotSubcommandToken } from './copilot/constants'; -import { - buildOfficialChannelsArgs, - getOfficialChannelsEnvironmentStatus, - officialChannelRequiresMacOS, - resolveOfficialChannelsLaunchPlan, -} from './channels/official-channels-runtime'; -import { getOfficialChannelReadiness } from './channels/official-channels-store'; -import { isCursorSubcommandToken, LEGACY_CURSOR_PROFILE_NAME } from './cursor/constants'; -import { isCLIProxyProvider } from './cliproxy/provider-capabilities'; - +import { fail } from './utils/ui'; // Import centralized error handling import { handleError, runCleanup } from './errors'; -import { tryHandleRootCommand } from './commands/root-command-router'; -// Import extracted utility functions -import { execClaude, stripAnthropicRoutingEnv, stripBrowserEnv } from './utils/shell-executor'; -import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation'; -import { createOpenAICompatLaunchSettings } from './utils/openai-compat-launch-settings'; -import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning'; import { createLogger, runWithRequestId } from './services/logging'; -import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides'; -import type { ProfileDetectionResult } from './auth/profile-detector'; -import type { BrowserLaunchOverride } from './utils/browser'; - // Import target adapter system -import { - registerTarget, - getTarget, - ClaudeAdapter, - DroidAdapter, - CodexAdapter, - evaluateTargetRuntimeCompatibility, - pruneOrphanedModels, - resolveDroidProvider, - type TargetCredentials, -} from './targets'; -import { resolveTargetType, stripTargetFlag } from './targets/target-resolver'; -import { - DroidReasoningFlagError, - resolveDroidReasoningRuntime, -} from './targets/droid-reasoning-runtime'; -import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router'; -import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge'; -import { - buildOpenAICompatProxyEnv, - resolveOpenAICompatProfileConfig, - startOpenAICompatProxy, -} from './proxy'; +import { registerTarget, ClaudeAdapter, DroidAdapter, CodexAdapter } from './targets'; -// Version and Update check utilities -import { getVersion } from './utils/version'; -import { - checkForUpdates, - showUpdateNotification, - checkCachedUpdate, - isCacheStale, -} from './utils/update-checker'; -// Note: npm is now the only supported installation method - -// ========== Profile Detection ========== - -interface DetectedProfile { - profile: string; - remainingArgs: string[]; -} - -interface RuntimeReasoningResolution { - argsWithoutReasoningFlags: string[]; - reasoningOverride: string | number | undefined; - reasoningSource: 'flag' | 'env' | undefined; - sourceDisplay: string | undefined; -} - -const CODEX_RUNTIME_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']); -const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']); -const NATIVE_CLAUDE_EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const; -const NATIVE_CLAUDE_EFFORT_LEVEL_SET = new Set(NATIVE_CLAUDE_EFFORT_LEVELS); - -function resolveCodexRuntimeConfigOverrides( - target: ReturnType, - browserLaunchOverride: BrowserLaunchOverride | undefined -): string[] { - if (target !== 'codex') { - return []; - } - - const codexBrowserExposure = resolveBrowserExposure( - getBrowserConfig().codex, - browserLaunchOverride - ); - if (!codexBrowserExposure.exposeForLaunch) { - return []; - } - - return buildCodexBrowserMcpOverrides(); -} - -/** - * Smart profile detection - */ -function detectProfile(args: string[]): DetectedProfile { - if (args.length === 0 || args[0].startsWith('-')) { - // No args or first arg is a flag → use default profile - return { profile: 'default', remainingArgs: args }; - } else { - // First arg doesn't start with '-' → treat as profile name - return { profile: args[0], remainingArgs: args.slice(1) }; - } -} - -function normalizeLegacyCursorArgs(args: string[]): string[] { - if (args[0] === 'legacy' && args[1] === 'cursor') { - return [LEGACY_CURSOR_PROFILE_NAME, ...args.slice(2)]; - } - - return args; -} - -function printCursorLegacySubcommandDeprecation(subcommand: string): void { - console.error( - info(`\`ccs cursor ${subcommand}\` is deprecated for the legacy Cursor IDE bridge.`) - ); - console.error( - info( - `Use \`ccs legacy cursor ${subcommand}\` for the old bridge, or \`ccs cursor --auth|--accounts|--config\` for the CLIProxy provider.` - ) - ); - console.error(''); -} - -function resolveRuntimeReasoningFlags( - args: string[], - envThinkingValue: string | undefined -): RuntimeReasoningResolution { - const runtime = resolveDroidReasoningRuntime(args, envThinkingValue); - - if (runtime.duplicateDisplays.length > 0) { - console.error( - warn( - `[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || ''}` - ) - ); - } - - return { - argsWithoutReasoningFlags: runtime.argsWithoutReasoningFlags, - reasoningOverride: runtime.reasoningOverride, - reasoningSource: runtime.sourceFlag - ? 'flag' - : runtime.reasoningOverride !== undefined - ? 'env' - : undefined, - sourceDisplay: runtime.sourceDisplay, - }; -} - -function normalizeCodexRuntimeReasoningOverride( - value: string | number | undefined -): string | undefined { - return typeof value === 'string' && CODEX_RUNTIME_REASONING_LEVELS.has(value) ? value : undefined; -} - -function exitWithRuntimeReasoningFlagError( - message: string, - options: { - codexAliasLevels: string; - includeDroidExecExample?: boolean; - } -): never { - console.error(fail(message)); - console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); - console.error(` Codex alias: --effort ${options.codexAliasLevels}`); - if (options.includeDroidExecExample) { - console.error(' Droid exec: --reasoning-effort high'); - } - process.exit(1); -} - -function normalizeNativeClaudeEffortArgs(args: string[]): string[] { - const normalizedArgs: string[] = []; - const allowedValues = NATIVE_CLAUDE_EFFORT_LEVELS.join(', '); - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === '--effort') { - const rawValue = args[i + 1]; - if (!rawValue || rawValue.startsWith('-') || !rawValue.trim()) { - throw new Error(`--effort requires a value: ${allowedValues}`); - } - const value = rawValue.toLowerCase(); - if (!NATIVE_CLAUDE_EFFORT_LEVEL_SET.has(value)) { - throw new Error(`Invalid --effort value: ${rawValue}. Expected one of: ${allowedValues}.`); - } - normalizedArgs.push(arg, value); - i++; - continue; - } - - if (arg.startsWith('--effort=')) { - const rawValue = arg.slice('--effort='.length); - if (!rawValue.trim()) { - throw new Error(`--effort requires a value: ${allowedValues}`); - } - const value = rawValue.toLowerCase(); - if (!NATIVE_CLAUDE_EFFORT_LEVEL_SET.has(value)) { - throw new Error(`Invalid --effort value: ${rawValue}. Expected one of: ${allowedValues}.`); - } - normalizedArgs.push(`--effort=${value}`); - continue; - } - - normalizedArgs.push(arg); - } - - return normalizedArgs; -} - -function shouldNormalizeNativeClaudeEffort(profileType: ProfileDetectionResult['type']): boolean { - return profileType === 'default' || profileType === 'account' || profileType === 'settings'; -} +// Import extracted dispatcher modules +import { bootstrapAndParseEarlyCli } from './dispatcher/cli-argument-parser'; +import { type ProfileError, dispatchProfile } from './dispatcher/target-executor'; +import { runPreDispatchHandlers } from './dispatcher/pre-dispatch'; +import { resolveProfileAndTarget } from './dispatcher/profile-resolver'; // ========== Main Execution ========== -interface ProfileError extends Error { - profileName?: string; - availableProfiles?: string; - suggestions?: string[]; -} - -/** - * Perform background update check (refreshes cache, no notification) - */ -async function refreshUpdateCache(): Promise { - try { - const currentVersion = getVersion(); - // npm is now the only supported installation method - await checkForUpdates(currentVersion, true, 'npm'); - } catch (_e) { - // Silently fail - update check shouldn't crash main CLI - } -} - -/** - * Show update notification if cached result indicates update available - * Returns true if notification was shown - */ -async function showCachedUpdateNotification(): Promise { - try { - const currentVersion = getVersion(); - const updateInfo = checkCachedUpdate(currentVersion); - - if (updateInfo) { - await showUpdateNotification(updateInfo); - return true; - } - } catch (_e) { - // Silently fail - } - return false; -} - -function resolveNativeClaudeLaunchArgs( - args: string[], - profileType: 'default' | 'account', - targetConfigDir?: string -): string[] { - const config = getOfficialChannelsConfig(); - const environment = getOfficialChannelsEnvironmentStatus( - targetConfigDir ? { CLAUDE_CONFIG_DIR: targetConfigDir } : undefined - ); - const channelReadiness = { - telegram: getOfficialChannelReadiness('telegram'), - discord: getOfficialChannelReadiness('discord'), - imessage: !officialChannelRequiresMacOS('imessage') || process.platform === 'darwin', - }; - const plan = resolveOfficialChannelsLaunchPlan({ - args, - config, - target: 'claude', - profileType, - environment, - channelReadiness, - }); - - for (const message of plan.skippedMessages) { - console.error(warn(message)); - } - - if ( - config.selected.length > 0 && - environment.auth.state === 'eligible' && - environment.auth.orgRequirementMessage - ) { - console.error(warn(environment.auth.orgRequirementMessage)); - } - - if (!plan.applied) { - return args; - } - - return buildOfficialChannelsArgs(args, plan.appliedChannels, plan.wantsPermissionBypass); -} - -function shouldPassthroughNativeCodexFlagCommand(args: string[]): boolean { - return getNativeCodexPassthroughArgs(args) !== null; -} - -function getNativeCodexPassthroughArgs(args: string[]): string[] | null { - const targetArgs = stripTargetFlag(args); - if (resolveTargetType(args) !== 'codex' || targetArgs.length === 0) { - return null; - } - - const firstArg = targetArgs[0] || ''; - if (CODEX_NATIVE_PASSTHROUGH_FLAGS.has(firstArg)) { - return targetArgs; - } - - const secondArg = targetArgs[1] || ''; - if (firstArg === 'codex' && CODEX_NATIVE_PASSTHROUGH_FLAGS.has(secondArg)) { - return targetArgs.slice(1); - } - - return null; -} - -function execNativeCodexFlagCommand(args: string[]): void { - const adapter = getTarget('codex'); - if (!adapter) { - console.error(fail('Target adapter not found for "codex"')); - process.exit(1); - } - - const binaryInfo = adapter.detectBinary(); - if (!binaryInfo) { - console.error(fail('Codex CLI not found.')); - console.error(info('Install a recent @openai/codex build, then retry.')); - process.exit(1); - } - - const targetArgs = getNativeCodexPassthroughArgs(args); - if (!targetArgs) { - console.error(fail('Native Codex passthrough args could not be resolved.')); - process.exit(1); - } - const creds: TargetCredentials = { - profile: 'default', - baseUrl: '', - apiKey: '', - }; - - const builtArgs = adapter.buildArgs('default', targetArgs, { - creds, - profileType: 'default', - binaryInfo, - }); - const targetEnv = adapter.buildEnv(creds, 'default'); - adapter.exec(builtArgs, targetEnv, { binaryInfo }); -} - async function main(): Promise { - // Register target adapters + // Register target adapters (singleton wiring — stays in main) registerTarget(new ClaudeAdapter()); registerTarget(new DroidAdapter()); registerTarget(new CodexAdapter()); const cliLogger = createLogger('cli'); - let args = process.argv.slice(2); - const isCompletionCommand = args[0] === '__complete'; - - // Initialize UI colors early to ensure consistent colored output - // Must happen before any status messages (ok, info, fail, etc.) - if (!isCompletionCommand && process.stdout.isTTY && !process.env['CI']) { - const { initUI } = await import('./utils/ui'); - await initUI(); - } - - // Parse --config-dir flag (must happen before any config loading) - const configDirIdx = args.findIndex((a) => a === '--config-dir' || a.startsWith('--config-dir=')); - if (configDirIdx !== -1) { - const arg = args[configDirIdx]; - let configDirValue: string | undefined; - let spliceCount = 1; - - if (arg.startsWith('--config-dir=')) { - configDirValue = arg.split('=').slice(1).join('='); - } else { - configDirValue = args[configDirIdx + 1]; - spliceCount = 2; - } - - if (!configDirValue || configDirValue.startsWith('-')) { - console.error(fail('--config-dir requires a path argument')); - process.exit(1); - } - - try { - const stat = fs.statSync(configDirValue); - if (!stat.isDirectory()) { - console.error(fail(`Not a directory: ${configDirValue}`)); - process.exit(1); - } - } catch { - console.error(fail(`Config directory not found: ${configDirValue}`)); - console.error(info('Create the directory first, then copy your config files into it.')); - process.exit(1); - } - - setGlobalConfigDir(configDirValue); - - // Security warning: cloud sync paths expose OAuth tokens - const cloudService = detectCloudSyncPath(configDirValue); - if (!isCompletionCommand && cloudService) { - console.error(warn(`CCS directory is under ${cloudService}.`)); - console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); - console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); - } - - // Remove consumed args so they don't leak to Claude CLI - args.splice(configDirIdx, spliceCount); - } else if (process.env.CCS_DIR) { - // Also warn for CCS_DIR env var pointing to cloud sync - const cloudService = detectCloudSyncPath(process.env.CCS_DIR); - if (!isCompletionCommand && cloudService) { - console.error(warn(`CCS directory is under ${cloudService}.`)); - console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); - console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); - } - } else if (process.env.CCS_HOME) { - // Also warn for CCS_HOME env var pointing to cloud sync - const cloudService = detectCloudSyncPath(process.env.CCS_HOME); - if (!isCompletionCommand && cloudService) { - console.error(warn(`CCS directory is under ${cloudService}.`)); - console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); - console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); - } - } - - if (isCompletionCommand) { - await tryHandleRootCommand(args); + // Phase A: bootstrap + early arg pre-parse + const bootstrap = await bootstrapAndParseEarlyCli(process.argv.slice(2)); + if (bootstrap.exitNow) { return; } - args = normalizeLegacyCursorArgs(args); - let browserLaunchOverride: BrowserLaunchOverride | undefined; - try { - const browserLaunchFlags = resolveBrowserLaunchFlagResolution(args); - browserLaunchOverride = browserLaunchFlags.override; - args = browserLaunchFlags.argsWithoutFlags; - } catch (error) { - console.error(fail((error as Error).message)); - process.exit(1); - return; - } + const args = bootstrap.args; + const browserLaunchOverride = bootstrap.browserLaunchOverride; cliLogger.info('command.start', 'CLI invocation started', { command: args[0] || 'default', @@ -526,129 +39,17 @@ async function main(): Promise { flags: args.filter((arg) => arg.startsWith('-')).slice(0, 20), }); - if (shouldPassthroughNativeCodexFlagCommand(args)) { - execNativeCodexFlagCommand(args); + // Phase B: pre-dispatch side-effects (update check, migrate, recovery, root commands, routing) + const preDispatchConsumed = await runPreDispatchHandlers({ args, cliLogger }); + if (preDispatchConsumed) { return; } - const firstArg = args[0]; + // Phase C: profile + target detection (extracted to dispatcher/profile-resolver.ts) + const resolvedProfile = await resolveProfileAndTarget({ args, browserLaunchOverride, cliLogger }); + const { profile, profileInfo, resolvedTarget, nativeClaudeRemainingArgs } = resolvedProfile; - // Trigger update check early for ALL commands except version/help/update - // Only if TTY and not CI to avoid noise in automated environments - const skipUpdateCheck = [ - 'version', - '--version', - '-v', - 'help', - '--help', - '-h', - 'update', - '--update', - ]; - if (process.stdout.isTTY && !process.env['CI'] && !skipUpdateCheck.includes(firstArg)) { - // 1. Show cached update notification (async for proper UI) - await showCachedUpdateNotification(); - - // 2. Refresh cache in background if stale (non-blocking) - if (isCacheStale()) { - refreshUpdateCache(); - } - } - - // Auto-migrate to unified config format (silent if already migrated) - // Skip if user is explicitly running migrate command - if (firstArg !== 'migrate') { - const { autoMigrate } = await import('./config/migration-manager'); - await autoMigrate(); - } - - // Auto-recovery for missing configuration (BEFORE any early-exit commands) - // This ensures ALL commands benefit from auto-recovery, not just profile-switching flow - // Recovery is safe to run early - it only creates missing files with safe defaults - // Wrapped in try-catch to prevent blocking --version/--help on permission errors - try { - const RecoveryManagerModule = await import('./management/recovery-manager'); - const RecoveryManager = RecoveryManagerModule.default; - const recovery = new RecoveryManager(); - const recovered = recovery.recoverAll(); - - if (recovered) { - recovery.showRecoveryHints(); - } - } catch (err) { - cliLogger.warn('recovery.failed', 'Auto-recovery failed during CLI startup', { - message: (err as Error).message, - }); - // Recovery is best-effort - don't block basic CLI functionality - console.warn('[!] Recovery failed:', (err as Error).message); - } - - if (await tryHandleRootCommand(args)) { - return; - } - - if ( - typeof firstArg === 'string' && - isCLIProxyProvider(firstArg) && - args.length > 1 && - (args.includes('--help') || args.includes('-h')) - ) { - const { showProviderShortcutHelp } = await import('./commands/help-command'); - await showProviderShortcutHelp(firstArg); - return; - } - - // Special case: copilot command (GitHub Copilot integration) - // Route known subcommands to command handler, keep all other args as profile passthrough. - if (firstArg === 'copilot' && args.length > 1) { - const copilotToken = args[1]; - const shouldRouteToCopilotCommand = isCopilotSubcommandToken(copilotToken); - - if (shouldRouteToCopilotCommand) { - const { handleCopilotCommand } = await import('./commands/copilot-command'); - const exitCode = await handleCopilotCommand(args.slice(1)); - process.exit(exitCode); - } - } - - // Special case: explicit legacy Cursor bridge namespace. - if (firstArg === LEGACY_CURSOR_PROFILE_NAME && args.length > 1) { - const { handleCursorCommand } = await import('./commands/cursor-command'); - const cursorToken = args[1]; - - if (isCursorSubcommandToken(cursorToken)) { - const exitCode = await handleCursorCommand(args.slice(1)); - process.exit(exitCode); - } - } - - // Compatibility shim: old `ccs cursor ` still forwards to the legacy bridge - // for one migration window, but bare/positional `ccs cursor` now belongs to CLIProxy. - if (firstArg === 'cursor' && args.length > 1) { - const { handleCursorCommand } = await import('./commands/cursor-command'); - const cursorToken = args[1]; - - if (isCursorSubcommandToken(cursorToken) && cursorToken !== '--help' && cursorToken !== '-h') { - printCursorLegacySubcommandDeprecation(cursorToken); - const exitCode = await handleCursorCommand(args.slice(1)); - process.exit(exitCode); - } - } - - // First-time install: offer setup wizard for interactive users - // Check independently of recovery status (user may have empty config.yaml) - // Skip if headless, CI, or non-TTY environment - const { isFirstTimeInstall } = await import('./commands/setup-command'); - if (process.stdout.isTTY && !process.env['CI'] && isFirstTimeInstall()) { - console.log(''); - console.log(info('First-time install detected. Run `ccs setup` for guided configuration.')); - console.log(' Or use `ccs config` for the web dashboard.'); - console.log(''); - } - - // Use ProfileDetector to determine profile type - const ProfileDetectorModule = await import('./auth/profile-detector'); - const ProfileDetector = ProfileDetectorModule.default; + // Dynamic imports needed by Phase E flows — preserve original load ordering. const InstanceManagerModule = await import('./management/instance-manager'); const InstanceManager = InstanceManagerModule.default; const ProfileRegistryModule = await import('./auth/profile-registry'); @@ -658,236 +59,17 @@ async function main(): Promise { const ProfileContinuityModule = await import('./auth/profile-continuity-inheritance'); const { resolveProfileContinuityInheritance } = ProfileContinuityModule; - const detector = new ProfileDetector(); + // Build full dispatch context (Phase E) + const dispatchCtx = { + ...resolvedProfile, + InstanceManager, + ProfileRegistry, + resolveAccountContextPolicy, + isAccountContextMetadata, + resolveProfileContinuityInheritance, + }; try { - // Detect profile (strip --target flags before profile detection) - const cleanArgs = stripTargetFlag(args); - const { profile, remainingArgs } = detectProfile(cleanArgs); - const profileInfo: ProfileDetectionResult = detector.detectProfileType(profile); - let resolvedTarget: ReturnType; - try { - resolvedTarget = resolveTargetType( - args, - profileInfo.target ? { target: profileInfo.target } : undefined - ); - } catch (error) { - console.error(fail((error as Error).message)); - process.exit(1); - return; - } - - // Detect Claude CLI (needed for claude target and all CLIProxy-derived flows) - const claudeCliRaw = detectClaudeCli(); - if (resolvedTarget === 'claude' && !claudeCliRaw) { - await ErrorManager.showClaudeNotFound(); - process.exit(1); - } - const claudeCli = claudeCliRaw || ''; - - // Resolve non-claude target adapter once. - const targetAdapter = resolvedTarget !== 'claude' ? getTarget(resolvedTarget) : null; - let resolvedSettingsPath: string | undefined; - let resolvedSettings: ReturnType | undefined; - let resolvedCliproxyBridge: ReturnType | undefined; - - // Preflight unsupported profile/target combinations BEFORE binary detection, - // so users get the most actionable error even when the target CLI is not installed. - if (resolvedTarget !== 'claude') { - if (!targetAdapter) { - console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); - process.exit(1); - } - - if (profileInfo.type === 'settings') { - resolvedSettingsPath = profileInfo.settingsPath - ? expandPath(profileInfo.settingsPath) - : getSettingsPath(profileInfo.name); - resolvedSettings = loadSettings(resolvedSettingsPath); - resolvedCliproxyBridge = resolveCliproxyBridgeMetadata(resolvedSettings); - const compatibility = evaluateTargetRuntimeCompatibility({ - target: resolvedTarget, - profileType: profileInfo.type, - cliproxyBridgeProvider: resolvedCliproxyBridge?.provider ?? null, - }); - if (!compatibility.supported) { - console.error( - fail( - compatibility.reason || `${targetAdapter.displayName} does not support this profile.` - ) - ); - if (compatibility.suggestion) { - console.error(info(compatibility.suggestion)); - } - process.exit(1); - } - } else { - const compatibility = evaluateTargetRuntimeCompatibility({ - target: resolvedTarget, - profileType: profileInfo.type, - cliproxyProvider: profileInfo.type === 'cliproxy' ? profileInfo.provider : undefined, - isComposite: - profileInfo.type === 'cliproxy' ? Boolean(profileInfo.isComposite) : undefined, - }); - if (!compatibility.supported) { - console.error( - fail( - compatibility.reason || `${targetAdapter.displayName} does not support this profile.` - ) - ); - if (compatibility.suggestion) { - console.error(info(compatibility.suggestion)); - } - process.exit(1); - } - } - - if (profileInfo.type === 'default') { - if (!targetAdapter.supportsProfileType('default')) { - console.error(fail(`${targetAdapter.displayName} does not support default profile mode`)); - process.exit(1); - } - - // For default mode, Droid requires explicit credentials from environment. - if (resolvedTarget === 'droid') { - const baseUrl = process.env['ANTHROPIC_BASE_URL'] || ''; - const apiKey = process.env['ANTHROPIC_AUTH_TOKEN'] || ''; - if (!baseUrl.trim() || !apiKey.trim()) { - console.error( - fail( - `${targetAdapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN` - ) - ); - console.error(info('Use a settings-based profile instead: ccs glm --target droid')); - process.exit(1); - } - } - } - } - - // For non-claude targets, verify target binary exists once and pass it through. - const targetBinaryInfo = targetAdapter?.detectBinary() ?? null; - const browserConfig = getBrowserConfig(); - const claudeAttachConfig = - resolvedTarget === 'claude' - ? getEffectiveClaudeBrowserAttachConfig(browserConfig) - : undefined; - const codexRuntimeConfigOverrides = resolveCodexRuntimeConfigOverrides( - resolvedTarget, - browserLaunchOverride - ); - const claudeBrowserExposure = - resolvedTarget === 'claude' - ? resolveBrowserExposure( - { - enabled: claudeAttachConfig?.enabled ?? browserConfig.claude.enabled, - policy: browserConfig.claude.policy, - }, - browserLaunchOverride - ) - : undefined; - const codexBrowserExposure = - resolvedTarget === 'codex' - ? resolveBrowserExposure(browserConfig.codex, browserLaunchOverride) - : undefined; - const blockedBrowserOverrideWarning = - resolvedTarget === 'claude' && claudeBrowserExposure - ? getBlockedBrowserOverrideWarning('Claude Browser Attach', claudeBrowserExposure) - : resolvedTarget === 'codex' && codexBrowserExposure - ? getBlockedBrowserOverrideWarning('Codex Browser Tools', codexBrowserExposure) - : undefined; - if (blockedBrowserOverrideWarning) { - console.error(warn(blockedBrowserOverrideWarning)); - } - if (resolvedTarget !== 'claude' && !targetBinaryInfo) { - const displayName = targetAdapter?.displayName || resolvedTarget; - console.error(fail(`${displayName} CLI not found.`)); - if (resolvedTarget === 'droid') { - console.error(info('Install: npm i -g @factory/cli')); - } else if (resolvedTarget === 'codex') { - console.error(info('Install a recent @openai/codex build, then retry.')); - } - process.exit(1); - } - - // Best-effort: prune stale Droid model entries at runtime so settings.json stays clean. - if (resolvedTarget === 'droid') { - try { - const allProfiles = detector.getAllProfiles(); - const activeProfiles = allProfiles.settings.filter((name) => - /^[a-zA-Z0-9._-]+$/.test(name) - ); - await pruneOrphanedModels(activeProfiles); - } catch (error) { - console.error(warn(`[!] Droid prune skipped: ${(error as Error).message}`)); - } - } - - let targetRemainingArgs = remainingArgs; - let runtimeReasoningOverride: string | number | undefined; - let nativeClaudeRemainingArgs = remainingArgs; - if (resolvedTarget === 'droid') { - try { - const droidRoute = routeDroidCommandArgs(remainingArgs); - targetRemainingArgs = droidRoute.argsForDroid; - - if (droidRoute.mode === 'interactive') { - const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env.CCS_THINKING); - targetRemainingArgs = runtime.argsWithoutReasoningFlags; - runtimeReasoningOverride = runtime.reasoningOverride; - } else { - if (droidRoute.duplicateReasoningDisplays.length > 0) { - console.error( - warn( - `[!] Multiple reasoning flags detected. Using first occurrence: ${droidRoute.reasoningSourceDisplay || ''}` - ) - ); - } - if (droidRoute.autoPrependedExec && process.stdout.isTTY) { - console.error( - info('Detected Droid exec-only flags. Routing as: droid exec [prompt]') - ); - } - } - } catch (error) { - if (error instanceof DroidReasoningFlagError || error instanceof DroidCommandRouterError) { - exitWithRuntimeReasoningFlagError(error.message, { - codexAliasLevels: 'minimal|low|medium|high|xhigh', - includeDroidExecExample: true, - }); - } - throw error; - } - } else if (resolvedTarget === 'codex') { - try { - const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env.CCS_THINKING); - targetRemainingArgs = runtime.argsWithoutReasoningFlags; - const normalizedReasoning = normalizeCodexRuntimeReasoningOverride( - runtime.reasoningOverride - ); - if (runtime.reasoningOverride !== undefined && !normalizedReasoning) { - if (runtime.reasoningSource === 'flag') { - throw new DroidReasoningFlagError( - 'Codex target supports reasoning levels only: minimal, low, medium, high, xhigh.', - '--effort' - ); - } - runtimeReasoningOverride = undefined; - } else { - runtimeReasoningOverride = normalizedReasoning; - } - } catch (error) { - if (error instanceof DroidReasoningFlagError) { - exitWithRuntimeReasoningFlagError(error.message, { - codexAliasLevels: 'minimal|low|medium|high|xhigh', - }); - } - throw error; - } - } else if (resolvedTarget === 'claude' && shouldNormalizeNativeClaudeEffort(profileInfo.type)) { - nativeClaudeRemainingArgs = normalizeNativeClaudeEffortArgs(remainingArgs); - } - // Special case: headless delegation (-p/--prompt) // Keep existing behavior for Claude targets only; non-claude targets must continue // through normal adapter dispatch logic. @@ -901,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/channels/official-channels-ids.ts b/src/channels/official-channels-ids.ts new file mode 100644 index 00000000..d99a11b4 --- /dev/null +++ b/src/channels/official-channels-ids.ts @@ -0,0 +1,48 @@ +/** + * Official Channels IDs (leaf module, no runtime dependencies) + * + * Pure helpers extracted from `official-channels-runtime` so that + * config-loader code can use them without transitively pulling in + * `claude-detector` / `shell-executor` / `websearch-manager`. + * + * The full channel definitions (with display names, plugin specs, env keys, + * etc.) live in `official-channels-runtime`. This module only owns: + * - the canonical ordered list of channel IDs + * - the type-narrowing predicate + * - the order-preserving normalizer + * - the legacy-discord shim + */ + +import type { OfficialChannelId } from '../config/unified-config-types'; + +/** Canonical, ordered list of official channel IDs. */ +export const OFFICIAL_CHANNEL_IDS: readonly OfficialChannelId[] = [ + 'telegram', + 'discord', + 'imessage', +] as const; + +const OFFICIAL_CHANNEL_ID_SET = new Set(OFFICIAL_CHANNEL_IDS); + +export function isOfficialChannelId(value: string): value is OfficialChannelId { + return OFFICIAL_CHANNEL_ID_SET.has(value); +} + +export function normalizeOfficialChannelIds(values: readonly string[]): OfficialChannelId[] { + const seen = new Set(); + const normalized: OfficialChannelId[] = []; + + for (const channelId of OFFICIAL_CHANNEL_IDS) { + if (!values.includes(channelId) || seen.has(channelId)) { + continue; + } + seen.add(channelId); + normalized.push(channelId); + } + + return normalized; +} + +export function resolveLegacyDiscordSelection(enabled: boolean | undefined): OfficialChannelId[] { + return enabled ? ['discord'] : []; +} diff --git a/src/channels/official-channels-runtime.ts b/src/channels/official-channels-runtime.ts index df5c97b1..7b95e19b 100644 --- a/src/channels/official-channels-runtime.ts +++ b/src/channels/official-channels-runtime.ts @@ -64,7 +64,20 @@ export const OFFICIAL_CHANNELS: Record(); - const normalized: OfficialChannelId[] = []; - - for (const channelId of OFFICIAL_CHANNEL_IDS) { - if (!values.includes(channelId) || seen.has(channelId)) { - continue; - } - - seen.add(channelId); - normalized.push(channelId); - } - - return normalized; -} - export function hasExplicitChannelsFlag(args: string[]): boolean { return args.some((arg) => arg === '--channels' || arg.startsWith('--channels=')); } @@ -757,10 +750,6 @@ export function isOfficialChannelSelectionValid(selection: string): boolean { ); } -export function resolveLegacyDiscordSelection(enabled: boolean | undefined): OfficialChannelId[] { - return enabled ? ['discord'] : []; -} - export function getOfficialChannelsSupportedProfiles(): string[] { return ['default', 'account']; } diff --git a/src/channels/official-channels-store.ts b/src/channels/official-channels-store.ts index 7e99e573..5eb73dac 100644 --- a/src/channels/official-channels-store.ts +++ b/src/channels/official-channels-store.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import { getCcsDir } from '../utils/config-manager'; + import { getDefaultClaudeConfigDir } from '../utils/claude-config-path'; import type { OfficialChannelId } from '../config/unified-config-types'; import { @@ -10,6 +10,7 @@ import { getOfficialChannelTokenIds, isOfficialChannelTokenRequired, } from './official-channels-runtime'; +import { getCcsDir } from '../config/config-loader-facade'; export type OfficialChannelTokenSource = 'saved_env' | 'process_env' | 'missing'; diff --git a/src/cliproxy/accounts/account-safety.ts b/src/cliproxy/accounts/account-safety.ts index f4b826f3..b3f48784 100644 --- a/src/cliproxy/accounts/account-safety.ts +++ b/src/cliproxy/accounts/account-safety.ts @@ -14,7 +14,7 @@ import * as path from 'path'; import { warn, info } from '../../utils/ui'; import { CLIProxyProvider } from '../types'; import { loadAccountsRegistry, pauseAccount, resumeAccount } from './registry'; -import { getCcsDir } from '../../utils/config-manager'; +import { getCcsDir } from '../../config/config-loader-facade'; const ISSUE_509_URL = 'https://github.com/kaitranntt/ccs/issues/509'; @@ -690,7 +690,7 @@ export async function handleQuotaExhaustion( // Dynamic imports to avoid circular dependencies const { applyCooldown, findHealthyAccount } = await import('../quota/quota-manager'); const { setDefaultAccount, touchAccount } = await import('./account-manager'); - const { loadOrCreateUnifiedConfig } = await import('../../config/unified-config-loader'); + const { loadOrCreateUnifiedConfig } = await import('../../config/config-loader-facade'); const config = loadOrCreateUnifiedConfig(); const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5; diff --git a/src/cliproxy/auth/antigravity-responsibility.ts b/src/cliproxy/auth/antigravity-responsibility.ts index 62f8e9a2..99e3411a 100644 --- a/src/cliproxy/auth/antigravity-responsibility.ts +++ b/src/cliproxy/auth/antigravity-responsibility.ts @@ -10,7 +10,7 @@ import { createInterface, Interface } from 'readline'; import { fail, info, ok, warn } from '../../utils/ui'; -import { getCliproxySafetyConfig } from '../../config/unified-config-loader'; +import { getCliproxySafetyConfig } from '../../config/config-loader-facade'; export const ANTIGRAVITY_RISK_ISSUE_URL = 'https://github.com/kaitranntt/ccs/issues/509'; export const ANTIGRAVITY_ACK_VERSION = '2026-02-24-antigravity-oauth-v2'; diff --git a/src/cliproxy/auth/auth-token-manager.ts b/src/cliproxy/auth/auth-token-manager.ts index 7ace9252..06cac201 100644 --- a/src/cliproxy/auth/auth-token-manager.ts +++ b/src/cliproxy/auth/auth-token-manager.ts @@ -8,8 +8,9 @@ */ import { randomBytes } from 'crypto'; -import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; + import { CCS_INTERNAL_API_KEY, CCS_CONTROL_PANEL_SECRET } from '../config/generator'; +import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade'; /** * Generate a cryptographically secure token. @@ -87,7 +88,7 @@ export function getEffectiveManagementSecret(): string { * @param apiKey - New API key (or undefined to reset to default) */ export function setGlobalApiKey(apiKey: string | undefined): void { - mutateUnifiedConfig((config) => { + mutateConfig((config) => { if (!config.cliproxy.auth) { config.cliproxy.auth = {}; } @@ -107,7 +108,7 @@ export function setGlobalApiKey(apiKey: string | undefined): void { * @param secret - New management secret (or undefined to reset to default) */ export function setGlobalManagementSecret(secret: string | undefined): void { - mutateUnifiedConfig((config) => { + mutateConfig((config) => { if (!config.cliproxy.auth) { config.cliproxy.auth = {}; } @@ -128,7 +129,7 @@ export function setGlobalManagementSecret(secret: string | undefined): void { * @param apiKey - New API key (or undefined to remove override) */ export function setVariantApiKey(variantName: string, apiKey: string | undefined): void { - mutateUnifiedConfig((config) => { + mutateConfig((config) => { const variant = config.cliproxy.variants[variantName]; if (!variant) { @@ -155,7 +156,7 @@ export function setVariantApiKey(variantName: string, apiKey: string | undefined * Removes cliproxy.auth and all variant auth overrides. */ export function resetAuthToDefaults(): void { - mutateUnifiedConfig((config) => { + mutateConfig((config) => { delete config.cliproxy.auth; for (const variantName of Object.keys(config.cliproxy.variants)) { diff --git a/src/cliproxy/auth/token-refresh-config.ts b/src/cliproxy/auth/token-refresh-config.ts index 39b25f42..3af38149 100644 --- a/src/cliproxy/auth/token-refresh-config.ts +++ b/src/cliproxy/auth/token-refresh-config.ts @@ -5,8 +5,8 @@ * Returns null if disabled or not configured. */ -import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import type { TokenRefreshSettings } from '../../config/unified-config-types'; +import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade'; /** * Get token refresh configuration from unified config diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index baa11726..74c24b71 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -18,7 +18,7 @@ import { } from './binary/platform-detector'; import { stopProxy } from './services/proxy-lifecycle-service'; import { waitForPortFree } from '../utils/port-utils'; -import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; + import { UpdateCheckResult, checkForUpdates, @@ -39,6 +39,7 @@ import { import type { CLIProxyBackend } from './types'; import { getVersionListCachePath } from './binary/version-cache'; +import { loadOrCreateUnifiedConfig } from '../config/config-loader-facade'; export const CLIPROXY_DELETED_PLUS_REPO = 'router-for-me/CLIProxyAPIPlus'; export const CLIPROXY_PLUS_FALLBACK_TRACKING_URL = 'https://github.com/kaitranntt/ccs/issues/1062'; diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index ec0d5c00..4b66f87f 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -7,7 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { CLIProxyProvider, ProviderModelMapping } from '../types'; import { getModelMappingFromConfig, getEnvVarsFromConfig } from '../config/base-config-loader'; -import { getGlobalEnvConfig } from '../../config/unified-config-loader'; + import { getEffectiveApiKey } from '../auth/auth-token-manager'; import { expandPath } from '../../utils/helpers'; import { warn } from '../../utils/ui'; @@ -31,6 +31,7 @@ import { normalizeIFlowLegacyModelAliases, normalizeModelIdForProvider, } from '../ai-providers/model-id-normalizer'; +import { getGlobalEnvConfig } from '../../config/config-loader-facade'; /** Settings file structure for user overrides */ interface ProviderSettings { diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index 3578c316..a34d8a45 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -9,11 +9,12 @@ import type { CLIProxyProvider, ProviderConfig } from '../types'; import { getProviderDisplayName } from '../provider-capabilities'; import { getModelMappingFromConfig } from '../config/base-config-loader'; import { AI_PROVIDER_FAMILY_IDS } from '../ai-providers/types'; -import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; + import { getEffectiveApiKey, getEffectiveManagementSecret } from '../auth/auth-token-manager'; import { getDeniedModelIdReasonForProvider } from '../ai-providers/model-id-normalizer'; import { getAuthDir, getProviderAuthDir, getConfigPathForPort } from './path-resolver'; import { CLIPROXY_DEFAULT_PORT } from './port-manager'; +import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade'; /** Internal API key for CCS-managed requests */ export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed'; diff --git a/src/cliproxy/config/model-config.ts b/src/cliproxy/config/model-config.ts index 9c782d84..5237ce48 100644 --- a/src/cliproxy/config/model-config.ts +++ b/src/cliproxy/config/model-config.ts @@ -12,8 +12,9 @@ import { getProviderCatalog, supportsModelConfig, ModelEntry } from '../model-ca import { getClaudeEnvVars, resolveProviderSettingsPath } from './config-generator'; import { CLIProxyProvider } from '../types'; import { initUI, color, bold, dim, ok, info, header } from '../../utils/ui'; -import { getCcsDir } from '../../utils/config-manager'; + import { normalizeModelIdForProvider } from '../ai-providers/model-id-normalizer'; +import { getCcsDir } from '../../config/config-loader-facade'; function canonicalizeModelForProvider(provider: CLIProxyProvider, model: string): string { return normalizeModelIdForProvider(model, provider); diff --git a/src/cliproxy/config/path-resolver.ts b/src/cliproxy/config/path-resolver.ts index 413d6aee..547b9034 100644 --- a/src/cliproxy/config/path-resolver.ts +++ b/src/cliproxy/config/path-resolver.ts @@ -4,9 +4,10 @@ */ import * as path from 'path'; -import { getCcsDir } from '../../utils/config-manager'; + import type { CLIProxyProvider } from '../types'; import { CLIPROXY_DEFAULT_PORT } from './port-manager'; +import { getCcsDir } from '../../config/config-loader-facade'; /** * Get CLIProxy base directory diff --git a/src/cliproxy/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts index 7e1eba81..b4d545f9 100644 --- a/src/cliproxy/config/thinking-config.ts +++ b/src/cliproxy/config/thinking-config.ts @@ -6,11 +6,12 @@ import type { CLIProxyProvider } from '../types'; import { DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/unified-config-types'; import type { ThinkingConfig } from '../../config/unified-config-types'; -import { getThinkingConfig } from '../../config/unified-config-loader'; + import { getModelThinkingSupport, supportsThinking } from '../model-catalog'; import { isThinkingOffValue, validateThinking } from '../thinking-validator'; import { normalizeModelIdForProvider } from '../ai-providers/model-id-normalizer'; import { warn } from '../../utils/ui'; +import { getThinkingConfig } from '../../config/config-loader-facade'; /** Model tier types for thinking budget defaults */ export type ModelTier = 'opus' | 'sonnet' | 'haiku'; diff --git a/src/cliproxy/executor/__tests__/account-resolution.test.ts b/src/cliproxy/executor/__tests__/account-resolution.test.ts new file mode 100644 index 00000000..1fabba98 --- /dev/null +++ b/src/cliproxy/executor/__tests__/account-resolution.test.ts @@ -0,0 +1,430 @@ +/** + * Unit tests for account-resolution.ts (Phase 05) + * + * Tests cover: + * - resolveRuntimeQuotaMonitorProviders: single provider, composite, dedup + * - resolveAccounts: --accounts early exit, --use switching, --nickname rename, + * default touch (no --use), warnOAuthBanRisk delegation + * - applyAccountSafetyGuards: delegates to safety functions (isolation + warn) + * + * Strategy: pure unit tests on the exported functions. Account-manager and + * account-safety modules are mocked to avoid file I/O. + */ + +import { afterEach, beforeEach, describe, expect, it, jest, mock } from 'bun:test'; + +// ── resolveRuntimeQuotaMonitorProviders ─────────────────────────────────────── + +describe('resolveRuntimeQuotaMonitorProviders', () => { + // Import the module under test directly (no heavy deps needed for this fn) + it('returns empty array when provider is not managed', async () => { + const { resolveRuntimeQuotaMonitorProviders } = await import('../account-resolution'); + const result = resolveRuntimeQuotaMonitorProviders('kiro', []); + expect(result).toEqual([]); + }); + + it('returns [provider] for single managed provider', async () => { + const { resolveRuntimeQuotaMonitorProviders } = await import('../account-resolution'); + const result = resolveRuntimeQuotaMonitorProviders('agy', []); + expect(result).toContain('agy'); + expect(result).toHaveLength(1); + }); + + it('returns composite providers that are managed, deduped', async () => { + const { resolveRuntimeQuotaMonitorProviders } = await import('../account-resolution'); + // agy is managed; kiro is not; duplicate agy should be deduped + const result = resolveRuntimeQuotaMonitorProviders('gemini', ['agy', 'kiro', 'agy']); + expect(result).toContain('agy'); + expect(result).not.toContain('kiro'); + expect(result.filter((p) => p === 'agy')).toHaveLength(1); + }); + + it('ignores base provider when compositeProviders is non-empty', async () => { + const { resolveRuntimeQuotaMonitorProviders } = await import('../account-resolution'); + // base provider is gemini (managed), but composite list contains only kiro (not managed) + const result = resolveRuntimeQuotaMonitorProviders('gemini', ['kiro']); + expect(result).toEqual([]); + }); +}); + +// ── resolveAccounts — --accounts early exit ─────────────────────────────────── + +describe('resolveAccounts — --accounts early exit', () => { + let exitSpy: ReturnType; + let logSpy: ReturnType; + + beforeEach(() => { + exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined as never) as typeof process.exit); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + exitSpy.mockRestore(); + logSpy.mockRestore(); + }); + + it('calls process.exit(0) and returns earlyExit=true when showAccounts=true (no accounts)', async () => { + mock.module('../../accounts/account-manager', () => ({ + getProviderAccounts: () => [], + findAccountByQuery: () => undefined, + setDefaultAccount: () => {}, + touchAccount: () => {}, + renameAccount: () => true, + getDefaultAccount: () => undefined, + })); + mock.module('../../accounts/email-account-identity', () => ({ + formatAccountDisplayName: (a: { email?: string }) => a.email ?? 'unknown', + })); + mock.module('../../config/config-generator', () => ({ + getProviderConfig: () => ({ displayName: 'Gemini' }), + })); + mock.module('../../accounts/account-safety', () => ({ + warnOAuthBanRisk: () => {}, + warnCrossProviderDuplicates: () => false, + cleanupStaleAutoPauses: () => {}, + enforceProviderIsolation: () => 0, + restoreAutoPausedAccounts: () => {}, + })); + + const { resolveAccounts } = await import('../account-resolution'); + const result = await resolveAccounts({ + provider: 'gemini', + showAccounts: true, + useAccount: undefined, + setNickname: undefined, + addAccount: false, + }); + + expect(exitSpy).toHaveBeenCalledWith(0); + expect(result.earlyExit).toBe(true); + }); + + it('prints account list when accounts exist', async () => { + mock.module('../../accounts/account-manager', () => ({ + getProviderAccounts: () => [ + { id: 'acc1', email: 'test@example.com', isDefault: true, nickname: 'main' }, + ], + findAccountByQuery: () => undefined, + setDefaultAccount: () => {}, + touchAccount: () => {}, + renameAccount: () => true, + getDefaultAccount: () => undefined, + })); + mock.module('../../accounts/email-account-identity', () => ({ + formatAccountDisplayName: () => 'test@example.com', + })); + mock.module('../../config/config-generator', () => ({ + getProviderConfig: () => ({ displayName: 'Gemini' }), + })); + mock.module('../../accounts/account-safety', () => ({ + warnOAuthBanRisk: () => {}, + warnCrossProviderDuplicates: () => false, + cleanupStaleAutoPauses: () => {}, + enforceProviderIsolation: () => 0, + restoreAutoPausedAccounts: () => {}, + })); + + const { resolveAccounts } = await import('../account-resolution'); + await resolveAccounts({ + provider: 'gemini', + showAccounts: true, + useAccount: undefined, + setNickname: undefined, + addAccount: false, + }); + + const allOutput = logSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(allOutput).toContain('test@example.com'); + expect(allOutput).toContain('(default)'); + }); +}); + +// ── resolveAccounts — --use switching ───────────────────────────────────────── + +describe('resolveAccounts — --use switching', () => { + let exitSpy: ReturnType; + let logSpy: ReturnType; + let errSpy: ReturnType; + + beforeEach(() => { + exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined as never) as typeof process.exit); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + exitSpy.mockRestore(); + logSpy.mockRestore(); + errSpy.mockRestore(); + }); + + it('calls setDefaultAccount + touchAccount and logs success', async () => { + const setDefaultMock = jest.fn(); + const touchMock = jest.fn(); + mock.module('../../accounts/account-manager', () => ({ + getProviderAccounts: () => [], + findAccountByQuery: () => ({ id: 'acc1', email: 'user@example.com', nickname: undefined }), + setDefaultAccount: setDefaultMock, + touchAccount: touchMock, + renameAccount: () => true, + getDefaultAccount: () => undefined, + })); + mock.module('../../accounts/email-account-identity', () => ({ + formatAccountDisplayName: () => 'user@example.com', + })); + mock.module('../../config/config-generator', () => ({ + getProviderConfig: () => ({ displayName: 'Gemini' }), + })); + mock.module('../../accounts/account-safety', () => ({ + warnOAuthBanRisk: () => {}, + warnCrossProviderDuplicates: () => false, + cleanupStaleAutoPauses: () => {}, + enforceProviderIsolation: () => 0, + restoreAutoPausedAccounts: () => {}, + })); + + const { resolveAccounts } = await import('../account-resolution'); + await resolveAccounts({ + provider: 'gemini', + showAccounts: false, + useAccount: 'user@example.com', + setNickname: undefined, + addAccount: false, + }); + + expect(setDefaultMock).toHaveBeenCalledWith('gemini', 'acc1'); + expect(touchMock).toHaveBeenCalledWith('gemini', 'acc1'); + const allOutput = logSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(allOutput).toContain('Switched to account'); + }); + + it('calls process.exit(1) when account not found', async () => { + mock.module('../../accounts/account-manager', () => ({ + getProviderAccounts: () => [], + findAccountByQuery: () => undefined, + setDefaultAccount: () => {}, + touchAccount: () => {}, + renameAccount: () => true, + getDefaultAccount: () => undefined, + })); + mock.module('../../accounts/email-account-identity', () => ({ + formatAccountDisplayName: () => 'x', + })); + mock.module('../../config/config-generator', () => ({ + getProviderConfig: () => ({ displayName: 'Gemini' }), + })); + mock.module('../../accounts/account-safety', () => ({ + warnOAuthBanRisk: () => {}, + warnCrossProviderDuplicates: () => false, + cleanupStaleAutoPauses: () => {}, + enforceProviderIsolation: () => 0, + restoreAutoPausedAccounts: () => {}, + })); + + const { resolveAccounts } = await import('../account-resolution'); + await resolveAccounts({ + provider: 'gemini', + showAccounts: false, + useAccount: 'nonexistent', + setNickname: undefined, + addAccount: false, + }); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); + +// ── resolveAccounts — --nickname rename ─────────────────────────────────────── + +describe('resolveAccounts — --nickname rename', () => { + let exitSpy: ReturnType; + let logSpy: ReturnType; + let errSpy: ReturnType; + + beforeEach(() => { + exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined as never) as typeof process.exit); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + exitSpy.mockRestore(); + logSpy.mockRestore(); + errSpy.mockRestore(); + }); + + it('renames default account and exits 0 on success', async () => { + const renameMock = jest.fn(() => true); + mock.module('../../accounts/account-manager', () => ({ + getProviderAccounts: () => [], + findAccountByQuery: () => undefined, + setDefaultAccount: () => {}, + touchAccount: () => {}, + renameAccount: renameMock, + getDefaultAccount: () => ({ id: 'acc1', email: 'user@example.com' }), + })); + mock.module('../../accounts/email-account-identity', () => ({ + formatAccountDisplayName: () => 'user@example.com', + })); + mock.module('../../config/config-generator', () => ({ + getProviderConfig: () => ({ displayName: 'Gemini' }), + })); + mock.module('../../accounts/account-safety', () => ({ + warnOAuthBanRisk: () => {}, + warnCrossProviderDuplicates: () => false, + cleanupStaleAutoPauses: () => {}, + enforceProviderIsolation: () => 0, + restoreAutoPausedAccounts: () => {}, + })); + + const { resolveAccounts } = await import('../account-resolution'); + await resolveAccounts({ + provider: 'gemini', + showAccounts: false, + useAccount: undefined, + setNickname: 'work', + addAccount: false, + }); + + expect(renameMock).toHaveBeenCalledWith('gemini', 'acc1', 'work'); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('exits 1 when no default account found', async () => { + mock.module('../../accounts/account-manager', () => ({ + getProviderAccounts: () => [], + findAccountByQuery: () => undefined, + setDefaultAccount: () => {}, + touchAccount: () => {}, + renameAccount: () => false, + getDefaultAccount: () => undefined, + })); + mock.module('../../accounts/email-account-identity', () => ({ + formatAccountDisplayName: () => 'x', + })); + mock.module('../../config/config-generator', () => ({ + getProviderConfig: () => ({ displayName: 'Gemini' }), + })); + mock.module('../../accounts/account-safety', () => ({ + warnOAuthBanRisk: () => {}, + warnCrossProviderDuplicates: () => false, + cleanupStaleAutoPauses: () => {}, + enforceProviderIsolation: () => 0, + restoreAutoPausedAccounts: () => {}, + })); + + const { resolveAccounts } = await import('../account-resolution'); + await resolveAccounts({ + provider: 'gemini', + showAccounts: false, + useAccount: undefined, + setNickname: 'work', + addAccount: false, + }); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('skips rename when addAccount=true (--auth flow)', async () => { + const renameMock = jest.fn(() => true); + mock.module('../../accounts/account-manager', () => ({ + getProviderAccounts: () => [], + findAccountByQuery: () => undefined, + setDefaultAccount: () => {}, + touchAccount: () => {}, + renameAccount: renameMock, + getDefaultAccount: () => ({ id: 'acc1', email: 'user@example.com' }), + })); + mock.module('../../accounts/email-account-identity', () => ({ + formatAccountDisplayName: () => 'user@example.com', + })); + mock.module('../../config/config-generator', () => ({ + getProviderConfig: () => ({ displayName: 'Gemini' }), + })); + mock.module('../../accounts/account-safety', () => ({ + warnOAuthBanRisk: () => {}, + warnCrossProviderDuplicates: () => false, + cleanupStaleAutoPauses: () => {}, + enforceProviderIsolation: () => 0, + restoreAutoPausedAccounts: () => {}, + })); + + const { resolveAccounts } = await import('../account-resolution'); + const result = await resolveAccounts({ + provider: 'gemini', + showAccounts: false, + useAccount: undefined, + setNickname: 'work', + addAccount: true, // suppresses rename + }); + + expect(renameMock).not.toHaveBeenCalled(); + expect(result.earlyExit).toBe(false); + }); +}); + +// ── applyAccountSafetyGuards — delegation ───────────────────────────────────── + +describe('applyAccountSafetyGuards', () => { + it('calls cleanupStaleAutoPauses and enforceProviderIsolation', async () => { + const cleanupMock = jest.fn(); + const enforceMock = jest.fn(() => 0); + const warnDupMock = jest.fn(() => false); + + mock.module('../../accounts/account-safety', () => ({ + warnOAuthBanRisk: () => {}, + warnCrossProviderDuplicates: warnDupMock, + cleanupStaleAutoPauses: cleanupMock, + enforceProviderIsolation: enforceMock, + restoreAutoPausedAccounts: () => {}, + })); + + const { applyAccountSafetyGuards } = await import('../account-resolution'); + applyAccountSafetyGuards('gemini', []); + + expect(cleanupMock).toHaveBeenCalledTimes(1); + expect(enforceMock).toHaveBeenCalledWith('gemini'); + }); + + it('calls warnCrossProviderDuplicates when isolation returns 0', async () => { + const warnDupMock = jest.fn(() => false); + const enforceMock = jest.fn(() => 0); + + mock.module('../../accounts/account-safety', () => ({ + warnOAuthBanRisk: () => {}, + warnCrossProviderDuplicates: warnDupMock, + cleanupStaleAutoPauses: () => {}, + enforceProviderIsolation: enforceMock, + restoreAutoPausedAccounts: () => {}, + })); + + const { applyAccountSafetyGuards } = await import('../account-resolution'); + applyAccountSafetyGuards('gemini', []); + + expect(warnDupMock).toHaveBeenCalledWith('gemini'); + }); + + it('does NOT call warnCrossProviderDuplicates when isolation is enforced', async () => { + const warnDupMock = jest.fn(() => false); + const enforceMock = jest.fn(() => 2); // 2 accounts isolated + + mock.module('../../accounts/account-safety', () => ({ + warnOAuthBanRisk: () => {}, + warnCrossProviderDuplicates: warnDupMock, + cleanupStaleAutoPauses: () => {}, + enforceProviderIsolation: enforceMock, + restoreAutoPausedAccounts: () => {}, + })); + + const { applyAccountSafetyGuards } = await import('../account-resolution'); + applyAccountSafetyGuards('gemini', []); + + expect(warnDupMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cliproxy/executor/__tests__/arg-parser.test.ts b/src/cliproxy/executor/__tests__/arg-parser.test.ts new file mode 100644 index 00000000..45d35b99 --- /dev/null +++ b/src/cliproxy/executor/__tests__/arg-parser.test.ts @@ -0,0 +1,413 @@ +/** + * Unit tests for arg-parser.ts (Phase 02) + * + * Tests cover: + * - readOptionValue: --flag value and --flag=value forms + * - hasGitLabTokenLoginFlag + * - filterCcsFlags / CCS_FLAGS + * - parseExecutorFlags: parse output and early-exit behavior + * - validateFlagCombinations: pass and fail cases + */ + +import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test'; +import { + readOptionValue, + hasGitLabTokenLoginFlag, + CCS_FLAGS, + filterCcsFlags, + parseExecutorFlags, + validateFlagCombinations, + type ParsedExecutorFlags, +} from '../arg-parser'; +import type { UnifiedConfig } from '../../../config/unified-config-types'; + +/** Minimal stub for UnifiedConfig — avoids loading js-yaml via the full loader. */ +function makeEmptyUnifiedConfig(): UnifiedConfig { + return {} as UnifiedConfig; +} + +// ── readOptionValue ──────────────────────────────────────────────────────────── + +describe('readOptionValue', () => { + it('parses space-separated form: --flag value', () => { + expect( + readOptionValue( + ['--kiro-idc-start-url', 'https://d-123.awsapps.com/start'], + '--kiro-idc-start-url' + ) + ).toEqual({ + present: true, + value: 'https://d-123.awsapps.com/start', + missingValue: false, + }); + }); + + it('parses equals form: --flag=value', () => { + expect(readOptionValue(['--kiro-idc-flow=device'], '--kiro-idc-flow')).toEqual({ + present: true, + value: 'device', + missingValue: false, + }); + }); + + it('returns missingValue=true when flag present but no value (next is a flag)', () => { + expect(readOptionValue(['--kiro-idc-region', '--other-flag'], '--kiro-idc-region')).toEqual({ + present: true, + value: undefined, + missingValue: true, + }); + }); + + it('returns missingValue=true when flag is last arg', () => { + expect(readOptionValue(['--kiro-idc-region'], '--kiro-idc-region')).toEqual({ + present: true, + value: undefined, + missingValue: true, + }); + }); + + it('returns missingValue=true for empty equals form: --flag=', () => { + expect(readOptionValue(['--kiro-idc-flow='], '--kiro-idc-flow')).toEqual({ + present: true, + value: undefined, + missingValue: true, + }); + }); + + it('returns present=false when flag not in args', () => { + expect(readOptionValue(['--other', 'val'], '--kiro-idc-region')).toEqual({ + present: false, + missingValue: false, + }); + }); + + it('trims surrounding whitespace from the value', () => { + const result = readOptionValue( + ['--gitlab-url', ' https://gitlab.example.com '], + '--gitlab-url' + ); + expect(result.value).toBe('https://gitlab.example.com'); + }); +}); + +// ── hasGitLabTokenLoginFlag ──────────────────────────────────────────────────── + +describe('hasGitLabTokenLoginFlag', () => { + it('detects --gitlab-token-login', () => { + expect(hasGitLabTokenLoginFlag(['--gitlab-token-login'])).toBe(true); + }); + + it('detects --token-login', () => { + expect(hasGitLabTokenLoginFlag(['--token-login'])).toBe(true); + }); + + it('returns false when neither flag present', () => { + expect(hasGitLabTokenLoginFlag(['--gitlab-url', 'https://gitlab.example.com'])).toBe(false); + expect(hasGitLabTokenLoginFlag([])).toBe(false); + }); +}); + +// ── CCS_FLAGS / filterCcsFlags ───────────────────────────────────────────────── + +describe('CCS_FLAGS and filterCcsFlags', () => { + it('CCS_FLAGS includes known CCS-specific flags', () => { + expect(CCS_FLAGS).toContain('--auth'); + expect(CCS_FLAGS).toContain('--accounts'); + expect(CCS_FLAGS).toContain('--use'); + expect(CCS_FLAGS).toContain('--kiro-auth-method'); + expect(CCS_FLAGS).toContain('--thinking'); + expect(CCS_FLAGS).toContain('--1m'); + expect(CCS_FLAGS).toContain('--no-1m'); + expect(CCS_FLAGS).toContain('--proxy-host'); + }); + + it('filterCcsFlags strips known flags and their values', () => { + const args = ['--use', 'myaccount', '--print', 'hello']; + expect(filterCcsFlags(args)).toEqual(['--print', 'hello']); + }); + + it('filterCcsFlags strips --auth (no value)', () => { + expect(filterCcsFlags(['--auth', '--some-claude-flag'])).toEqual(['--some-claude-flag']); + }); + + it('filterCcsFlags strips equals-form flags', () => { + const args = ['--kiro-auth-method=aws', '--model', 'claude-3']; + expect(filterCcsFlags(args)).toEqual(['--model', 'claude-3']); + }); + + it('filterCcsFlags strips --thinking=value', () => { + expect(filterCcsFlags(['--thinking=high', '--dangerously-skip-permissions'])).toEqual([ + '--dangerously-skip-permissions', + ]); + }); + + it('filterCcsFlags preserves non-CCS args untouched', () => { + const args = ['--model', 'claude-opus-4-5', '--verbose']; + expect(filterCcsFlags(args)).toEqual(args); + }); + + it('filterCcsFlags strips --effort and its value', () => { + const args = ['--effort', 'xhigh', '--print']; + expect(filterCcsFlags(args)).toEqual(['--print']); + }); + + it('filterCcsFlags strips --1m= and --no-1m= inline forms', () => { + expect(filterCcsFlags(['--1m=true'])).toEqual([]); + expect(filterCcsFlags(['--no-1m=true'])).toEqual([]); + }); +}); + +// ── parseExecutorFlags ───────────────────────────────────────────────────────── + +describe('parseExecutorFlags', () => { + let originalExitCode: number | undefined; + let errorSpy: ReturnType; + let exitSpy: ReturnType; + + beforeEach(() => { + originalExitCode = process.exitCode as number | undefined; + process.exitCode = 0; + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined as never) as typeof process.exit); + }); + + afterEach(() => { + process.exitCode = originalExitCode; + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + function makeCtx(provider = 'gemini', compositeProviders: string[] = []) { + return { provider, compositeProviders, unifiedConfig: makeEmptyUnifiedConfig() }; + } + + it('returns default false/undefined for bare args', () => { + const result = parseExecutorFlags([], makeCtx()); + expect(result.forceAuth).toBe(false); + expect(result.showAccounts).toBe(false); + expect(result.useAccount).toBeUndefined(); + expect(result.kiroAuthMethod).toBeUndefined(); + expect(result.extendedContextOverride).toBeUndefined(); + }); + + it('detects --auth flag', () => { + const result = parseExecutorFlags(['--auth'], makeCtx()); + expect(result.forceAuth).toBe(true); + }); + + it('detects --accounts flag', () => { + const result = parseExecutorFlags(['--accounts'], makeCtx()); + expect(result.showAccounts).toBe(true); + }); + + it('parses --use value', () => { + const result = parseExecutorFlags(['--use', 'myaccount'], makeCtx()); + expect(result.useAccount).toBe('myaccount'); + }); + + it('parses --nickname value', () => { + const result = parseExecutorFlags(['--nickname', 'mynick'], makeCtx()); + expect(result.setNickname).toBe('mynick'); + }); + + it('parses --kiro-auth-method=aws for kiro provider', () => { + const result = parseExecutorFlags(['--kiro-auth-method=aws'], makeCtx('kiro')); + expect(result.kiroAuthMethod).toBe('aws'); + }); + + it('sets process.exitCode=1 and returns on invalid --kiro-auth-method value', () => { + parseExecutorFlags(['--kiro-auth-method=invalid-method'], makeCtx('kiro')); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('sets process.exitCode=1 and returns on missing --kiro-auth-method value', () => { + parseExecutorFlags(['--kiro-auth-method'], makeCtx('kiro')); + expect(process.exitCode).toBe(1); + }); + + it('parses --kiro-idc-start-url', () => { + const result = parseExecutorFlags( + ['--kiro-auth-method=idc', '--kiro-idc-start-url', 'https://d-xxx.awsapps.com/start'], + makeCtx('kiro') + ); + expect(result.kiroIDCStartUrl).toBe('https://d-xxx.awsapps.com/start'); + expect(result.kiroAuthMethod).toBe('idc'); + }); + + it('auto-sets kiroAuthMethod=idc when IDC sub-flags present', () => { + const result = parseExecutorFlags( + ['--kiro-idc-start-url', 'https://d-xxx.awsapps.com/start'], + makeCtx('kiro') + ); + expect(result.kiroAuthMethod).toBe('idc'); + }); + + it('parses --1m → extendedContextOverride=true', () => { + expect(parseExecutorFlags(['--1m'], makeCtx()).extendedContextOverride).toBe(true); + }); + + it('parses --no-1m → extendedContextOverride=false', () => { + expect(parseExecutorFlags(['--no-1m'], makeCtx()).extendedContextOverride).toBe(false); + }); + + it('calls process.exit(1) when --1m and --no-1m both present', () => { + parseExecutorFlags(['--1m', '--no-1m'], makeCtx()); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('calls process.exit(1) when --paste-callback and --port-forward both present', () => { + parseExecutorFlags(['--paste-callback', '--port-forward'], makeCtx()); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('kiro noIncognito defaults to true when provider=kiro and no override', () => { + const result = parseExecutorFlags([], makeCtx('kiro')); + expect(result.noIncognito).toBe(true); + }); + + it('--incognito overrides kiro default noIncognito', () => { + const result = parseExecutorFlags(['--incognito'], makeCtx('kiro')); + expect(result.noIncognito).toBe(false); + }); + + it('parses gitlabTokenLogin correctly', () => { + expect(parseExecutorFlags(['--gitlab-token-login'], makeCtx('gitlab')).gitlabTokenLogin).toBe( + true + ); + expect(parseExecutorFlags(['--token-login'], makeCtx('gitlab')).gitlabTokenLogin).toBe(true); + }); +}); + +// ── validateFlagCombinations ─────────────────────────────────────────────────── + +describe('validateFlagCombinations', () => { + let originalExitCode: number | undefined; + let errorSpy: ReturnType; + + beforeEach(() => { + originalExitCode = process.exitCode as number | undefined; + process.exitCode = 0; + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + process.exitCode = originalExitCode; + errorSpy.mockRestore(); + }); + + function baseFlags() { + return { + forceAuth: false, + pasteCallback: false, + portForward: false, + forceHeadless: false, + forceLogout: false, + forceConfig: false, + addAccount: false, + showAccounts: false, + forceImport: false, + gitlabTokenLogin: false, + acceptAgyRisk: false, + incognitoFlag: false, + noIncognitoFlag: false, + noIncognito: false, + useAccount: undefined as string | undefined, + setNickname: undefined as string | undefined, + kiroAuthMethod: undefined as ReturnType['kiroAuthMethod'], + kiroIDCStartUrl: undefined as string | undefined, + kiroIDCRegion: undefined as string | undefined, + kiroIDCFlow: undefined as ReturnType['kiroIDCFlow'], + gitlabBaseUrl: undefined as string | undefined, + extendedContextOverride: undefined as boolean | undefined, + thinkingParse: { + value: null, + error: null, + sourceFlag: null, + sourceDisplay: null, + duplicateDisplays: [], + } as unknown as ReturnType['thinkingParse'], + }; + } + + it('passes validation for clean gemini flags', () => { + validateFlagCombinations(baseFlags(), { provider: 'gemini', compositeProviders: [] }, []); + expect(process.exitCode).toBe(0); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('sets exitCode=1 when --kiro-auth-method used with non-kiro provider', () => { + const flags = { ...baseFlags(), kiroAuthMethod: 'aws' as const }; + validateFlagCombinations(flags, { provider: 'gemini', compositeProviders: [] }, [ + '--kiro-auth-method=aws', + ]); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('passes when kiro-auth-method used with kiro provider', () => { + const flags = { ...baseFlags(), kiroAuthMethod: 'aws' as const }; + validateFlagCombinations(flags, { provider: 'kiro', compositeProviders: [] }, [ + '--kiro-auth-method=aws', + ]); + expect(process.exitCode).toBe(0); + }); + + it('sets exitCode=1 when IDC sub-flags used without kiro provider', () => { + const flags = { ...baseFlags(), kiroIDCStartUrl: 'https://d-xxx.awsapps.com/start' }; + validateFlagCombinations(flags, { provider: 'gemini', compositeProviders: [] }, []); + expect(process.exitCode).toBe(1); + }); + + it('sets exitCode=1 when kiro IDC method missing --kiro-idc-start-url', () => { + const flags = { ...baseFlags(), kiroAuthMethod: 'idc' as const }; + validateFlagCombinations(flags, { provider: 'kiro', compositeProviders: [] }, []); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--kiro-idc-start-url')); + }); + + it('passes when IDC method has start-url', () => { + const flags = { + ...baseFlags(), + kiroAuthMethod: 'idc' as const, + kiroIDCStartUrl: 'https://d-xxx.awsapps.com/start', + }; + validateFlagCombinations(flags, { provider: 'kiro', compositeProviders: [] }, []); + expect(process.exitCode).toBe(0); + }); + + it('sets exitCode=1 when non-idc method used with IDC sub-flags', () => { + const flags = { + ...baseFlags(), + kiroAuthMethod: 'aws' as const, + kiroIDCStartUrl: 'https://d-xxx.awsapps.com/start', + }; + validateFlagCombinations(flags, { provider: 'kiro', compositeProviders: [] }, []); + expect(process.exitCode).toBe(1); + }); + + it('sets exitCode=1 when --gitlab-token-login used with non-gitlab provider', () => { + const flags = { ...baseFlags(), gitlabTokenLogin: true }; + validateFlagCombinations(flags, { provider: 'gemini', compositeProviders: [] }, [ + '--gitlab-token-login', + ]); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('gitlab')); + }); + + it('passes --gitlab-token-login with gitlab provider', () => { + const flags = { ...baseFlags(), gitlabTokenLogin: true }; + validateFlagCombinations(flags, { provider: 'gitlab', compositeProviders: [] }, [ + '--gitlab-token-login', + ]); + expect(process.exitCode).toBe(0); + }); + + it('passes kiro flags when kiro is a composite provider', () => { + const flags = { ...baseFlags(), kiroAuthMethod: 'aws' as const }; + validateFlagCombinations(flags, { provider: 'gemini', compositeProviders: ['kiro'] }, []); + expect(process.exitCode).toBe(0); + }); +}); diff --git a/src/cliproxy/executor/__tests__/auth-coordinator.test.ts b/src/cliproxy/executor/__tests__/auth-coordinator.test.ts new file mode 100644 index 00000000..d7617fe6 --- /dev/null +++ b/src/cliproxy/executor/__tests__/auth-coordinator.test.ts @@ -0,0 +1,487 @@ +/** + * Auth Coordinator Tests (Phase 06) + * + * Tests for auth-coordinator.ts exported functions. + * + * Strategy: + * - jest.mock() at top level to intercept all imports (static + dynamic) + * before the module-under-test is loaded + * - process.exit spied in beforeEach to prevent runner exit + * - Dynamic-import paths (e.g. '../auth/auth-handler') are mocked via + * jest.mock() from the coordinator's perspective (relative to executor/) + */ + +import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test'; +import type { ExecutorConfig } from '../../types'; +import type { UnifiedConfig } from '../../../config/schemas/unified-config'; +import type { AuthCoordinationContext } from '../auth-coordinator'; +import type { ParsedExecutorFlags } from '../arg-parser'; + +// ── Module mocks (must be top-level, before any import of subject) ──────────── + +const mockIsAuthenticated = jest.fn(() => false); +const mockClearAuth = jest.fn(() => true); +const mockTriggerOAuth = jest.fn(async () => true); + +jest.mock('../../auth/auth-handler', () => ({ + isAuthenticated: (...args: unknown[]) => mockIsAuthenticated(...args), + clearAuth: (...args: unknown[]) => mockClearAuth(...args), + triggerOAuth: (...args: unknown[]) => mockTriggerOAuth(...args), +})); + +const mockEnsureAntigravity = jest.fn(async () => true); +const MOCK_ANTIGRAVITY_FLAGS = ['--accept-agr-risk', '--accept-antigravity-risk']; + +jest.mock('../../auth/antigravity-responsibility', () => ({ + ensureCliAntigravityResponsibility: (...args: unknown[]) => mockEnsureAntigravity(...args), + ANTIGRAVITY_ACCEPT_RISK_FLAGS: MOCK_ANTIGRAVITY_FLAGS, +})); + +const mockHandleTokenExpiration = jest.fn(async () => {}); +const mockHandleQuotaCheck = jest.fn(async () => {}); + +jest.mock('../retry-handler', () => ({ + handleTokenExpiration: (...args: unknown[]) => mockHandleTokenExpiration(...args), + handleQuotaCheck: (...args: unknown[]) => mockHandleQuotaCheck(...args), +})); + +const mockApplyAccountSafetyGuards = jest.fn(() => {}); +const mockTouchDefaultAccount = jest.fn(() => {}); + +jest.mock('../account-resolution', () => ({ + applyAccountSafetyGuards: (...args: unknown[]) => mockApplyAccountSafetyGuards(...args), + touchDefaultAccount: (...args: unknown[]) => mockTouchDefaultAccount(...args), +})); + +const mockConfigureProviderModel = jest.fn(async () => {}); +const mockGetCurrentModel = jest.fn(() => 'claude-opus'); + +jest.mock('../../config/model-config', () => ({ + configureProviderModel: (...args: unknown[]) => mockConfigureProviderModel(...args), + getCurrentModel: (...args: unknown[]) => mockGetCurrentModel(...args), +})); + +const mockReconcileCodexModel = jest.fn(async () => {}); + +jest.mock('../../ai-providers/codex-plan-compatibility', () => ({ + reconcileCodexModelForActivePlan: (...args: unknown[]) => mockReconcileCodexModel(...args), +})); + +// Do NOT mock quota-manager, model-catalog, config-generator — pure modules +// with no I/O side effects; mocking them would strip exports needed by the +// wider module graph and cause "Export not found" errors. + +// ── Import subject AFTER mocks ───────────────────────────────────────────────── + +const { + resolveSkipLocalAuth, + handleLogout, + handleImport, + runAntigravityGate, + ensureProviderAuthentication, + runPreflightQuotaCheck, + runAccountSafetyGuards, + ensureModelConfiguration, +} = await import('../auth-coordinator'); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeFlags(overrides: Partial> = {}): ParsedExecutorFlags { + return { + forceAuth: false, + pasteCallback: false, + portForward: false, + forceHeadless: false, + forceLogout: false, + forceConfig: false, + addAccount: false, + showAccounts: false, + forceImport: false, + gitlabTokenLogin: false, + acceptAgyRisk: false, + incognitoFlag: false, + noIncognitoFlag: false, + noIncognito: false, + useAccount: undefined, + setNickname: undefined, + kiroAuthMethod: undefined, + kiroIDCStartUrl: undefined, + kiroIDCRegion: undefined, + kiroIDCFlow: undefined, + gitlabBaseUrl: undefined, + extendedContextOverride: undefined, + thinkingParse: { + value: undefined, + sourceFlag: undefined, + sourceDisplay: 'none', + duplicateDisplays: [], + } as ParsedExecutorFlags['thinkingParse'], + ...overrides, + } as unknown as ParsedExecutorFlags; +} + +function makeCtx(overrides: Partial = {}): AuthCoordinationContext { + return { + provider: 'gemini', + compositeProviders: [], + parsedFlags: makeFlags(), + cfg: { port: 8090, timeout: 5000, verbose: false, pollInterval: 100 } as ExecutorConfig, + unifiedConfig: {} as UnifiedConfig, + verbose: false, + log: () => {}, + ...overrides, + }; +} + +// ── resolveSkipLocalAuth ────────────────────────────────────────────────────── + +describe('resolveSkipLocalAuth', () => { + it('returns false when useRemoteProxy=false', () => { + expect(resolveSkipLocalAuth('tok', false)).toBe(false); + }); + + it('returns false when token is whitespace only', () => { + expect(resolveSkipLocalAuth(' ', true)).toBe(false); + }); + + it('returns false when token is undefined', () => { + expect(resolveSkipLocalAuth(undefined, true)).toBe(false); + }); + + it('returns true when useRemoteProxy=true and token non-empty', () => { + expect(resolveSkipLocalAuth('tok123', true)).toBe(true); + }); +}); + +// ── handleLogout ────────────────────────────────────────────────────────────── + +describe('handleLogout', () => { + let exitSpy: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as typeof process.exit); + }); + + afterEach(() => { + exitSpy.mockRestore(); + }); + + it('returns false and does not exit when forceLogout=false', async () => { + const result = await handleLogout(makeCtx({ parsedFlags: makeFlags({ forceLogout: false }) })); + expect(result).toBe(false); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('exits 0 when forceLogout=true and clearAuth succeeds', async () => { + mockClearAuth.mockReturnValue(true); + await handleLogout(makeCtx({ parsedFlags: makeFlags({ forceLogout: true }) })); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('exits 0 even when no auth found (clearAuth returns false)', async () => { + mockClearAuth.mockReturnValue(false); + await handleLogout(makeCtx({ parsedFlags: makeFlags({ forceLogout: true }) })); + expect(exitSpy).toHaveBeenCalledWith(0); + }); +}); + +// ── handleImport ────────────────────────────────────────────────────────────── + +describe('handleImport', () => { + let exitSpy: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + mockTriggerOAuth.mockResolvedValue(true); + exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as typeof process.exit); + }); + + afterEach(() => { + exitSpy.mockRestore(); + }); + + it('returns false when forceImport=false', async () => { + const result = await handleImport(makeCtx({ parsedFlags: makeFlags({ forceImport: false }) })); + expect(result).toBe(false); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('exits 1 for non-kiro provider', async () => { + await handleImport( + makeCtx({ provider: 'gemini', parsedFlags: makeFlags({ forceImport: true }) }) + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('exits 1 when --import + --auth combined', async () => { + await handleImport( + makeCtx({ provider: 'kiro', parsedFlags: makeFlags({ forceImport: true, forceAuth: true }) }) + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('exits 1 when --import + --logout combined', async () => { + await handleImport( + makeCtx({ + provider: 'kiro', + parsedFlags: makeFlags({ forceImport: true, forceLogout: true }), + }) + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('exits 0 on successful kiro import', async () => { + mockTriggerOAuth.mockResolvedValue(true); + await handleImport( + makeCtx({ provider: 'kiro', parsedFlags: makeFlags({ forceImport: true }) }) + ); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('exits 1 when triggerOAuth returns false', async () => { + mockTriggerOAuth.mockResolvedValue(false); + await handleImport( + makeCtx({ provider: 'kiro', parsedFlags: makeFlags({ forceImport: true }) }) + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); + +// ── runAntigravityGate ──────────────────────────────────────────────────────── + +describe('runAntigravityGate', () => { + let exitSpy: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + mockEnsureAntigravity.mockResolvedValue(true); + mockIsAuthenticated.mockReturnValue(false); + exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as typeof process.exit); + }); + + afterEach(() => { + exitSpy.mockRestore(); + }); + + it('returns earlyReturn=false for non-agy provider without calling gate', async () => { + const result = await runAntigravityGate(makeCtx({ provider: 'gemini' }), false); + expect(result.earlyReturn).toBe(false); + expect(mockEnsureAntigravity).not.toHaveBeenCalled(); + }); + + it('agy + forceAuth + skipLocalAuth + acknowledged → earlyReturn=true', async () => { + mockEnsureAntigravity.mockResolvedValue(true); + const ctx = makeCtx({ provider: 'agy', parsedFlags: makeFlags({ forceAuth: true }) }); + const result = await runAntigravityGate(ctx, true); + expect(result.earlyReturn).toBe(true); + expect(mockEnsureAntigravity).toHaveBeenCalledWith( + expect.objectContaining({ context: 'oauth' }) + ); + }); + + it('agy + forceAuth + skipLocalAuth + refused → throws', async () => { + mockEnsureAntigravity.mockResolvedValue(false); + const ctx = makeCtx({ provider: 'agy', parsedFlags: makeFlags({ forceAuth: true }) }); + await expect(runAntigravityGate(ctx, true)).rejects.toThrow('Antigravity auth blocked'); + }); + + it('agy + no forceAuth + skipLocalAuth + acknowledged → earlyReturn=false, no exit', async () => { + mockEnsureAntigravity.mockResolvedValue(true); + mockIsAuthenticated.mockReturnValue(true); // already authenticated → run context triggers + const ctx = makeCtx({ provider: 'agy', parsedFlags: makeFlags({ forceAuth: false }) }); + const result = await runAntigravityGate(ctx, true); + expect(result.earlyReturn).toBe(false); + expect(exitSpy).not.toHaveBeenCalled(); + expect(mockEnsureAntigravity).toHaveBeenCalledWith(expect.objectContaining({ context: 'run' })); + }); + + it('agy + no forceAuth + skipLocalAuth + refused → process.exit(1)', async () => { + mockEnsureAntigravity.mockResolvedValue(false); + mockIsAuthenticated.mockReturnValue(true); + const ctx = makeCtx({ provider: 'agy', parsedFlags: makeFlags({ forceAuth: false }) }); + await runAntigravityGate(ctx, true); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); + +// ── ensureProviderAuthentication ────────────────────────────────────────────── + +describe('ensureProviderAuthentication', () => { + let exitSpy: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + mockIsAuthenticated.mockReturnValue(false); + mockTriggerOAuth.mockResolvedValue(true); + mockHandleTokenExpiration.mockResolvedValue(undefined); + mockTouchDefaultAccount.mockImplementation(() => {}); + exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as typeof process.exit); + }); + + afterEach(() => { + exitSpy.mockRestore(); + }); + + it('already authenticated → no OAuth trigger, no exit', async () => { + mockIsAuthenticated.mockReturnValue(true); + await ensureProviderAuthentication(makeCtx({ provider: 'gemini' })); + expect(mockTriggerOAuth).not.toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('not authenticated → triggerOAuth called', async () => { + mockIsAuthenticated.mockReturnValue(false); + await ensureProviderAuthentication(makeCtx({ provider: 'gemini' })); + expect(mockTriggerOAuth).toHaveBeenCalledWith('gemini', expect.any(Object)); + }); + + it('forceAuth=true → exits 0 after successful OAuth', async () => { + await ensureProviderAuthentication( + makeCtx({ provider: 'gemini', parsedFlags: makeFlags({ forceAuth: true }) }) + ); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('OAuth fails → throws Authentication required error', async () => { + mockTriggerOAuth.mockResolvedValue(false); + await expect(ensureProviderAuthentication(makeCtx({ provider: 'gemini' }))).rejects.toThrow( + 'Authentication required' + ); + }); + + it('calls touchDefaultAccount after successful single-provider auth', async () => { + mockIsAuthenticated.mockReturnValue(true); + await ensureProviderAuthentication(makeCtx({ provider: 'gemini' })); + expect(mockTouchDefaultAccount).toHaveBeenCalledWith('gemini'); + }); + + it('runs token refresh after single-provider auth', async () => { + mockIsAuthenticated.mockReturnValue(true); + await ensureProviderAuthentication(makeCtx({ provider: 'gemini' })); + expect(mockHandleTokenExpiration).toHaveBeenCalledWith('gemini', false); + }); + + it('composite + forceAuth: all succeed → exit(0)', async () => { + mockTriggerOAuth.mockResolvedValue(true); + const ctx = makeCtx({ + provider: 'agy', + compositeProviders: ['gemini', 'codex'], + parsedFlags: makeFlags({ forceAuth: true }), + }); + await ensureProviderAuthentication(ctx); + expect(exitSpy).toHaveBeenCalledWith(0); + expect(mockTriggerOAuth).toHaveBeenCalledTimes(2); + }); + + it('composite + forceAuth: partial failure → exit(1)', async () => { + let call = 0; + mockTriggerOAuth.mockImplementation(async () => ++call === 1); + const ctx = makeCtx({ + provider: 'agy', + compositeProviders: ['gemini', 'codex'], + parsedFlags: makeFlags({ forceAuth: true }), + }); + await ensureProviderAuthentication(ctx); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('composite no forceAuth + unauthenticated → exit(1)', async () => { + mockIsAuthenticated.mockReturnValue(false); + const ctx = makeCtx({ + provider: 'agy', + compositeProviders: ['gemini', 'codex'], + parsedFlags: makeFlags({ forceAuth: false }), + }); + await ensureProviderAuthentication(ctx); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('composite all authenticated → token refresh for each provider', async () => { + mockIsAuthenticated.mockReturnValue(true); + const ctx = makeCtx({ + provider: 'agy', + compositeProviders: ['gemini', 'codex'], + parsedFlags: makeFlags({ forceAuth: false }), + }); + await ensureProviderAuthentication(ctx); + expect(exitSpy).not.toHaveBeenCalled(); + expect(mockHandleTokenExpiration).toHaveBeenCalledTimes(2); + }); +}); + +// ── runPreflightQuotaCheck ──────────────────────────────────────────────────── + +describe('runPreflightQuotaCheck', () => { + beforeEach(() => jest.clearAllMocks()); + + it('single provider → handleQuotaCheck called once with that provider', async () => { + await runPreflightQuotaCheck('gemini', []); + expect(mockHandleQuotaCheck).toHaveBeenCalledWith('gemini'); + expect(mockHandleQuotaCheck).toHaveBeenCalledTimes(1); + }); + + it('composite list → checks only managed providers from the list', async () => { + // Real MANAGED_QUOTA_PROVIDERS = ['agy','claude','codex','gemini','ghcp'] + // 'gemini' and 'codex' are both managed; 'kiro' is not + await runPreflightQuotaCheck('agy', ['gemini', 'kiro']); + expect(mockHandleQuotaCheck).toHaveBeenCalledWith('gemini'); + expect(mockHandleQuotaCheck).not.toHaveBeenCalledWith('kiro'); + }); +}); + +// ── runAccountSafetyGuards ──────────────────────────────────────────────────── + +describe('runAccountSafetyGuards', () => { + beforeEach(() => jest.clearAllMocks()); + + it('delegates to applyAccountSafetyGuards with correct args', () => { + runAccountSafetyGuards('gemini', ['codex']); + expect(mockApplyAccountSafetyGuards).toHaveBeenCalledWith('gemini', ['codex']); + }); +}); + +// ── ensureModelConfiguration ────────────────────────────────────────────────── + +describe('ensureModelConfiguration', () => { + beforeEach(() => jest.clearAllMocks()); + + it('non-composite provider with model support → configureProviderModel called', async () => { + const cfg = { isComposite: false, customSettingsPath: undefined } as ExecutorConfig; + await ensureModelConfiguration('gemini', cfg, false); + expect(mockConfigureProviderModel).toHaveBeenCalledWith('gemini', false, undefined); + }); + + it('composite variant → configureProviderModel NOT called', async () => { + const cfg = { isComposite: true } as ExecutorConfig; + await ensureModelConfiguration('gemini', cfg, false); + expect(mockConfigureProviderModel).not.toHaveBeenCalled(); + }); + + it('agy (has model support) → configureProviderModel IS called', async () => { + // agy IS in MODEL_CATALOG so supportsModelConfig returns true + const cfg = { isComposite: false } as ExecutorConfig; + await ensureModelConfiguration('agy', cfg, false); + expect(mockConfigureProviderModel).toHaveBeenCalled(); + }); + + it('codex non-composite → reconcileCodexModelForActivePlan called', async () => { + const cfg = { isComposite: false } as ExecutorConfig; + await ensureModelConfiguration('codex', cfg, false); + expect(mockReconcileCodexModel).toHaveBeenCalled(); + }); + + it('codex composite → reconcileCodexModelForActivePlan NOT called', async () => { + const cfg = { isComposite: true } as ExecutorConfig; + await ensureModelConfiguration('codex', cfg, false); + expect(mockReconcileCodexModel).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cliproxy/executor/__tests__/browser-launch-setup.test.ts b/src/cliproxy/executor/__tests__/browser-launch-setup.test.ts new file mode 100644 index 00000000..574cfe75 --- /dev/null +++ b/src/cliproxy/executor/__tests__/browser-launch-setup.test.ts @@ -0,0 +1,184 @@ +/** + * Unit tests for browser-launch-setup.ts (Phase 04) + * + * Tests cover: + * - resolveBrowserLaunchFlags: no flags (default), --browser-launch override, + * blocked override warning emitted, process.exit on parse error + * - resolveBrowserRuntime: no attach (disabled), active runtime env, MCP sync + * error propagation + * + * Strategy: mock the utils/browser and unified-config-loader modules so that + * no real browser detection or file I/O occurs. + */ + +import { describe, expect, it, jest, beforeEach, afterEach, mock } from 'bun:test'; + +// ── helpers ────────────────────────────────────────────────────────────────── + +/** Minimal BrowserConfig stub */ +function makeBrowserConfig(enabled = false, policy: 'auto' | 'always' | 'never' = 'auto'): object { + return { + claude: { enabled, policy, user_data_dir: '', devtools_port: 9222 }, + codex: { enabled: false, policy: 'auto' }, + }; +} + +// ── resolveBrowserLaunchFlags — no flags ────────────────────────────────────── + +describe('resolveBrowserLaunchFlags — no browser flags', () => { + it('returns undefined override and passes args through unchanged', async () => { + mock.module('../../utils/browser', () => ({ + appendBrowserToolArgs: (a: string[]) => a, + resolveBrowserLaunchFlagResolution: (_args: string[]) => ({ + override: undefined, + argsWithoutFlags: _args, + }), + getBlockedBrowserOverrideWarning: () => null, + getEffectiveClaudeBrowserAttachConfig: () => ({ enabled: false }), + resolveBrowserExposure: () => ({ exposeForLaunch: false }), + ensureBrowserMcpOrThrow: () => true, + resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }), + syncBrowserMcpToConfigDir: () => true, + })); + mock.module('../../../config/unified-config-loader', () => ({ + getBrowserConfig: () => makeBrowserConfig(false), + loadOrCreateUnifiedConfig: () => ({}), + getThinkingConfig: () => ({}), + })); + + const { resolveBrowserLaunchFlags } = await import('../browser-launch-setup'); + const args = ['--model', 'claude-opus-4-5']; + const result = resolveBrowserLaunchFlags(args); + expect(result.browserLaunchOverride).toBeUndefined(); + expect(result.argsWithoutBrowserFlags).toEqual(args); + }); +}); + +// ── resolveBrowserLaunchFlags — --browser-launch override ───────────────────── + +describe('resolveBrowserLaunchFlags — with browser-launch override', () => { + it('returns override and strips the browser flag from args', async () => { + mock.module('../../utils/browser', () => ({ + appendBrowserToolArgs: (a: string[]) => a, + resolveBrowserLaunchFlagResolution: (_args: string[]) => ({ + override: 'force-enable' as const, + argsWithoutFlags: ['--model', 'claude-opus-4-5'], + }), + getBlockedBrowserOverrideWarning: () => null, + getEffectiveClaudeBrowserAttachConfig: () => ({ enabled: true }), + resolveBrowserExposure: () => ({ exposeForLaunch: true }), + ensureBrowserMcpOrThrow: () => true, + resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }), + syncBrowserMcpToConfigDir: () => true, + })); + mock.module('../../../config/unified-config-loader', () => ({ + getBrowserConfig: () => makeBrowserConfig(true, 'auto'), + loadOrCreateUnifiedConfig: () => ({}), + getThinkingConfig: () => ({}), + })); + + const { resolveBrowserLaunchFlags } = await import('../browser-launch-setup'); + const result = resolveBrowserLaunchFlags(['--browser-launch', '--model', 'claude-opus-4-5']); + expect(result.browserLaunchOverride).toBe('force-enable'); + expect(result.argsWithoutBrowserFlags).toEqual(['--model', 'claude-opus-4-5']); + }); +}); + +// ── resolveBrowserLaunchFlags — blocked override warning emitted ────────────── + +describe('resolveBrowserLaunchFlags — blocked override warning', () => { + let stderrSpy: ReturnType; + + beforeEach(() => { + stderrSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + stderrSpy.mockRestore(); + }); + + it('emits warn() when getBlockedBrowserOverrideWarning returns a message', async () => { + mock.module('../../utils/browser', () => ({ + appendBrowserToolArgs: (a: string[]) => a, + resolveBrowserLaunchFlagResolution: (args: string[]) => ({ + override: 'force-enable' as const, + argsWithoutFlags: args, + }), + getBlockedBrowserOverrideWarning: () => 'Browser override is blocked by policy', + getEffectiveClaudeBrowserAttachConfig: () => ({ enabled: false }), + resolveBrowserExposure: () => ({ exposeForLaunch: false }), + ensureBrowserMcpOrThrow: () => true, + resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }), + syncBrowserMcpToConfigDir: () => true, + })); + mock.module('../../../config/unified-config-loader', () => ({ + getBrowserConfig: () => makeBrowserConfig(false, 'never'), + loadOrCreateUnifiedConfig: () => ({}), + getThinkingConfig: () => ({}), + })); + + const { resolveBrowserLaunchFlags } = await import('../browser-launch-setup'); + resolveBrowserLaunchFlags(['--model', 'claude-opus-4-5']); + expect(stderrSpy).toHaveBeenCalledTimes(1); + expect(stderrSpy.mock.calls[0][0]).toContain('Browser override is blocked by policy'); + }); +}); + +// ── resolveBrowserRuntime — attach disabled ─────────────────────────────────── + +describe('resolveBrowserRuntime — attach disabled', () => { + it('returns undefined browserRuntimeEnv when browser attach is disabled', async () => { + mock.module('../../utils/browser', () => ({ + appendBrowserToolArgs: (a: string[]) => a, + resolveBrowserLaunchFlagResolution: (a: string[]) => ({ + override: undefined, + argsWithoutFlags: a, + }), + getBlockedBrowserOverrideWarning: () => null, + getEffectiveClaudeBrowserAttachConfig: () => ({ enabled: false }), + resolveBrowserExposure: () => ({ exposeForLaunch: false }), + ensureBrowserMcpOrThrow: () => true, + resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }), + syncBrowserMcpToConfigDir: () => true, + })); + mock.module('../../../config/unified-config-loader', () => ({ + getBrowserConfig: () => makeBrowserConfig(false), + loadOrCreateUnifiedConfig: () => ({}), + getThinkingConfig: () => ({}), + })); + + const { resolveBrowserRuntime } = await import('../browser-launch-setup'); + const result = await resolveBrowserRuntime(undefined, undefined); + expect(result.browserRuntimeEnv).toBeUndefined(); + }); +}); + +// ── resolveBrowserRuntime — active runtime env ──────────────────────────────── + +describe('resolveBrowserRuntime — active runtime env', () => { + it('returns runtimeEnv when browser attach resolves successfully', async () => { + const fakeRuntimeEnv = { CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1:9222/json' }; + mock.module('../../utils/browser', () => ({ + appendBrowserToolArgs: (a: string[]) => a, + resolveBrowserLaunchFlagResolution: (a: string[]) => ({ + override: 'force-enable' as const, + argsWithoutFlags: a, + }), + getBlockedBrowserOverrideWarning: () => null, + getEffectiveClaudeBrowserAttachConfig: () => ({ enabled: true }), + resolveBrowserExposure: () => ({ exposeForLaunch: true }), + ensureBrowserMcpOrThrow: () => true, + resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: fakeRuntimeEnv }), + syncBrowserMcpToConfigDir: () => true, + })); + mock.module('../../../config/unified-config-loader', () => ({ + getBrowserConfig: () => makeBrowserConfig(true, 'always'), + loadOrCreateUnifiedConfig: () => ({}), + getThinkingConfig: () => ({}), + })); + + const { resolveBrowserRuntime } = await import('../browser-launch-setup'); + const result = await resolveBrowserRuntime('force-enable', undefined); + expect(result.browserRuntimeEnv).toEqual(fakeRuntimeEnv); + }); +}); diff --git a/src/cliproxy/executor/__tests__/claude-launcher.test.ts b/src/cliproxy/executor/__tests__/claude-launcher.test.ts new file mode 100644 index 00000000..535bef1f --- /dev/null +++ b/src/cliproxy/executor/__tests__/claude-launcher.test.ts @@ -0,0 +1,217 @@ +/** + * Tests for claude-launcher.ts — Phase 09 + * + * Verifies: + * - spawn called with expected args and env + * - Windows shell escaping path + * - Cleanup handlers registered (setupCleanupHandlers called) + * + * Strategy: mock child_process.spawn and all side-effectful dependencies so + * no real processes are started. + */ + +import { describe, expect, it, jest, beforeEach, afterEach, mock } from 'bun:test'; +import type { ChildProcess } from 'child_process'; +import type { ExecutorConfig } from '../../types'; + +// ── Spawn mock ──────────────────────────────────────────────────────────────── + +const mockSpawnResult = { + pid: 42, + on: jest.fn(), + stdout: null, + stderr: null, +} as unknown as ChildProcess; + +const mockSpawn = jest.fn().mockReturnValue(mockSpawnResult); + +mock.module('child_process', () => ({ + spawn: mockSpawn, +})); + +// ── Dependency mocks ────────────────────────────────────────────────────────── + +const mockEscapeShellArg = jest.fn((s: string) => `"${s}"`); +const mockGetWindowsEscapedCommandShell = jest.fn().mockReturnValue('cmd.exe'); + +mock.module('../../utils/shell-executor', () => ({ + escapeShellArg: mockEscapeShellArg, + getWindowsEscapedCommandShell: mockGetWindowsEscapedCommandShell, +})); + +mock.module('../../config/config-generator', () => ({ + getProviderSettingsPath: jest.fn().mockReturnValue('/fake/.ccs/settings/gemini.json'), + CLIPROXY_DEFAULT_PORT: 8317, + generateConfig: jest.fn(), + getProviderConfig: jest.fn(), +})); + +mock.module('../../utils/websearch-manager', () => ({ + appendThirdPartyWebSearchToolArgs: (args: string[]) => args, + createWebSearchTraceContext: jest.fn().mockReturnValue({}), + ensureWebSearchMcpOrThrow: jest.fn(), + displayWebSearchStatus: jest.fn(), + getWebSearchHookEnv: jest.fn().mockReturnValue({}), +})); + +mock.module('../../utils/image-analysis', () => ({ + appendThirdPartyImageAnalysisToolArgs: (args: string[]) => [...args, '--mcp-image-analysis'], + syncImageAnalysisMcpToConfigDir: jest.fn(), + ensureImageAnalysisMcpOrThrow: jest.fn().mockReturnValue(true), +})); + +mock.module('../../utils/browser', () => ({ + appendBrowserToolArgs: (args: string[]) => [...args, '--browser'], +})); + +mock.module('../accounts/account-manager', () => ({ + getDefaultAccount: jest.fn().mockReturnValue(null), +})); + +const mockSetupCleanupHandlers = jest.fn(); +mock.module('../session-bridge', () => ({ + setupCleanupHandlers: mockSetupCleanupHandlers, + checkOrJoinProxy: jest.fn(), + registerProxySession: jest.fn(), +})); + +const mockResolveRuntimeQuotaMonitorProviders = jest.fn().mockReturnValue([]); +mock.module('../account-resolution', () => ({ + resolveRuntimeQuotaMonitorProviders: mockResolveRuntimeQuotaMonitorProviders, + resolveAccounts: jest.fn(), +})); + +// Dynamic import for quota-manager +mock.module('../quota/quota-manager', () => ({ + startQuotaMonitor: jest.fn(), + stopQuotaMonitor: jest.fn(), +})); + +// ── Subject under test ──────────────────────────────────────────────────────── + +import { launchClaude } from '../claude-launcher'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeCfg(overrides: Partial = {}): ExecutorConfig { + return { + port: 8317, + timeout: 5000, + verbose: false, + pollInterval: 100, + ...overrides, + } as ExecutorConfig; +} + +function baseContext(overrides: object = {}) { + return { + claudeCli: '/usr/local/bin/claude', + claudeArgs: ['chat', '--model', 'gemini-2.5-pro'], + env: { ANTHROPIC_BASE_URL: 'http://localhost:8317' } as NodeJS.ProcessEnv, + cfg: makeCfg(), + provider: 'gemini' as const, + compositeProviders: [] as string[], + skipLocalAuth: true, + sessionId: undefined, + imageAnalysisMcpReady: false, + browserRuntimeEnv: undefined, + inheritedClaudeConfigDir: undefined, + codexReasoningProxy: null, + toolSanitizationProxy: null, + httpsTunnel: null, + verbose: false, + ...overrides, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('launchClaude', () => { + beforeEach(() => { + mockSpawn.mockClear(); + mockSetupCleanupHandlers.mockClear(); + mockEscapeShellArg.mockClear(); + }); + + it('calls spawn with claudeCli and includes --settings arg', async () => { + const ctx = baseContext(); + await launchClaude(ctx); + + expect(mockSpawn).toHaveBeenCalledTimes(1); + const [cmd, spawnArgs] = mockSpawn.mock.calls[0] as [string, string[]]; + expect(cmd).toBe('/usr/local/bin/claude'); + expect(spawnArgs).toContain('--settings'); + }); + + it('inherits process env merged with provided env', async () => { + const ctx = baseContext({ env: { CUSTOM_KEY: 'custom-value' } as NodeJS.ProcessEnv }); + await launchClaude(ctx); + + const spawnOpts = mockSpawn.mock.calls[0][2] as { env: NodeJS.ProcessEnv }; + expect(spawnOpts.env?.CUSTOM_KEY).toBe('custom-value'); + }); + + it('passes stdio: inherit', async () => { + await launchClaude(baseContext()); + const spawnOpts = mockSpawn.mock.calls[0][2] as { stdio: string }; + expect(spawnOpts.stdio).toBe('inherit'); + }); + + it('appends image analysis args when imageAnalysisMcpReady=true', async () => { + await launchClaude(baseContext({ imageAnalysisMcpReady: true })); + const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; + expect(spawnArgs).toContain('--mcp-image-analysis'); + }); + + it('does not append image analysis args when imageAnalysisMcpReady=false', async () => { + await launchClaude(baseContext({ imageAnalysisMcpReady: false })); + const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; + expect(spawnArgs).not.toContain('--mcp-image-analysis'); + }); + + it('appends browser tool args when browserRuntimeEnv is set', async () => { + await launchClaude( + baseContext({ browserRuntimeEnv: { CCS_BROWSER: '1' } as NodeJS.ProcessEnv }) + ); + const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; + expect(spawnArgs).toContain('--browser'); + }); + + it('registers cleanup handlers after spawn', async () => { + await launchClaude(baseContext()); + expect(mockSetupCleanupHandlers).toHaveBeenCalledTimes(1); + // First arg should be the ChildProcess returned by spawn + expect(mockSetupCleanupHandlers.mock.calls[0][0]).toBe(mockSpawnResult); + }); + + it('returns the ChildProcess from spawn', async () => { + const result = await launchClaude(baseContext()); + expect(result).toBe(mockSpawnResult); + }); + + describe('Windows shell escaping', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + }); + + it('uses shell mode for .cmd executables on Windows', async () => { + await launchClaude(baseContext({ claudeCli: 'C:\\tools\\claude.cmd' })); + const spawnOpts = mockSpawn.mock.calls[0][2] as { shell: string | boolean }; + // shell property should be set (cmd.exe from mock) + expect(spawnOpts.shell).toBeTruthy(); + }); + + it('skips shell mode for non-script executables on Windows', async () => { + await launchClaude(baseContext({ claudeCli: 'C:\\tools\\claude.exe' })); + // spawn should be called with separate args array, not a shell cmd string + const [, secondArg] = mockSpawn.mock.calls[0]; + expect(Array.isArray(secondArg)).toBe(true); + }); + }); +}); diff --git a/src/cliproxy/executor/__tests__/index-characterization.test.ts b/src/cliproxy/executor/__tests__/index-characterization.test.ts new file mode 100644 index 00000000..f02155ea --- /dev/null +++ b/src/cliproxy/executor/__tests__/index-characterization.test.ts @@ -0,0 +1,249 @@ +/** + * Characterization tests — CLIProxy Executor (Phase 01) + * + * Goal: lock in the current observable behavior of the executor's flag-parsing + * and validation pipeline so that subsequent module extractions (Phases 03–10) + * can be proven non-behavior-changing. + * + * Approach: + * - Test the re-exported surface of index.ts to verify backwards compat. + * - Full execClaudeWithCLIProxy integration scenarios (spawn-level) require + * mocking the entire module graph including js-yaml / cli-table3 native + * deps not installed on this worktree. Those scenarios are marked it.skip + * with clear rationale; they will be enabled once bun install is run in CI. + * - The meaningful behavioral contracts (flag parsing, CCS flag filtering, + * validation guards) are fully tested via arg-parser.test.ts (Phase 02). + * These characterization tests deliberately duplicate the surface-level + * assertions to confirm index.ts still re-exports the same behavior. + * + * When to unskip the skipped tests: + * Run `bun install` in the worktree so js-yaml and cli-table3 are available, + * then remove the `.skip` and implement full spawn mocks with mock.module(). + */ + +import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +// ── Surface re-export verification ──────────────────────────────────────────── +// Confirm that readOptionValue / hasGitLabTokenLoginFlag / CCS_FLAGS / filterCcsFlags +// behave correctly. These are re-exported from index.ts; we import from arg-parser +// directly here because index.ts transitively loads js-yaml / cli-table3 native +// packages that are not installed in this worktree (no bun install run). +// The re-export contract is verified structurally by TypeScript (the export block +// in index.ts would fail tsc if the symbols were missing from arg-parser.ts). + +import { readOptionValue, hasGitLabTokenLoginFlag, CCS_FLAGS, filterCcsFlags } from '../arg-parser'; + +describe('index.ts re-export surface (backwards compatibility)', () => { + // ── readOptionValue ──────────────────────────────────────────────────────── + + describe('readOptionValue', () => { + it('parses split-token form: --flag value', () => { + expect( + readOptionValue( + ['--kiro-idc-start-url', 'https://d-123.awsapps.com/start'], + '--kiro-idc-start-url' + ) + ).toEqual({ present: true, value: 'https://d-123.awsapps.com/start', missingValue: false }); + }); + + it('parses equals form: --flag=value', () => { + expect(readOptionValue(['--kiro-idc-flow=device'], '--kiro-idc-flow')).toEqual({ + present: true, + value: 'device', + missingValue: false, + }); + }); + + it('returns missingValue=true for bare flag with no value', () => { + expect(readOptionValue(['--kiro-idc-region'], '--kiro-idc-region')).toEqual({ + present: true, + value: undefined, + missingValue: true, + }); + }); + + it('returns missingValue=true for empty equals form', () => { + expect(readOptionValue(['--kiro-idc-flow='], '--kiro-idc-flow')).toEqual({ + present: true, + value: undefined, + missingValue: true, + }); + }); + + it('returns present=false when flag absent', () => { + expect(readOptionValue(['--other'], '--kiro-idc-region')).toEqual({ + present: false, + missingValue: false, + }); + }); + }); + + // ── hasGitLabTokenLoginFlag ──────────────────────────────────────────────── + + describe('hasGitLabTokenLoginFlag', () => { + it('detects --gitlab-token-login', () => { + expect(hasGitLabTokenLoginFlag(['--gitlab-token-login'])).toBe(true); + }); + + it('detects --token-login', () => { + expect(hasGitLabTokenLoginFlag(['--token-login'])).toBe(true); + }); + + it('returns false when neither flag present', () => { + expect(hasGitLabTokenLoginFlag(['--gitlab-url', 'https://gitlab.example.com'])).toBe(false); + }); + }); + + // ── CCS_FLAGS + filterCcsFlags ───────────────────────────────────────────── + + describe('CCS_FLAGS', () => { + it('is a non-empty readonly array', () => { + expect(Array.isArray(CCS_FLAGS)).toBe(true); + expect(CCS_FLAGS.length).toBeGreaterThan(0); + }); + + it('contains the core CCS flags', () => { + const expected = [ + '--auth', + '--accounts', + '--use', + '--thinking', + '--1m', + '--no-1m', + '--proxy-host', + ]; + for (const flag of expected) { + expect(CCS_FLAGS).toContain(flag); + } + }); + }); + + describe('filterCcsFlags', () => { + it('removes --auth and passes through non-CCS args', () => { + expect(filterCcsFlags(['--auth', '--verbose'])).toEqual(['--verbose']); + }); + + it('removes --use and its value argument', () => { + expect(filterCcsFlags(['--use', 'myaccount', '--print'])).toEqual(['--print']); + }); + + it('removes --kiro-auth-method= inline form', () => { + expect(filterCcsFlags(['--kiro-auth-method=aws', '--model', 'claude-3'])).toEqual([ + '--model', + 'claude-3', + ]); + }); + + it('removes --thinking= inline form', () => { + expect(filterCcsFlags(['--thinking=high', '--dangerously-skip-permissions'])).toEqual([ + '--dangerously-skip-permissions', + ]); + }); + + it('preserves non-CCS args', () => { + const args = ['--model', 'claude-opus-4-5', '--print', 'hello world']; + expect(filterCcsFlags(args)).toEqual(args); + }); + + it('removes empty args list to empty result', () => { + expect(filterCcsFlags([])).toEqual([]); + }); + }); +}); + +// ── execClaudeWithCLIProxy integration scenarios (skipped — native deps) ────── +// +// These scenarios characterize the end-to-end spawn behavior. +// They are skipped because the worktree's bun install has not been run, +// so js-yaml and cli-table3 are missing. Enable after `bun install`. +// +// Mock strategy (for when unskipped): +// - mock.module('child_process', ...) to capture spawn args +// - mock.module('../auth/auth-handler', ...) to stub isAuthenticated + triggerOAuth +// - mock.module('../services/remote-proxy-client', ...) to stub checkRemoteProxy +// - mock.module('../binary-manager', ...) to stub ensureCLIProxyBinary +// - mock.module('../../config/unified-config-loader', ...) to return minimal config +// - Set CCS_HOME to a temp dir to avoid touching ~/.ccs + +describe.skip('execClaudeWithCLIProxy — integration scenarios (requires bun install)', () => { + let tmpHome = ''; + let fakeClaudePath = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-exec-characterization-')); + fakeClaudePath = path.join(tmpHome, 'fake-claude.sh'); + fs.writeFileSync(fakeClaudePath, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + fs.chmodSync(fakeClaudePath, 0o755); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + // Scenario 1: --accounts listing exits without spawning Claude + it('--accounts exits with code 0 and does not spawn Claude', async () => { + // TODO: mock child_process.spawn, assert it was NOT called + // TODO: mock getProviderAccounts to return [] + // TODO: assert process.exit called with 0 + }); + + // Scenario 2: invalid kiro flag combination → exits code 1 + it('invalid kiro flag combination calls process.exit(1)', async () => { + // --kiro-auth-method used with non-kiro provider + const exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined as never) as typeof process.exit); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + try { + // TODO: import execClaudeWithCLIProxy and call with gemini + --kiro-auth-method=aws + // await execClaudeWithCLIProxy(fakeClaudePath, 'gemini', ['--kiro-auth-method=aws'], {}); + // expect(exitSpy).toHaveBeenCalledWith(1); + } finally { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); + + // Scenario 3: --auth flow exits without spawning Claude + it('--auth (forceAuth) exits after OAuth without spawning Claude CLI', async () => { + // TODO: mock triggerOAuth to resolve true + // TODO: assert spawn not called, process.exit(0) called + }); + + // Scenario 4: gemini local profile — verify spawn args subset + it('gemini local profile — spawn includes ANTHROPIC_BASE_URL pointing to local port', async () => { + // TODO: capture spawn(claudeCli, args, opts) call + // TODO: assert opts.env.ANTHROPIC_BASE_URL includes 'localhost' or '127.0.0.1' + // TODO: assert args does NOT include CCS-specific flags + }); + + // Scenario 5: codex local with reasoning proxy — spawn ordering + it('codex local — codex reasoning proxy started before Claude spawn', async () => { + // TODO: assert CodexReasoningProxy.start() called before spawn + // TODO: assert env.ANTHROPIC_BASE_URL points to reasoning proxy port + }); + + // Scenario 6: composite remote https tunnel — tool sanitization started + it('composite remote https — tool sanitization proxy and HTTPS tunnel started', async () => { + // TODO: mock checkRemoteProxy to return { reachable: true, latencyMs: 5 } + // TODO: assert HttpsTunnelProxy.start() called (when shouldStartHttpsTunnel returns true) + // TODO: assert ToolSanitizationProxy.start() called + }); + + // Scenario 7: kiro with --kiro-auth-method — validation guard passes + it('kiro provider + --kiro-auth-method=aws — validation passes and proceeds to auth', async () => { + // TODO: mock triggerOAuth, assert called with { kiroMethod: 'aws' } + }); +}); diff --git a/src/cliproxy/executor/__tests__/model-warnings.test.ts b/src/cliproxy/executor/__tests__/model-warnings.test.ts new file mode 100644 index 00000000..054b3a7a --- /dev/null +++ b/src/cliproxy/executor/__tests__/model-warnings.test.ts @@ -0,0 +1,227 @@ +/** + * Tests for model-warnings.ts — Phase 08 + * + * Verifies that warnBrokenModels emits warnings for broken models and is + * silent for healthy ones, covering both simple and composite providers. + */ + +import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test'; + +// ── Stubs ───────────────────────────────────────────────────────────────────── + +// getCurrentModel: return whatever the test configures +const mockGetCurrentModel = jest.fn(); +// isModelBroken: return false by default +const mockIsModelBroken = jest.fn().mockReturnValue(false); +const mockGetModelIssueUrl = jest + .fn() + .mockReturnValue(undefined); +const mockFindModel = jest + .fn<{ name: string } | undefined, [string, string]>() + .mockReturnValue(undefined); +const mockGetSuggestedReplacementModel = jest + .fn() + .mockReturnValue(undefined); + +jest.mock('../model-warnings', () => { + // We test the real implementation — only mock its dependencies + return jest.requireActual('../model-warnings'); +}); + +jest.mock('../../config/model-config', () => ({ + getCurrentModel: mockGetCurrentModel, +})); + +jest.mock('../../cliproxy/model-catalog', () => ({ + isModelBroken: mockIsModelBroken, + getModelIssueUrl: mockGetModelIssueUrl, + findModel: mockFindModel, + getSuggestedReplacementModel: mockGetSuggestedReplacementModel, +})); + +// We import from real path after jest.mock so actual module is used +import { warnBrokenModels } from '../model-warnings'; +import type { ExecutorConfig } from '../../types'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function makeSimpleCfg(overrides: Partial = {}): ExecutorConfig { + return { + port: 8317, + timeout: 5000, + verbose: false, + pollInterval: 100, + isComposite: false, + ...overrides, + } as ExecutorConfig; +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe('warnBrokenModels', () => { + let errorSpy: ReturnType; + + beforeEach(() => { + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockGetCurrentModel.mockReset(); + mockIsModelBroken.mockReturnValue(false); + mockGetModelIssueUrl.mockReturnValue(undefined); + mockFindModel.mockReturnValue(undefined); + mockGetSuggestedReplacementModel.mockReturnValue(undefined); + }); + + afterEach(() => { + errorSpy.mockRestore(); + }); + + it('does not warn when no model is configured', () => { + mockGetCurrentModel.mockReturnValue(undefined); + + warnBrokenModels({ + provider: 'gemini', + cfg: makeSimpleCfg(), + compositeProviders: [], + skipLocalAuth: false, + }); + + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('does not warn when model is healthy', () => { + mockGetCurrentModel.mockReturnValue('gemini-2.5-pro'); + mockIsModelBroken.mockReturnValue(false); + + warnBrokenModels({ + provider: 'gemini', + cfg: makeSimpleCfg(), + compositeProviders: [], + skipLocalAuth: false, + }); + + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('warns when model is broken (no replacement, no issue url)', () => { + mockGetCurrentModel.mockReturnValue('gemini-old'); + mockIsModelBroken.mockReturnValue(true); + mockFindModel.mockReturnValue({ name: 'Gemini Old' } as { name: string }); + + warnBrokenModels({ + provider: 'gemini', + cfg: makeSimpleCfg(), + compositeProviders: [], + skipLocalAuth: false, + }); + + const calls = errorSpy.mock.calls.map((c) => String(c[0])); + expect(calls.some((m) => m.includes('has known issues with Claude Code'))).toBe(true); + expect(calls.some((m) => m.includes('Tool calls will fail'))).toBe(true); + }); + + it('includes replacement model suggestion when available', () => { + mockGetCurrentModel.mockReturnValue('gemini-old'); + mockIsModelBroken.mockReturnValue(true); + mockGetSuggestedReplacementModel.mockReturnValue('gemini-2.5-pro'); + + warnBrokenModels({ + provider: 'gemini', + cfg: makeSimpleCfg(), + compositeProviders: [], + skipLocalAuth: false, + }); + + const calls = errorSpy.mock.calls.map((c) => String(c[0])); + expect(calls.some((m) => m.includes('gemini-2.5-pro'))).toBe(true); + }); + + it('includes tracking URL when issue url is available', () => { + mockGetCurrentModel.mockReturnValue('gemini-old'); + mockIsModelBroken.mockReturnValue(true); + mockGetModelIssueUrl.mockReturnValue('https://github.com/issues/123'); + + warnBrokenModels({ + provider: 'gemini', + cfg: makeSimpleCfg(), + compositeProviders: [], + skipLocalAuth: false, + }); + + const calls = errorSpy.mock.calls.map((c) => String(c[0])); + expect(calls.some((m) => m.includes('https://github.com/issues/123'))).toBe(true); + }); + + it('includes remote proxy note when skipLocalAuth=true', () => { + mockGetCurrentModel.mockReturnValue('gemini-old'); + mockIsModelBroken.mockReturnValue(true); + + warnBrokenModels({ + provider: 'gemini', + cfg: makeSimpleCfg(), + compositeProviders: [], + skipLocalAuth: true, + }); + + const calls = errorSpy.mock.calls.map((c) => String(c[0])); + expect(calls.some((m) => m.includes('remote proxy'))).toBe(true); + }); + + it('includes --config suggestion when skipLocalAuth=false', () => { + mockGetCurrentModel.mockReturnValue('gemini-old'); + mockIsModelBroken.mockReturnValue(true); + + warnBrokenModels({ + provider: 'gemini', + cfg: makeSimpleCfg(), + compositeProviders: [], + skipLocalAuth: false, + }); + + const calls = errorSpy.mock.calls.map((c) => String(c[0])); + expect(calls.some((m) => m.includes('--config'))).toBe(true); + }); + + describe('composite variants', () => { + function makeCompositeCfg(): ExecutorConfig { + return { + port: 8317, + timeout: 5000, + verbose: false, + pollInterval: 100, + isComposite: true, + compositeTiers: { + opus: { provider: 'gemini', model: 'gemini-opus-old' }, + sonnet: { provider: 'gemini', model: 'gemini-sonnet-ok' }, + haiku: { provider: 'gemini', model: 'gemini-haiku-ok' }, + }, + } as unknown as ExecutorConfig; + } + + it('warns for broken composite tier', () => { + mockIsModelBroken.mockImplementation((_p, m) => m === 'gemini-opus-old'); + mockFindModel.mockReturnValue({ name: 'Gemini Opus Old' } as { name: string }); + + warnBrokenModels({ + provider: 'gemini', + cfg: makeCompositeCfg(), + compositeProviders: ['gemini'], + skipLocalAuth: false, + }); + + const calls = errorSpy.mock.calls.map((c) => String(c[0])); + expect(calls.some((m) => m.includes('opus tier'))).toBe(true); + }); + + it('does not warn for healthy composite tiers', () => { + mockIsModelBroken.mockReturnValue(false); + + warnBrokenModels({ + provider: 'gemini', + cfg: makeCompositeCfg(), + compositeProviders: ['gemini'], + skipLocalAuth: false, + }); + + expect(errorSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/cliproxy/executor/__tests__/proxy-chain-builder.test.ts b/src/cliproxy/executor/__tests__/proxy-chain-builder.test.ts new file mode 100644 index 00000000..a39be32e --- /dev/null +++ b/src/cliproxy/executor/__tests__/proxy-chain-builder.test.ts @@ -0,0 +1,273 @@ +/** + * Unit tests for proxy-chain-builder.ts (Phase 07) + * + * Mocking strategy: proxy constructors are injected via the _ToolSanitizationProxy + * and _CodexReasoningProxy fields on ProxyChainContext (dependency-injection escape + * hatch added for testability). This avoids Bun module-cache limitations with + * mock.module / jest.mock and never spins up real HTTP servers. + * + * Tests cover: + * - Tool sanitization proxy started when ANTHROPIC_BASE_URL is set + * - Tool sanitization proxy skipped when ANTHROPIC_BASE_URL is absent + * - Tool sanitization proxy start failure swallowed (null returned, verbose warn) + * - Codex reasoning proxy started for single-provider codex + * - Codex reasoning proxy skipped for composite codex (cfg.isComposite true) + * - Codex reasoning proxy skipped for non-codex provider + * - Codex reasoning proxy uses post-sanitization URL when tool-san is active + * - Codex reasoning proxy start failure swallowed (null returned) + * - All results null when ANTHROPIC_BASE_URL absent + */ + +import { describe, expect, it, jest, beforeEach } from 'bun:test'; +import { buildProxyChain } from '../proxy-chain-builder'; +import type { ProxyChainContext } from '../proxy-chain-builder'; +import type { ToolSanitizationProxy } from '../../proxy/tool-sanitization-proxy'; +import type { CodexReasoningProxy } from '../../ai-providers/codex-reasoning-proxy'; + +// ── Stub factory ────────────────────────────────────────────────────────────── + +type ProxyStub = { start: jest.Mock; stop: jest.Mock; _port?: number }; + +function makeStubCtor(resolvePort: number | (() => Promise | number)): { + ctor: jest.Mock; + instance: ProxyStub; +} { + const instance: ProxyStub = { + start: jest + .fn() + .mockImplementation( + typeof resolvePort === 'function' ? resolvePort : () => Promise.resolve(resolvePort) + ), + stop: jest.fn(), + }; + const ctor = jest.fn().mockReturnValue(instance); + return { ctor, instance }; +} + +function makeFailCtor(message: string): { ctor: jest.Mock; instance: ProxyStub } { + const instance: ProxyStub = { + start: jest.fn().mockRejectedValue(new Error(message)), + stop: jest.fn(), + }; + const ctor = jest.fn().mockReturnValue(instance); + return { ctor, instance }; +} + +// ── Context helpers ─────────────────────────────────────────────────────────── + +function makeThinkingCfg() { + return { mode: 'auto' as const, override: undefined }; +} + +function makeProxyConfig() { + return { + mode: 'local' as const, + port: 8317, + protocol: 'http' as const, + fallbackEnabled: false, + autoStartLocal: true, + remoteOnly: false, + forceLocal: false, + }; +} + +function makeCfg(overrides: Partial<{ isComposite: boolean }> = {}) { + return { port: 8317, timeout: 5000, verbose: false, pollInterval: 100, ...overrides }; +} + +// Stub ctors shared across tests, reset in beforeEach +let defaultToolSan: ReturnType; +let defaultCodex: ReturnType; + +beforeEach(() => { + defaultToolSan = makeStubCtor(19001); + defaultCodex = makeStubCtor(19002); +}); + +function baseCtx(overrides: Partial = {}): ProxyChainContext { + return { + provider: 'gemini', + useRemoteProxy: false, + proxyConfig: makeProxyConfig(), + cfg: makeCfg(), + initialEnvVars: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317' }, + thinkingOverride: undefined, + thinkingCfg: makeThinkingCfg(), + verbose: false, + log: () => {}, + _ToolSanitizationProxy: defaultToolSan.ctor as unknown as new ( + cfg: object + ) => ToolSanitizationProxy, + _CodexReasoningProxy: defaultCodex.ctor as unknown as new (cfg: object) => CodexReasoningProxy, + ...overrides, + }; +} + +// ── Tool sanitization: started when ANTHROPIC_BASE_URL set ─────────────────── + +describe('buildProxyChain — tool sanitization: started when ANTHROPIC_BASE_URL set', () => { + it('starts proxy and returns instance + port', async () => { + const { ctor, instance } = makeStubCtor(13001); + + const result = await buildProxyChain(baseCtx({ _ToolSanitizationProxy: ctor as never })); + + expect(ctor).toHaveBeenCalledTimes(1); + expect(result.toolSanitizationProxy).toBe(instance); + expect(result.toolSanitizationPort).toBe(13001); + }); +}); + +// ── Tool sanitization: skipped when ANTHROPIC_BASE_URL absent ──────────────── + +describe('buildProxyChain — tool sanitization: skipped when no ANTHROPIC_BASE_URL', () => { + it('returns null proxy and port without constructing', async () => { + const { ctor } = makeStubCtor(13001); + + const result = await buildProxyChain( + baseCtx({ initialEnvVars: {}, _ToolSanitizationProxy: ctor as never }) + ); + + expect(ctor).not.toHaveBeenCalled(); + expect(result.toolSanitizationProxy).toBeNull(); + expect(result.toolSanitizationPort).toBeNull(); + }); +}); + +// ── Tool sanitization: start failure swallowed ──────────────────────────────── + +describe('buildProxyChain — tool sanitization: start failure swallowed', () => { + it('returns null and emits verbose warn without throwing', async () => { + const { ctor } = makeFailCtor('port in use'); + const warnSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await buildProxyChain( + baseCtx({ verbose: true, _ToolSanitizationProxy: ctor as never }) + ); + + expect(result.toolSanitizationProxy).toBeNull(); + expect(result.toolSanitizationPort).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('port in use')); + warnSpy.mockRestore(); + }); +}); + +// ── Codex reasoning: started for single-provider codex ─────────────────────── + +describe('buildProxyChain — codex reasoning: started for single-provider codex', () => { + it('starts codex proxy and returns instance + port', async () => { + const sanCtorPair = makeStubCtor(13010); + const codexCtorPair = makeStubCtor(13011); + + const result = await buildProxyChain( + baseCtx({ + provider: 'codex', + cfg: makeCfg({ isComposite: false }), + _ToolSanitizationProxy: sanCtorPair.ctor as never, + _CodexReasoningProxy: codexCtorPair.ctor as never, + }) + ); + + expect(codexCtorPair.ctor).toHaveBeenCalledTimes(1); + expect(result.codexReasoningProxy).toBe(codexCtorPair.instance); + expect(result.codexReasoningPort).toBe(13011); + }); +}); + +// ── Codex reasoning: skipped for composite codex ───────────────────────────── + +describe('buildProxyChain — codex reasoning: skipped for composite codex', () => { + it('does not construct CodexReasoningProxy when cfg.isComposite is true', async () => { + const { ctor } = makeStubCtor(13020); + + const result = await buildProxyChain( + baseCtx({ + provider: 'codex', + cfg: makeCfg({ isComposite: true }), + _CodexReasoningProxy: ctor as never, + }) + ); + + expect(ctor).not.toHaveBeenCalled(); + expect(result.codexReasoningProxy).toBeNull(); + expect(result.codexReasoningPort).toBeNull(); + }); +}); + +// ── Codex reasoning: skipped for non-codex provider ────────────────────────── + +describe('buildProxyChain — codex reasoning: skipped for non-codex provider', () => { + it('does not construct CodexReasoningProxy for gemini', async () => { + const { ctor } = makeStubCtor(13030); + + await buildProxyChain(baseCtx({ provider: 'gemini', _CodexReasoningProxy: ctor as never })); + + expect(ctor).not.toHaveBeenCalled(); + }); +}); + +// ── Codex reasoning: uses post-sanitization URL ─────────────────────────────── + +describe('buildProxyChain — codex reasoning: uses post-sanitization base URL', () => { + it('passes http://127.0.0.1: as upstreamBaseUrl', async () => { + const sanCtorPair = makeStubCtor(13040); + const codexCtorPair = makeStubCtor(13041); + + await buildProxyChain( + baseCtx({ + provider: 'codex', + cfg: makeCfg({ isComposite: false }), + _ToolSanitizationProxy: sanCtorPair.ctor as never, + _CodexReasoningProxy: codexCtorPair.ctor as never, + }) + ); + + expect(codexCtorPair.ctor).toHaveBeenCalledWith( + expect.objectContaining({ upstreamBaseUrl: 'http://127.0.0.1:13040' }) + ); + }); +}); + +// ── Codex reasoning: start failure swallowed ───────────────────────────────── + +describe('buildProxyChain — codex reasoning: start failure swallowed', () => { + it('returns null codex proxy/port without throwing', async () => { + const sanCtorPair = makeStubCtor(13050); + const { ctor: codexFailCtor } = makeFailCtor('bind failed'); + + const result = await buildProxyChain( + baseCtx({ + provider: 'codex', + cfg: makeCfg({ isComposite: false }), + _ToolSanitizationProxy: sanCtorPair.ctor as never, + _CodexReasoningProxy: codexFailCtor as never, + }) + ); + + expect(result.codexReasoningProxy).toBeNull(); + expect(result.codexReasoningPort).toBeNull(); + }); +}); + +// ── All results null when no ANTHROPIC_BASE_URL ─────────────────────────────── + +describe('buildProxyChain — all results null when no ANTHROPIC_BASE_URL', () => { + it('returns four nulls for gemini without base URL', async () => { + const sanCtor = jest.fn(); + const codexCtor = jest.fn(); + + const result = await buildProxyChain( + baseCtx({ + initialEnvVars: {}, + _ToolSanitizationProxy: sanCtor as never, + _CodexReasoningProxy: codexCtor as never, + }) + ); + + expect(sanCtor).not.toHaveBeenCalled(); + expect(codexCtor).not.toHaveBeenCalled(); + expect(result.toolSanitizationProxy).toBeNull(); + expect(result.toolSanitizationPort).toBeNull(); + expect(result.codexReasoningProxy).toBeNull(); + expect(result.codexReasoningPort).toBeNull(); + }); +}); diff --git a/src/cliproxy/executor/__tests__/proxy-resolver.test.ts b/src/cliproxy/executor/__tests__/proxy-resolver.test.ts new file mode 100644 index 00000000..86af7137 --- /dev/null +++ b/src/cliproxy/executor/__tests__/proxy-resolver.test.ts @@ -0,0 +1,257 @@ +/** + * Unit tests for proxy-resolver.ts (Phase 03 extraction) + * + * Tests cover the proxy resolution + remote reachability + binary acquisition + * logic extracted from executor/index.ts. + */ + +import { beforeEach, describe, expect, it, jest } from 'bun:test'; +import type { ResolveExecutorProxyContext } from '../proxy-resolver'; +import type { ExecutorConfig } from '../../types'; +import type { UnifiedConfig } from '../../../config/schemas/unified-config'; + +// ── Module mocks ────────────────────────────────────────────────────────────── + +const mockEnsureCLIProxyBinary = jest.fn().mockResolvedValue('/usr/local/bin/cliproxy'); +const mockGetConfiguredBackend = jest.fn().mockReturnValue('original'); +const mockGetPlusBackendUnavailableMessage = jest.fn().mockReturnValue('Plus backend unavailable'); + +jest.mock('../../binary-manager', () => ({ + ensureCLIProxyBinary: mockEnsureCLIProxyBinary, + getConfiguredBackend: mockGetConfiguredBackend, + getPlusBackendUnavailableMessage: mockGetPlusBackendUnavailableMessage, +})); + +const mockCheckRemoteProxy = jest.fn(); +jest.mock('../../services/remote-proxy-client', () => ({ + checkRemoteProxy: mockCheckRemoteProxy, +})); + +jest.mock('../retry-handler', () => ({ + isNetworkError: jest.fn().mockReturnValue(false), + handleNetworkError: jest.fn(), +})); + +const mockResolveProxyConfig = jest.fn(); +jest.mock('../../proxy/proxy-config-resolver', () => ({ + resolveProxyConfig: mockResolveProxyConfig, +})); + +jest.mock('../../config/config-generator', () => ({ + CLIPROXY_DEFAULT_PORT: 8317, + validatePort: jest.fn((port: number | undefined) => port ?? 8317), +})); + +// ── Import after mocks ──────────────────────────────────────────────────────── + +const { resolveExecutorProxy } = await import('../proxy-resolver'); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeMinimalUnifiedConfig(): UnifiedConfig { + return { + cliproxy_server: undefined, + } as unknown as UnifiedConfig; +} + +function makeBaseCfg(): ExecutorConfig { + return { + port: 8317, + timeout: 5000, + verbose: false, + pollInterval: 100, + }; +} + +function makeContext( + overrides: Partial = {} +): ResolveExecutorProxyContext { + return { + unifiedConfig: makeMinimalUnifiedConfig(), + allProviders: ['gemini'], + verbose: false, + cfg: makeBaseCfg(), + log: jest.fn(), + ...overrides, + }; +} + +/** Mock resolveProxyConfig to return a local-mode config */ +function mockLocalProxyConfig(remainingArgs: string[] = []): void { + mockResolveProxyConfig.mockReturnValue({ + config: { + mode: 'local', + port: 8317, + protocol: 'http', + fallbackEnabled: false, + autoStartLocal: false, + remoteOnly: false, + forceLocal: true, + }, + remainingArgs, + }); +} + +/** Mock resolveProxyConfig to return a remote-mode config */ +function mockRemoteProxyConfig(remainingArgs: string[] = []): void { + mockResolveProxyConfig.mockReturnValue({ + config: { + mode: 'remote', + host: '192.168.1.100', + port: 8317, + protocol: 'http', + fallbackEnabled: false, + autoStartLocal: false, + remoteOnly: false, + forceLocal: false, + }, + remainingArgs, + }); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +beforeEach(() => { + jest.clearAllMocks(); + mockEnsureCLIProxyBinary.mockResolvedValue('/usr/local/bin/cliproxy'); + mockGetConfiguredBackend.mockReturnValue('original'); +}); + +describe('resolveExecutorProxy — local mode', () => { + it('returns useRemoteProxy=false and correct binary for local mode', async () => { + mockLocalProxyConfig(['--verbose']); + const result = await resolveExecutorProxy(['--verbose'], makeContext()); + + expect(result.useRemoteProxy).toBe(false); + expect(result.localBackend).toBe('original'); + expect(result.binaryPath).toBe('/usr/local/bin/cliproxy'); + expect(result.argsWithoutProxy).toEqual(['--verbose']); + }); + + it('strips proxy flags and passes remainingArgs through', async () => { + mockLocalProxyConfig(['clean-arg']); + const result = await resolveExecutorProxy(['--local-proxy', 'clean-arg'], makeContext()); + + expect(result.argsWithoutProxy).toEqual(['clean-arg']); + expect(result.useRemoteProxy).toBe(false); + }); + + it('does not call checkRemoteProxy in local mode', async () => { + mockLocalProxyConfig(); + await resolveExecutorProxy([], makeContext()); + + expect(mockCheckRemoteProxy).not.toHaveBeenCalled(); + }); +}); + +describe('resolveExecutorProxy — remote mode reachable', () => { + it('returns useRemoteProxy=true when remote proxy is reachable', async () => { + mockRemoteProxyConfig(); + mockCheckRemoteProxy.mockResolvedValue({ reachable: true, latencyMs: 12, error: undefined }); + + const result = await resolveExecutorProxy([], makeContext()); + + expect(result.useRemoteProxy).toBe(true); + }); + + it('skips binary acquisition when remote proxy is reachable', async () => { + mockRemoteProxyConfig(); + mockCheckRemoteProxy.mockResolvedValue({ reachable: true, latencyMs: 5, error: undefined }); + + const result = await resolveExecutorProxy([], makeContext()); + + expect(result.binaryPath).toBeUndefined(); + expect(mockEnsureCLIProxyBinary).not.toHaveBeenCalled(); + }); +}); + +describe('resolveExecutorProxy — remote mode unreachable', () => { + it('throws expected message when remoteOnly=true and remote is unreachable', async () => { + mockResolveProxyConfig.mockReturnValue({ + config: { + mode: 'remote', + host: '192.168.1.100', + port: 8317, + protocol: 'http', + fallbackEnabled: false, + autoStartLocal: false, + remoteOnly: true, + forceLocal: false, + }, + remainingArgs: [], + }); + mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Connection refused' }); + + await expect(resolveExecutorProxy([], makeContext())).rejects.toThrow( + 'Remote proxy unreachable and --remote-only specified' + ); + }); + + it('throws when fallback disabled and remote is unreachable', async () => { + mockResolveProxyConfig.mockReturnValue({ + config: { + mode: 'remote', + host: '192.168.1.100', + port: 8317, + protocol: 'http', + fallbackEnabled: false, + autoStartLocal: false, + remoteOnly: false, + forceLocal: false, + }, + remainingArgs: [], + }); + mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Timeout' }); + + await expect(resolveExecutorProxy([], makeContext())).rejects.toThrow( + 'Remote proxy unreachable and fallback disabled' + ); + }); + + it('falls back to local and acquires binary when autoStartLocal=true', async () => { + mockResolveProxyConfig.mockReturnValue({ + config: { + mode: 'remote', + host: '192.168.1.100', + port: 8317, + protocol: 'http', + fallbackEnabled: true, + autoStartLocal: true, + remoteOnly: false, + forceLocal: false, + }, + remainingArgs: [], + }); + mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Timeout' }); + mockEnsureCLIProxyBinary.mockResolvedValue('/usr/local/bin/cliproxy'); + + const result = await resolveExecutorProxy([], makeContext()); + + expect(result.useRemoteProxy).toBe(false); + expect(result.binaryPath).toBe('/usr/local/bin/cliproxy'); + expect(mockEnsureCLIProxyBinary).toHaveBeenCalled(); + }); +}); + +describe('resolveExecutorProxy — proxyConfig propagated in result', () => { + it('returns the resolved proxyConfig object', async () => { + mockLocalProxyConfig(); + + const result = await resolveExecutorProxy([], makeContext()); + + expect(result.proxyConfig).toBeDefined(); + expect(result.proxyConfig.mode).toBe('local'); + expect(result.proxyConfig.port).toBe(8317); + }); + + it('returns mutated cfg with validated port', async () => { + mockLocalProxyConfig(); + const ctx = makeContext(); + + const result = await resolveExecutorProxy([], ctx); + + // cfg is mutated in place and also returned + expect(result.cfg).toBe(ctx.cfg); + expect(result.cfg.port).toBe(8317); + }); +}); diff --git a/src/cliproxy/executor/account-resolution.ts b/src/cliproxy/executor/account-resolution.ts new file mode 100644 index 00000000..9d1c49fb --- /dev/null +++ b/src/cliproxy/executor/account-resolution.ts @@ -0,0 +1,197 @@ +/** + * Account Resolution — Executor-level account management + * + * Extracted from executor/index.ts (Phase 05). + * Handles: + * - --accounts listing (early exit) + * - --use switching + * - --nickname rename + * - Default account touch (lastUsedAt update) + * - Account safety guards (cross-provider isolation, ban risk, stale pauses) + * - Runtime quota monitor provider resolution + */ + +import { ok, fail, info } from '../../utils/ui'; +import { + findAccountByQuery, + getProviderAccounts, + setDefaultAccount, + touchAccount, + renameAccount, + getDefaultAccount, +} from '../accounts/account-manager'; +import { formatAccountDisplayName } from '../accounts/email-account-identity'; +import { getProviderConfig } from '../config/config-generator'; +import { CLIProxyProvider } from '../types'; +import { MANAGED_QUOTA_PROVIDERS, type ManagedQuotaProvider } from '../quota/quota-manager'; +import { + warnCrossProviderDuplicates, + warnOAuthBanRisk, + cleanupStaleAutoPauses, + enforceProviderIsolation, + restoreAutoPausedAccounts, +} from '../accounts/account-safety'; + +// ── Quota provider resolution ───────────────────────────────────────────────── + +/** + * Determine which managed quota providers need runtime monitoring. + * For composite variants, checks all tier providers; otherwise checks the + * single active provider. + */ +export function resolveRuntimeQuotaMonitorProviders( + provider: CLIProxyProvider, + compositeProviders: CLIProxyProvider[] +): ManagedQuotaProvider[] { + const candidates = compositeProviders.length > 0 ? compositeProviders : [provider]; + const resolved: ManagedQuotaProvider[] = []; + + for (const candidate of candidates) { + if ( + MANAGED_QUOTA_PROVIDERS.includes(candidate as ManagedQuotaProvider) && + !resolved.includes(candidate as ManagedQuotaProvider) + ) { + resolved.push(candidate as ManagedQuotaProvider); + } + } + + return resolved; +} + +// ── Account safety guards ───────────────────────────────────────────────────── + +/** + * Apply account safety guards: stale auto-pause cleanup, provider isolation, + * cross-provider duplicate warnings, and OAuth ban risk warnings. + * + * Registers process.on('exit') restore handler when isolation is enforced. + */ +export function applyAccountSafetyGuards( + provider: CLIProxyProvider, + _compositeProviders: CLIProxyProvider[] +): void { + cleanupStaleAutoPauses(); + const isolated = enforceProviderIsolation(provider); + if (isolated === 0) { + // No enforcement — still warn about duplicates for awareness + warnCrossProviderDuplicates(provider); + } else { + // 'exit' handlers must be synchronous — restoreAutoPausedAccounts uses sync fs APIs + process.on('exit', () => { + restoreAutoPausedAccounts(provider); + }); + } +} + +// ── Context / Result types ──────────────────────────────────────────────────── + +export interface AccountResolutionContext { + provider: CLIProxyProvider; + /** True when running in composite variant mode */ + showAccounts: boolean; + useAccount: string | undefined; + setNickname: string | undefined; + addAccount: boolean; +} + +export interface AccountResolutionResult { + /** true if --accounts listing was shown (caller should return early) */ + earlyExit: boolean; +} + +// ── Main account resolution ─────────────────────────────────────────────────── + +/** + * Handle account management CLI flags: + * --accounts (list + early exit), --use (switch), --nickname (rename). + * + * Also calls warnOAuthBanRisk for the active provider. + */ +export async function resolveAccounts( + ctx: AccountResolutionContext +): Promise { + const { provider, showAccounts, useAccount, setNickname, addAccount } = ctx; + const providerConfig = getProviderConfig(provider); + + // Warn about OAuth ban risk for this provider (always) + warnOAuthBanRisk(provider); + + // Handle --accounts + if (showAccounts) { + const accounts = getProviderAccounts(provider); + if (accounts.length === 0) { + console.log(info(`No accounts registered for ${providerConfig.displayName}`)); + console.log(` Run "ccs ${provider} --auth" to add an account`); + } else { + console.log(`\n${providerConfig.displayName} Accounts:\n`); + for (const acct of accounts) { + const defaultMark = acct.isDefault ? ' (default)' : ''; + const nickname = acct.nickname ? `[${acct.nickname}]` : ''; + console.log(` ${nickname.padEnd(12)} ${formatAccountDisplayName(acct)}${defaultMark}`); + } + console.log(`\n Use "ccs ${provider} --use " to switch accounts`); + } + process.exit(0); + return { earlyExit: true }; + } + + // Handle --use + if (useAccount) { + const account = findAccountByQuery(provider, useAccount); + if (!account) { + console.error(fail(`Account not found: "${useAccount}"`)); + const accounts = getProviderAccounts(provider); + if (accounts.length > 0) { + console.error(` Available accounts:`); + for (const acct of accounts) { + const displayName = formatAccountDisplayName(acct); + const label = acct.nickname ? `${acct.nickname} (${displayName})` : displayName; + console.error(` - ${label}`); + } + } + process.exit(1); + } + setDefaultAccount(provider, account.id); + touchAccount(provider, account.id); + const switchedLabel = account.nickname + ? `${account.nickname} (${formatAccountDisplayName(account)})` + : formatAccountDisplayName(account); + console.log(ok(`Switched to account: ${switchedLabel}`)); + } + + // Handle --nickname (rename account) — only when not in --auth flow + if (setNickname && !addAccount) { + const defaultAccount = getDefaultAccount(provider); + if (!defaultAccount) { + console.error(fail(`No account found for ${providerConfig.displayName}`)); + console.error(` Run "ccs ${provider} --auth" to add an account first`); + process.exit(1); + } + try { + const success = renameAccount(provider, defaultAccount.id, setNickname); + if (success) { + console.log(ok(`Renamed account to: ${setNickname}`)); + } else { + console.error(fail('Failed to rename account')); + process.exit(1); + } + } catch (err) { + console.error(fail(err instanceof Error ? err.message : 'Failed to rename account')); + process.exit(1); + } + process.exit(0); + } + + return { earlyExit: false }; +} + +/** + * Touch the default account's lastUsedAt timestamp. + * Called after authentication succeeds and before proxy spawn. + */ +export function touchDefaultAccount(provider: CLIProxyProvider): void { + const usedAccount = getDefaultAccount(provider); + if (usedAccount) { + touchAccount(provider, usedAccount.id); + } +} diff --git a/src/cliproxy/executor/arg-parser.ts b/src/cliproxy/executor/arg-parser.ts new file mode 100644 index 00000000..ea581f98 --- /dev/null +++ b/src/cliproxy/executor/arg-parser.ts @@ -0,0 +1,624 @@ +/** + * CLIProxy Executor Arg Parser + * + * Extracted from index.ts (Concern A): + * - readOptionValue + * - hasGitLabTokenLoginFlag / getGitLabTokenLoginFlagName + * - CCS_FLAGS constant + filterCcsFlags() + * - parseExecutorFlags() — flag extraction block (lines ~411-639 in original) + * - validateFlagCombinations() — cross-flag guard block (lines ~531-585) + * + * IMPORTANT: process.exit semantics are kept identical to original index.ts. + * All console.error messages are byte-identical. + */ + +import { fail } from '../../utils/ui/indicators'; +import { + isKiroAuthMethod, + isKiroIDCFlow, + type KiroAuthMethod, + type KiroIDCFlow, + normalizeKiroAuthMethod, + normalizeKiroIDCFlow, +} from '../auth/auth-types'; +import type { UnifiedConfig } from '../../config/unified-config-types'; +import { PROXY_CLI_FLAGS } from '../proxy/proxy-config-resolver'; +import { parseThinkingOverride } from './thinking-arg-parser'; + +// Inlined from antigravity-responsibility.ts to avoid pulling in unified-config-loader +// (which requires js-yaml at runtime). Keep in sync if ANTIGRAVITY_ACCEPT_RISK_FLAGS changes. +const ANTIGRAVITY_ACCEPT_RISK_FLAGS_LOCAL = [ + '--accept-agr-risk', + '--accept-antigravity-risk', +] as const; + +function hasAntigravityRiskAcceptanceFlag(args: string[]): boolean { + return args.some((arg) => + (ANTIGRAVITY_ACCEPT_RISK_FLAGS_LOCAL as readonly string[]).includes(arg) + ); +} + +// ── Simple Helpers ──────────────────────────────────────────────────────────── + +/** + * Parse a flag value from args supporting both `--flag value` and `--flag=value` forms. + * Returns the shape used throughout the executor: + * { present, value?, missingValue } + */ +export function readOptionValue( + args: string[], + flag: string +): { present: boolean; value?: string; missingValue: boolean } { + const inlinePrefix = `${flag}=`; + const inlineArg = args.find((arg) => arg.startsWith(inlinePrefix)); + if (inlineArg !== undefined) { + const value = inlineArg.slice(inlinePrefix.length).trim(); + return { + present: true, + value: value.length > 0 ? value : undefined, + missingValue: value.length === 0, + }; + } + + const index = args.indexOf(flag); + if (index === -1) { + return { present: false, missingValue: false }; + } + + const next = args[index + 1]; + if (!next || next.startsWith('-')) { + return { present: true, missingValue: true }; + } + + return { present: true, value: next.trim(), missingValue: false }; +} + +/** Returns true if args contain a GitLab token-login flag. */ +export function hasGitLabTokenLoginFlag(args: string[]): boolean { + return args.includes('--gitlab-token-login') || args.includes('--token-login'); +} + +/** Returns the specific flag name present in args (used for error messages). */ +export function getGitLabTokenLoginFlagName( + args: string[] +): '--gitlab-token-login' | '--token-login' { + return args.includes('--gitlab-token-login') ? '--gitlab-token-login' : '--token-login'; +} + +// ── CCS Flags Filter ────────────────────────────────────────────────────────── + +/** + * CCS-specific flags that must not be forwarded to the underlying Claude CLI. + * The list is kept here as the single source of truth (previously inlined in index.ts). + */ +export const CCS_FLAGS: readonly string[] = [ + '--auth', + '--paste-callback', + '--port-forward', + '--headless', + '--logout', + '--config', + '--add', + '--accounts', + '--use', + '--nickname', + '--kiro-auth-method', + '--kiro-idc-start-url', + '--kiro-idc-region', + '--kiro-idc-flow', + '--thinking', + '--effort', + '--1m', + '--no-1m', + '--incognito', + '--no-incognito', + '--import', + '--accept-agr-risk', + '--accept-antigravity-risk', + '--settings', + ...PROXY_CLI_FLAGS, +] as const; + +/** + * Filter all CCS-specific flags (and their value arguments) from args + * before forwarding to the Claude CLI. + * Mirrors the filter logic from index.ts lines ~1328-1349. + */ +export function filterCcsFlags(args: string[]): string[] { + return args.filter((arg, idx) => { + if (CCS_FLAGS.includes(arg)) return false; + if (arg.startsWith('--kiro-auth-method=')) return false; + if (arg.startsWith('--kiro-idc-start-url=')) return false; + if (arg.startsWith('--kiro-idc-region=')) return false; + if (arg.startsWith('--kiro-idc-flow=')) return false; + if (arg.startsWith('--thinking=')) return false; + if (arg.startsWith('--effort=')) return false; + if (arg.startsWith('--1m=') || arg.startsWith('--no-1m=')) return false; + if ( + args[idx - 1] === '--use' || + args[idx - 1] === '--nickname' || + args[idx - 1] === '--kiro-auth-method' || + args[idx - 1] === '--kiro-idc-start-url' || + args[idx - 1] === '--kiro-idc-region' || + args[idx - 1] === '--kiro-idc-flow' || + args[idx - 1] === '--thinking' || + args[idx - 1] === '--effort' + ) + return false; + return true; + }); +} + +// ── ParsedExecutorFlags ─────────────────────────────────────────────────────── + +/** Result of parsing CCS executor flags from args. */ +export interface ParsedExecutorFlags { + forceAuth: boolean; + pasteCallback: boolean; + portForward: boolean; + forceHeadless: boolean; + forceLogout: boolean; + forceConfig: boolean; + addAccount: boolean; + showAccounts: boolean; + forceImport: boolean; + gitlabTokenLogin: boolean; + acceptAgyRisk: boolean; + incognitoFlag: boolean; + noIncognitoFlag: boolean; + noIncognito: boolean; + useAccount: string | undefined; + setNickname: string | undefined; + kiroAuthMethod: KiroAuthMethod | undefined; + kiroIDCStartUrl: string | undefined; + kiroIDCRegion: string | undefined; + kiroIDCFlow: KiroIDCFlow | undefined; + gitlabBaseUrl: string | undefined; + extendedContextOverride: boolean | undefined; + thinkingParse: ReturnType; +} + +/** + * Parse all CCS executor flags from args. + * + * Exits with code 1 (process.exitCode = 1 + return) on invalid flag values. + * Exits with process.exit(1) on conflicting flag combinations — identical to + * the original index.ts behavior. + * + * @param args args AFTER proxy flags have been stripped (argsWithoutProxy) + * @param context provider context needed for kiro/incognito defaults + */ +export function parseExecutorFlags( + args: string[], + context: { + provider: string; + compositeProviders: string[]; + unifiedConfig: UnifiedConfig; + } +): ParsedExecutorFlags { + const { provider, unifiedConfig } = context; + + const forceAuth = args.includes('--auth'); + const pasteCallback = args.includes('--paste-callback'); + const portForward = args.includes('--port-forward'); + const forceHeadless = args.includes('--headless'); + + if (pasteCallback && portForward) { + console.error(fail('Cannot use --paste-callback with --port-forward')); + console.error(' --paste-callback: Manually paste OAuth redirect URL'); + console.error(' --port-forward: Use SSH port forwarding for callback'); + process.exit(1); + } + + const forceLogout = args.includes('--logout'); + const forceConfig = args.includes('--config'); + const addAccount = args.includes('--add'); + const showAccounts = args.includes('--accounts'); + const forceImport = args.includes('--import'); + const gitlabTokenLogin = hasGitLabTokenLoginFlag(args); + const acceptAgyRisk = hasAntigravityRiskAcceptanceFlag(args); + + const incognitoFlag = args.includes('--incognito'); + const noIncognitoFlag = args.includes('--no-incognito'); + const kiroNoIncognitoConfig = + provider === 'kiro' ? (unifiedConfig.cliproxy?.kiro_no_incognito ?? true) : false; + const noIncognito = incognitoFlag ? false : noIncognitoFlag || kiroNoIncognitoConfig; + + // Parse --use flag + let useAccount: string | undefined; + const useIdx = args.indexOf('--use'); + if (useIdx !== -1 && args[useIdx + 1] && !args[useIdx + 1].startsWith('-')) { + useAccount = args[useIdx + 1]; + } + + // Parse --nickname flag + let setNickname: string | undefined; + const nicknameIdx = args.indexOf('--nickname'); + if (nicknameIdx !== -1 && args[nicknameIdx + 1] && !args[nicknameIdx + 1].startsWith('-')) { + setNickname = args[nicknameIdx + 1]; + } + + // Parse --kiro-auth-method flag + let kiroAuthMethod: KiroAuthMethod | undefined; + const kiroMethodValue = readOptionValue(args, '--kiro-auth-method'); + if (kiroMethodValue.present) { + const rawMethod = kiroMethodValue.value; + if (kiroMethodValue.missingValue || !rawMethod) { + console.error(fail('--kiro-auth-method requires a value')); + console.error(' Supported values: aws, aws-authcode, google, github, idc'); + process.exitCode = 1; + // Caller must check process.exitCode = 1 and bail — matching original return behavior + return buildPartialFlags({ + forceAuth, + pasteCallback, + portForward, + forceHeadless, + forceLogout, + forceConfig, + addAccount, + showAccounts, + forceImport, + gitlabTokenLogin, + acceptAgyRisk, + incognitoFlag, + noIncognitoFlag, + noIncognito, + useAccount, + setNickname, + kiroAuthMethod: undefined, + kiroIDCStartUrl: undefined, + kiroIDCRegion: undefined, + kiroIDCFlow: undefined, + gitlabBaseUrl: undefined, + extendedContextOverride: undefined, + thinkingParse: parseThinkingOverride(args), + }); + } + const normalized = rawMethod.trim().toLowerCase(); + if (!isKiroAuthMethod(normalized)) { + console.error(fail(`Invalid --kiro-auth-method value: ${rawMethod}`)); + console.error(' Supported values: aws, aws-authcode, google, github, idc'); + process.exitCode = 1; + return buildPartialFlags({ + forceAuth, + pasteCallback, + portForward, + forceHeadless, + forceLogout, + forceConfig, + addAccount, + showAccounts, + forceImport, + gitlabTokenLogin, + acceptAgyRisk, + incognitoFlag, + noIncognitoFlag, + noIncognito, + useAccount, + setNickname, + kiroAuthMethod: undefined, + kiroIDCStartUrl: undefined, + kiroIDCRegion: undefined, + kiroIDCFlow: undefined, + gitlabBaseUrl: undefined, + extendedContextOverride: undefined, + thinkingParse: parseThinkingOverride(args), + }); + } + kiroAuthMethod = normalizeKiroAuthMethod(normalized); + } + + let kiroIDCStartUrl: string | undefined; + const kiroIDCStartUrlValue = readOptionValue(args, '--kiro-idc-start-url'); + if (kiroIDCStartUrlValue.present && kiroIDCStartUrlValue.value) { + kiroIDCStartUrl = kiroIDCStartUrlValue.value; + } else if (kiroIDCStartUrlValue.present) { + console.error(fail('--kiro-idc-start-url requires a value')); + process.exitCode = 1; + return buildPartialFlags({ + forceAuth, + pasteCallback, + portForward, + forceHeadless, + forceLogout, + forceConfig, + addAccount, + showAccounts, + forceImport, + gitlabTokenLogin, + acceptAgyRisk, + incognitoFlag, + noIncognitoFlag, + noIncognito, + useAccount, + setNickname, + kiroAuthMethod, + kiroIDCStartUrl: undefined, + kiroIDCRegion: undefined, + kiroIDCFlow: undefined, + gitlabBaseUrl: undefined, + extendedContextOverride: undefined, + thinkingParse: parseThinkingOverride(args), + }); + } + + let kiroIDCRegion: string | undefined; + const kiroIDCRegionValue = readOptionValue(args, '--kiro-idc-region'); + if (kiroIDCRegionValue.present && kiroIDCRegionValue.value) { + kiroIDCRegion = kiroIDCRegionValue.value; + } else if (kiroIDCRegionValue.present) { + console.error(fail('--kiro-idc-region requires a value')); + process.exitCode = 1; + return buildPartialFlags({ + forceAuth, + pasteCallback, + portForward, + forceHeadless, + forceLogout, + forceConfig, + addAccount, + showAccounts, + forceImport, + gitlabTokenLogin, + acceptAgyRisk, + incognitoFlag, + noIncognitoFlag, + noIncognito, + useAccount, + setNickname, + kiroAuthMethod, + kiroIDCStartUrl, + kiroIDCRegion: undefined, + kiroIDCFlow: undefined, + gitlabBaseUrl: undefined, + extendedContextOverride: undefined, + thinkingParse: parseThinkingOverride(args), + }); + } + + let kiroIDCFlow: KiroIDCFlow | undefined; + const kiroIDCFlowValue = readOptionValue(args, '--kiro-idc-flow'); + if (kiroIDCFlowValue.present) { + const rawFlow = kiroIDCFlowValue.value; + if (kiroIDCFlowValue.missingValue || !rawFlow) { + console.error(fail('--kiro-idc-flow requires a value')); + console.error(' Supported values: authcode, device'); + process.exitCode = 1; + return buildPartialFlags({ + forceAuth, + pasteCallback, + portForward, + forceHeadless, + forceLogout, + forceConfig, + addAccount, + showAccounts, + forceImport, + gitlabTokenLogin, + acceptAgyRisk, + incognitoFlag, + noIncognitoFlag, + noIncognito, + useAccount, + setNickname, + kiroAuthMethod, + kiroIDCStartUrl, + kiroIDCRegion, + kiroIDCFlow: undefined, + gitlabBaseUrl: undefined, + extendedContextOverride: undefined, + thinkingParse: parseThinkingOverride(args), + }); + } + const normalized = rawFlow.trim().toLowerCase(); + if (!isKiroIDCFlow(normalized)) { + console.error(fail(`Invalid --kiro-idc-flow value: ${rawFlow}`)); + console.error(' Supported values: authcode, device'); + process.exitCode = 1; + return buildPartialFlags({ + forceAuth, + pasteCallback, + portForward, + forceHeadless, + forceLogout, + forceConfig, + addAccount, + showAccounts, + forceImport, + gitlabTokenLogin, + acceptAgyRisk, + incognitoFlag, + noIncognitoFlag, + noIncognito, + useAccount, + setNickname, + kiroAuthMethod, + kiroIDCStartUrl, + kiroIDCRegion, + kiroIDCFlow: undefined, + gitlabBaseUrl: undefined, + extendedContextOverride: undefined, + thinkingParse: parseThinkingOverride(args), + }); + } + kiroIDCFlow = normalizeKiroIDCFlow(normalized); + } + + let gitlabBaseUrl: string | undefined; + const gitlabBaseUrlValue = readOptionValue(args, '--gitlab-url'); + if (gitlabBaseUrlValue.present && gitlabBaseUrlValue.value) { + gitlabBaseUrl = gitlabBaseUrlValue.value.trim(); + } else if (gitlabBaseUrlValue.present) { + console.error(fail('--gitlab-url requires a value')); + process.exitCode = 1; + return buildPartialFlags({ + forceAuth, + pasteCallback, + portForward, + forceHeadless, + forceLogout, + forceConfig, + addAccount, + showAccounts, + forceImport, + gitlabTokenLogin, + acceptAgyRisk, + incognitoFlag, + noIncognitoFlag, + noIncognito, + useAccount, + setNickname, + kiroAuthMethod, + kiroIDCStartUrl, + kiroIDCRegion, + kiroIDCFlow, + gitlabBaseUrl: undefined, + extendedContextOverride: undefined, + thinkingParse: parseThinkingOverride(args), + }); + } + + // Parse --thinking / --effort flags (aliases; first occurrence wins) + const thinkingParse = parseThinkingOverride(args); + if (thinkingParse.error) { + const { flag } = thinkingParse.error; + console.error(fail(`${flag} requires a value`)); + + if (provider === 'codex') { + console.error(' Codex examples: --effort xhigh, --effort high, --effort medium'); + console.error(' Alias: --thinking xhigh (same behavior)'); + } else { + console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); + console.error(' Levels: minimal, low, medium, high, xhigh, max, auto'); + } + + process.exit(1); + } + + // Parse --1m / --no-1m flags for extended context (1M token window) + let extendedContextOverride: boolean | undefined; + const has1mFlag = args.includes('--1m') || args.some((arg) => arg.startsWith('--1m=')); + const hasNo1mFlag = args.includes('--no-1m') || args.some((arg) => arg.startsWith('--no-1m=')); + + if (has1mFlag && hasNo1mFlag) { + console.error(fail('Cannot use both --1m and --no-1m flags')); + process.exit(1); + } else if (has1mFlag) { + extendedContextOverride = true; + } else if (hasNo1mFlag) { + extendedContextOverride = false; + } + + // Auto-set kiroAuthMethod = 'idc' if IDC sub-flags present without explicit method + if (!kiroAuthMethod && (kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow)) { + kiroAuthMethod = 'idc'; + } + + return { + forceAuth, + pasteCallback, + portForward, + forceHeadless, + forceLogout, + forceConfig, + addAccount, + showAccounts, + forceImport, + gitlabTokenLogin, + acceptAgyRisk, + incognitoFlag, + noIncognitoFlag, + noIncognito, + useAccount, + setNickname, + kiroAuthMethod, + kiroIDCStartUrl, + kiroIDCRegion, + kiroIDCFlow, + gitlabBaseUrl, + extendedContextOverride, + thinkingParse, + }; +} + +/** Internal helper — builds a ParsedExecutorFlags from raw fields (avoids repeating the full struct). */ +function buildPartialFlags(fields: ParsedExecutorFlags): ParsedExecutorFlags { + return fields; +} + +// ── Cross-Flag Validation ───────────────────────────────────────────────────── + +/** + * Validate flag combinations that are mutually exclusive or provider-scoped. + * Calls process.exit(1) on any violation — identical to original index.ts. + * Call AFTER parseExecutorFlags() and only if process.exitCode is still 0. + * + * @param parsed Result of parseExecutorFlags() + * @param context Provider context (provider string + compositeProviders list) + * @param args Raw argsWithoutProxy (needed for getGitLabTokenLoginFlagName) + */ +export function validateFlagCombinations( + parsed: ParsedExecutorFlags, + context: { provider: string; compositeProviders: string[] }, + args: string[] +): void { + const { provider, compositeProviders } = context; + const { + kiroAuthMethod, + kiroIDCStartUrl, + kiroIDCRegion, + kiroIDCFlow, + gitlabTokenLogin, + gitlabBaseUrl, + } = parsed; + + if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) { + console.error(fail('--kiro-auth-method is only valid for ccs kiro')); + process.exitCode = 1; + return; + } + + if ( + (kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow) && + provider !== 'kiro' && + !compositeProviders.includes('kiro') + ) { + console.error( + fail( + '--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow are only valid for ccs kiro' + ) + ); + process.exitCode = 1; + return; + } + + if (kiroAuthMethod === 'idc' && !kiroIDCStartUrl) { + console.error(fail('Kiro IDC login requires --kiro-idc-start-url')); + console.error( + ' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start' + ); + process.exitCode = 1; + return; + } + + if ( + kiroAuthMethod && + kiroAuthMethod !== 'idc' && + (kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow) + ) { + console.error( + fail( + '--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow require --kiro-auth-method idc' + ) + ); + process.exitCode = 1; + return; + } + + if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') { + const flagName = gitlabTokenLogin ? getGitLabTokenLoginFlagName(args) : '--gitlab-url'; + console.error(fail(`${flagName} is only valid for ccs gitlab`)); + process.exitCode = 1; + return; + } +} diff --git a/src/cliproxy/executor/auth-coordinator.ts b/src/cliproxy/executor/auth-coordinator.ts new file mode 100644 index 00000000..4e1f2f42 --- /dev/null +++ b/src/cliproxy/executor/auth-coordinator.ts @@ -0,0 +1,397 @@ +/** + * Auth Coordinator — Executor-level authentication handoff + * + * Extracted from executor/index.ts (Phase 06). + * Handles: + * - --logout early exit + * - --import token early exit (Kiro only) + * - --auth / forceAuth OAuth flow (single + composite providers) + * - Antigravity responsibility gate (run and oauth contexts) + * - isAuthenticated checks + automatic OAuth trigger + * - Proactive token refresh (multi-provider for composite) + * - lastUsedAt touch via touchDefaultAccount + * - Preflight quota check + * - Account safety guards (cross-provider isolation) + * - First-run model configuration + * + * ORDERING (load-bearing — do not change): + * 1. logout/import/forceAuth early exits + * 2. Remote-proxy auth skip detection + * 3. Antigravity gate (oauth context: remote+forceAuth | run context: else branch) + * 4. OAuth check / trigger (single or composite) + * 5. Token refresh + * 6. lastUsedAt touch + * 7. Preflight quota check + * 8. Account safety guards + * 9. First-run model configuration + */ + +import { ok, fail, info } from '../../utils/ui'; +import { isAuthenticated } from '../auth/auth-handler'; +import { CLIProxyProvider, ExecutorConfig } from '../types'; +import { getProviderConfig, ensureProviderSettings } from '../config/config-generator'; +import { configureProviderModel, getCurrentModel } from '../config/model-config'; +import { reconcileCodexModelForActivePlan } from '../ai-providers/codex-plan-compatibility'; +import { supportsModelConfig } from '../model-catalog'; +import { + ensureCliAntigravityResponsibility, + ANTIGRAVITY_ACCEPT_RISK_FLAGS, +} from '../auth/antigravity-responsibility'; +import { handleTokenExpiration, handleQuotaCheck } from './retry-handler'; +import { applyAccountSafetyGuards, touchDefaultAccount } from './account-resolution'; +import { MANAGED_QUOTA_PROVIDERS } from '../quota/quota-manager'; +import type { ParsedExecutorFlags } from './arg-parser'; +import type { UnifiedConfig } from '../../config/schemas/unified-config'; + +// ── Context / Result types ───────────────────────────────────────────────────── + +export interface AuthCoordinationContext { + provider: CLIProxyProvider; + compositeProviders: CLIProxyProvider[]; + parsedFlags: ParsedExecutorFlags; + cfg: ExecutorConfig; + unifiedConfig: UnifiedConfig; + verbose: boolean; + log: (msg: string) => void; +} + +// ── 1. Special early-exit auth flags (logout / import) ──────────────────────── + +/** + * Handle --logout: clear auth and exit 0. + * Returns true if early exit occurred (caller should return immediately). + */ +export async function handleLogout(context: AuthCoordinationContext): Promise { + const { provider, parsedFlags } = context; + if (!parsedFlags.forceLogout) return false; + + const providerConfig = getProviderConfig(provider); + const { clearAuth } = await import('../auth/auth-handler'); + if (clearAuth(provider)) { + console.log(ok(`Logged out from ${providerConfig.displayName}`)); + } else { + console.log(info(`No authentication found for ${providerConfig.displayName}`)); + } + process.exit(0); +} + +/** + * Handle --import: Kiro-only token import flow, exits when done. + * Returns true if early exit occurred. + */ +export async function handleImport(context: AuthCoordinationContext): Promise { + const { provider, parsedFlags, verbose } = context; + const { + forceImport, + forceAuth, + forceLogout, + kiroAuthMethod, + kiroIDCStartUrl, + kiroIDCRegion, + kiroIDCFlow, + setNickname, + } = parsedFlags; + + if (!forceImport) return false; + + if (provider !== 'kiro') { + console.error(fail('--import is only available for Kiro')); + console.error(` Run "ccs ${provider} --auth" to authenticate`); + process.exit(1); + } + if (forceAuth) { + console.error(fail('Cannot use --import with --auth')); + console.error(' --import: Import existing token from Kiro IDE'); + console.error(' --auth: Trigger new OAuth flow in browser'); + process.exit(1); + } + if (forceLogout) { + console.error(fail('Cannot use --import with --logout')); + process.exit(1); + } + + const { triggerOAuth } = await import('../auth/auth-handler'); + const authSuccess = await triggerOAuth(provider, { + verbose, + import: true, + ...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}), + ...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}), + ...(kiroIDCRegion ? { kiroIDCRegion } : {}), + ...(kiroIDCFlow ? { kiroIDCFlow } : {}), + ...(setNickname ? { nickname: setNickname } : {}), + }); + if (!authSuccess) { + console.error(fail('Failed to import Kiro token from IDE')); + console.error(' Make sure you are logged into Kiro IDE first'); + process.exit(1); + } + process.exit(0); +} + +// ── 2. Remote proxy auth skip ────────────────────────────────────────────────── + +/** + * Returns true when a remote proxy with an authToken is active — + * local OAuth is not needed in this case. + */ +export function resolveSkipLocalAuth( + remoteAuthToken: string | undefined, + useRemoteProxy: boolean +): boolean { + return useRemoteProxy && !!remoteAuthToken?.trim(); +} + +// ── 3. Antigravity responsibility gate ──────────────────────────────────────── + +/** + * Runs the Antigravity responsibility gate for the relevant code path. + * + * Two scenarios (must match original ordering in index.ts): + * A. provider=agy + forceAuth + skipLocalAuth → oauth-context gate; if refused, log and return + * (caller must return early — this function returns { earlyReturn: true }) + * B. provider=agy + !forceAuth + (skipLocalAuth || !requiresAuthNow) → run-context gate; + * if refused, log and process.exit(1) + * + * Returns { earlyReturn: true } when the caller should return immediately (case A refused). + */ +export async function runAntigravityGate( + context: AuthCoordinationContext, + skipLocalAuth: boolean +): Promise<{ earlyReturn: boolean }> { + const { provider, parsedFlags } = context; + const { forceAuth, acceptAgyRisk } = parsedFlags; + + if (provider !== 'agy') return { earlyReturn: false }; + + const providerConfig = getProviderConfig(provider); + const requiresAuthNow = providerConfig.requiresOAuth && !isAuthenticated(provider); + + if (forceAuth && skipLocalAuth) { + // Case A: remote proxy + forceAuth — gate in oauth context, skip local OAuth + const acknowledged = await ensureCliAntigravityResponsibility({ + context: 'oauth', + acceptedByFlag: acceptAgyRisk, + }); + if (!acknowledged) { + throw new Error( + `Antigravity auth blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.` + ); + } + console.error(info('Remote proxy mode is active; local OAuth flow is skipped in --auth mode.')); + return { earlyReturn: true }; + } + + if (!forceAuth) { + // Case B: run-context gate (only when auth not immediately required) + if (skipLocalAuth || !requiresAuthNow) { + const acknowledged = await ensureCliAntigravityResponsibility({ + context: 'run', + acceptedByFlag: acceptAgyRisk, + }); + if (!acknowledged) { + console.error( + fail( + `Antigravity session blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.` + ) + ); + process.exit(1); + } + } + } + + return { earlyReturn: false }; +} + +// ── 4–6. OAuth check / trigger + token refresh + account touch ───────────────── + +/** + * Ensure provider(s) are authenticated; trigger OAuth if needed. + * Handles both composite (multi-provider) and single-provider flows. + * Also runs proactive token refresh and touches lastUsedAt. + * + * Only called when providerConfig.requiresOAuth && !skipLocalAuth. + */ +export async function ensureProviderAuthentication( + context: AuthCoordinationContext +): Promise { + const { provider, compositeProviders, parsedFlags, verbose, log } = context; + const { + forceAuth, + addAccount, + acceptAgyRisk, + kiroAuthMethod, + kiroIDCStartUrl, + kiroIDCRegion, + kiroIDCFlow, + gitlabTokenLogin, + gitlabBaseUrl, + forceHeadless, + setNickname, + noIncognito, + pasteCallback, + portForward, + } = parsedFlags; + + log(`Checking authentication for ${provider}`); + + // Multi-provider path (composite variants) + if (compositeProviders.length > 0) { + if (forceAuth) { + const { triggerOAuth } = await import('../auth/auth-handler'); + const failures: string[] = []; + for (const p of compositeProviders) { + const authSuccess = await triggerOAuth(p, { + verbose, + add: addAccount, + ...(acceptAgyRisk ? { acceptAgyRisk: true } : {}), + ...(kiroAuthMethod && p === 'kiro' ? { kiroMethod: kiroAuthMethod } : {}), + ...(kiroIDCStartUrl && p === 'kiro' ? { kiroIDCStartUrl } : {}), + ...(kiroIDCRegion && p === 'kiro' ? { kiroIDCRegion } : {}), + ...(kiroIDCFlow && p === 'kiro' ? { kiroIDCFlow } : {}), + ...(gitlabTokenLogin && p === 'gitlab' ? { gitlabAuthMode: 'pat' as const } : {}), + ...(gitlabBaseUrl && p === 'gitlab' ? { gitlabBaseUrl } : {}), + ...(forceHeadless ? { headless: true } : {}), + ...(setNickname ? { nickname: setNickname } : {}), + ...(noIncognito ? { noIncognito: true } : {}), + ...(pasteCallback ? { pasteCallback: true } : {}), + ...(portForward ? { portForward: true } : {}), + }); + if (!authSuccess) { + failures.push(p); + } + } + if (failures.length > 0) { + const succeeded = compositeProviders.filter((p) => !failures.includes(p)); + console.error(fail(`Auth failed for: ${failures.join(', ')}`)); + if (succeeded.length > 0) { + console.error(info(`Succeeded: ${succeeded.join(', ')}`)); + } + process.exit(1); + } + process.exit(0); + } + + // Check for unauthenticated providers + const unauthenticatedProviders: string[] = []; + for (const p of compositeProviders) { + if (!isAuthenticated(p)) { + unauthenticatedProviders.push(p); + } + } + if (unauthenticatedProviders.length > 0) { + console.error(fail('Composite variant requires authentication for multiple providers:')); + for (const p of unauthenticatedProviders) { + console.error(` - ${p} (run "ccs ${p} --auth")`); + } + process.exit(1); + } + } else { + // Single-provider path + if (forceAuth || !isAuthenticated(provider)) { + const { triggerOAuth } = await import('../auth/auth-handler'); + const providerConfig = getProviderConfig(provider); + const authSuccess = await triggerOAuth(provider, { + verbose, + add: addAccount, + ...(acceptAgyRisk ? { acceptAgyRisk: true } : {}), + ...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}), + ...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}), + ...(kiroIDCRegion ? { kiroIDCRegion } : {}), + ...(kiroIDCFlow ? { kiroIDCFlow } : {}), + ...(gitlabTokenLogin ? { gitlabAuthMode: 'pat' as const } : {}), + ...(gitlabBaseUrl ? { gitlabBaseUrl } : {}), + ...(forceHeadless ? { headless: true } : {}), + ...(setNickname ? { nickname: setNickname } : {}), + ...(noIncognito ? { noIncognito: true } : {}), + ...(pasteCallback ? { pasteCallback: true } : {}), + ...(portForward ? { portForward: true } : {}), + }); + if (!authSuccess) { + throw new Error(`Authentication required for ${providerConfig.displayName}`); + } + if (forceAuth) { + process.exit(0); + } + } else { + log(`${provider} already authenticated`); + } + } + + // 3a. Proactive token refresh (multi-provider for composite) + if (compositeProviders.length > 0) { + for (const p of compositeProviders) { + await handleTokenExpiration(p, verbose); + } + } else { + await handleTokenExpiration(provider, verbose); + } + + // 3a-1. Update lastUsedAt + touchDefaultAccount(provider); +} + +// ── 7. Preflight quota check ────────────────────────────────────────────────── + +/** + * Preflight quota check for providers with quota-based rotation. + * Only runs when !skipLocalAuth. + */ +export async function runPreflightQuotaCheck( + provider: CLIProxyProvider, + compositeProviders: CLIProxyProvider[] +): Promise { + if (compositeProviders.length > 0) { + for (const managedProvider of MANAGED_QUOTA_PROVIDERS) { + if (compositeProviders.includes(managedProvider)) { + await handleQuotaCheck(managedProvider); + } + } + } else { + await handleQuotaCheck(provider); + } +} + +// ── 8. Account safety guards ────────────────────────────────────────────────── + +/** + * Enforce cross-provider account isolation. Only runs when !skipLocalAuth. + */ +export function runAccountSafetyGuards( + provider: CLIProxyProvider, + compositeProviders: CLIProxyProvider[] +): void { + applyAccountSafetyGuards(provider, compositeProviders); +} + +// ── 9. First-run model configuration ───────────────────────────────────────── + +/** + * Ensure provider model is configured on first run. + * Skipped for composite variants and remote proxy mode. + * Also reconciles Codex model for active plan. + */ +export async function ensureModelConfiguration( + provider: CLIProxyProvider, + cfg: ExecutorConfig, + verbose: boolean +): Promise { + if (!cfg.isComposite && supportsModelConfig(provider)) { + await configureProviderModel(provider, false, cfg.customSettingsPath); + } + + if (provider === 'codex' && !cfg.isComposite) { + await reconcileCodexModelForActivePlan({ + currentModel: getCurrentModel(provider, cfg.customSettingsPath), + verbose, + }); + } +} + +// ── Ensure provider settings file ──────────────────────────────────────────── + +/** + * Ensure the provider settings file exists. + */ +export function ensureProviderSettingsFile(provider: CLIProxyProvider): void { + ensureProviderSettings(provider); +} diff --git a/src/cliproxy/executor/browser-launch-setup.ts b/src/cliproxy/executor/browser-launch-setup.ts new file mode 100644 index 00000000..94405a33 --- /dev/null +++ b/src/cliproxy/executor/browser-launch-setup.ts @@ -0,0 +1,118 @@ +/** + * Browser Launch Setup — Executor-level browser initialization + * + * Extracted from executor/index.ts (Phase 04). + * Handles: + * 1. Browser launch flag resolution and override parsing + * 2. Browser attach config + exposure resolution + blocked-override warning + * 3. Optional browser attach runtime resolution (devtools WebSocket) + * 4. Browser MCP ensure + sync-to-config-dir + */ + +import { warn } from '../../utils/ui'; +import { + type BrowserLaunchOverride, + ensureBrowserMcpOrThrow, + getBlockedBrowserOverrideWarning, + getEffectiveClaudeBrowserAttachConfig, + resolveBrowserExposure, + resolveBrowserLaunchFlagResolution, + resolveOptionalBrowserAttachRuntime, + syncBrowserMcpToConfigDir, +} from '../../utils/browser'; +import { getBrowserConfig } from '../../config/config-loader-facade'; + +export interface BrowserLaunchSetupResult { + /** CLI override flag if --browser-launch / --no-browser-launch was passed */ + browserLaunchOverride: BrowserLaunchOverride | undefined; + /** args list with --browser-launch* flags removed */ + argsWithoutBrowserFlags: string[]; + /** Devtools WebSocket env vars if browser attach runtime is active */ + browserRuntimeEnv: Record | undefined; +} + +/** + * Phase 1 — resolve browser CLI flags and attach config. + * Call this immediately after resolveExecutorProxy so that + * argsWithoutBrowserFlags is available for downstream parsing. + * + * @returns partial setup result (no async work yet) + */ +export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): { + browserLaunchOverride: BrowserLaunchOverride | undefined; + argsWithoutBrowserFlags: string[]; +} { + let browserLaunchOverride: BrowserLaunchOverride | undefined; + let argsWithoutBrowserFlags = argsWithoutProxy; + try { + const browserLaunchFlags = resolveBrowserLaunchFlagResolution(argsWithoutProxy); + browserLaunchOverride = browserLaunchFlags.override; + argsWithoutBrowserFlags = browserLaunchFlags.argsWithoutFlags; + } catch (error) { + console.error(warn((error as Error).message)); + process.exit(1); + return { browserLaunchOverride: undefined, argsWithoutBrowserFlags }; + } + + const browserConfig = getBrowserConfig(); + const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(browserConfig); + const claudeBrowserExposure = resolveBrowserExposure( + { + enabled: browserAttachConfig.enabled, + policy: browserConfig.claude.policy, + }, + browserLaunchOverride + ); + const blockedBrowserOverrideWarning = getBlockedBrowserOverrideWarning( + 'Claude Browser Attach', + claudeBrowserExposure + ); + if (blockedBrowserOverrideWarning) { + console.error(warn(blockedBrowserOverrideWarning)); + } + + return { browserLaunchOverride, argsWithoutBrowserFlags }; +} + +/** + * Phase 2 — resolve async browser attach runtime and MCP setup. + * Must be called AFTER phase-1 and AFTER ensureWebSearchMcpOrThrow(). + */ +export async function resolveBrowserRuntime( + browserLaunchOverride: BrowserLaunchOverride | undefined, + inheritedClaudeConfigDir: string | undefined +): Promise> { + const browserConfig = getBrowserConfig(); + const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(browserConfig); + const claudeBrowserExposure = resolveBrowserExposure( + { + enabled: browserAttachConfig.enabled, + policy: browserConfig.claude.policy, + }, + browserLaunchOverride + ); + + const browserAttachRuntime = + browserAttachConfig.enabled && claudeBrowserExposure.exposeForLaunch + ? await resolveOptionalBrowserAttachRuntime(browserAttachConfig) + : undefined; + + const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; + if (browserAttachRuntime?.warning) { + process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`); + } + if (browserRuntimeEnv) { + ensureBrowserMcpOrThrow(); + } + + // Sync browser MCP config into inherited Claude instance if browser is active + if (browserRuntimeEnv && inheritedClaudeConfigDir) { + if (!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)) { + throw new Error( + 'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.' + ); + } + } + + return { browserRuntimeEnv }; +} diff --git a/src/cliproxy/executor/claude-launcher.ts b/src/cliproxy/executor/claude-launcher.ts new file mode 100644 index 00000000..c4f97a13 --- /dev/null +++ b/src/cliproxy/executor/claude-launcher.ts @@ -0,0 +1,161 @@ +/** + * Claude Launcher — Concern H + * + * Handles final argument assembly, trace context injection, Windows shell + * escaping, spawning the Claude CLI process, starting the runtime quota + * monitor, and wiring up cleanup handlers. + */ + +import { spawn, ChildProcess } from 'child_process'; +import * as os from 'os'; +import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor'; +import { getProviderSettingsPath } from '../config/config-generator'; +import { + ensureWebSearchMcpOrThrow as _ensureWebSearchMcpOrThrow, + appendThirdPartyWebSearchToolArgs, + createWebSearchTraceContext, +} from '../../utils/websearch-manager'; +import { appendThirdPartyImageAnalysisToolArgs } from '../../utils/image-analysis'; +import { appendBrowserToolArgs } from '../../utils/browser'; +import { getDefaultAccount } from '../accounts/account-manager'; +import { CLIProxyProvider, ExecutorConfig } from '../types'; +import { CodexReasoningProxy } from '../ai-providers/codex-reasoning-proxy'; +import { ToolSanitizationProxy } from '../proxy/tool-sanitization-proxy'; +import { HttpsTunnelProxy } from '../proxy/https-tunnel-proxy'; +import { setupCleanupHandlers } from './session-bridge'; +import { resolveRuntimeQuotaMonitorProviders } from './account-resolution'; + +export interface ClaudeLaunchContext { + /** Path to the Claude CLI executable */ + claudeCli: string; + /** Pre-filtered Claude args (CCS flags already stripped) */ + claudeArgs: string[]; + /** Fully assembled environment variables (without trace additions) */ + env: NodeJS.ProcessEnv; + /** Resolved executor config */ + cfg: ExecutorConfig; + /** Active CLIProxy provider */ + provider: CLIProxyProvider; + /** Providers derived from composite tiers (empty for simple providers) */ + compositeProviders: CLIProxyProvider[]; + /** Whether local OAuth was skipped (remote proxy auth in use) */ + skipLocalAuth: boolean; + /** Session ID for cleanup tracking */ + sessionId: string | undefined; + /** Whether image analysis MCP is ready */ + imageAnalysisMcpReady: boolean; + /** Browser runtime environment variables (undefined if browser not active) */ + browserRuntimeEnv: NodeJS.ProcessEnv | undefined; + /** Inherited Claude config dir for continuity */ + inheritedClaudeConfigDir: string | undefined; + /** Active Codex reasoning proxy (if any) */ + codexReasoningProxy: CodexReasoningProxy | null; + /** Active tool sanitization proxy (if any) */ + toolSanitizationProxy: ToolSanitizationProxy | null; + /** Active HTTPS tunnel proxy (if any) */ + httpsTunnel: HttpsTunnelProxy | null; + /** Whether verbose logging is enabled */ + verbose: boolean; +} + +/** + * Assemble final Claude CLI arguments, inject trace context, spawn the process, + * start the runtime quota monitor, and wire cleanup handlers. + * + * @returns The spawned ChildProcess for the Claude CLI. + */ +export async function launchClaude(context: ClaudeLaunchContext): Promise { + const { + claudeCli, + claudeArgs, + env, + cfg, + provider, + compositeProviders, + skipLocalAuth, + sessionId, + imageAnalysisMcpReady, + browserRuntimeEnv, + inheritedClaudeConfigDir, + codexReasoningProxy, + toolSanitizationProxy, + httpsTunnel, + verbose, + } = context; + + const isWindows = process.platform === 'win32'; + const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); + + const settingsPath = cfg.customSettingsPath + ? cfg.customSettingsPath.replace(/^~/, os.homedir()) + : getProviderSettingsPath(provider); + + // Assemble final args: image analysis tools → browser tools → web search tools → settings + const imageAnalysisArgs = imageAnalysisMcpReady + ? appendThirdPartyImageAnalysisToolArgs(claudeArgs) + : claudeArgs; + const browserArgs = browserRuntimeEnv + ? appendBrowserToolArgs(imageAnalysisArgs) + : imageAnalysisArgs; + const launchArgs = [ + '--settings', + settingsPath, + ...appendThirdPartyWebSearchToolArgs(browserArgs), + ]; + + // Inject web search trace context into env + const traceEnv = createWebSearchTraceContext({ + launcher: 'cliproxy.executor', + args: launchArgs, + profile: cfg.profileName || provider, + profileType: 'cliproxy', + settingsPath, + claudeConfigDir: inheritedClaudeConfigDir, + }); + const tracedEnv = { ...env, ...traceEnv }; + + // Spawn: Windows .cmd/.bat/.ps1 need shell escaping; all others spawn directly + let claude: ChildProcess; + if (needsShell) { + const cmdString = [claudeCli, ...launchArgs].map(escapeShellArg).join(' '); + claude = spawn(cmdString, { + stdio: 'inherit', + windowsHide: true, + shell: getWindowsEscapedCommandShell(), + env: tracedEnv, + }); + } else { + claude = spawn(claudeCli, launchArgs, { + stdio: 'inherit', + windowsHide: true, + env: tracedEnv, + }); + } + + // Start runtime quota monitor (adaptive polling during session) + if (!skipLocalAuth) { + const { startQuotaMonitor } = await import('../quota/quota-manager'); + for (const monitorProvider of resolveRuntimeQuotaMonitorProviders( + provider, + compositeProviders + )) { + const monitorAccount = getDefaultAccount(monitorProvider); + if (monitorAccount) { + startQuotaMonitor(monitorProvider, monitorAccount.id); + } + } + } + + // Wire cleanup handlers (process exit, SIGINT, SIGTERM, proxy teardown) + setupCleanupHandlers( + claude, + sessionId, + cfg.port, + codexReasoningProxy, + toolSanitizationProxy, + httpsTunnel, + verbose + ); + + return claude; +} diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 80ca3088..d88ff83e 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -10,143 +10,66 @@ * 6. Claude CLI execution with cleanup handlers */ -import { spawn, ChildProcess } from 'child_process'; +import { ChildProcess } from 'child_process'; import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { ProgressIndicator } from '../../utils/progress-indicator'; -import { ok, fail, info, warn } from '../../utils/ui'; -import { getCcsDir } from '../../utils/config-manager'; -import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor'; -import { - ensureCLIProxyBinary, - getConfiguredBackend, - getPlusBackendUnavailableMessage, -} from '../binary-manager'; +import { fail, info, warn } from '../../utils/ui'; import { generateConfig, getProviderConfig, - ensureProviderSettings, - getProviderSettingsPath, CLIPROXY_DEFAULT_PORT, - validatePort, } from '../config/config-generator'; -import { checkRemoteProxy } from '../services/remote-proxy-client'; -import { isAuthenticated } from '../auth/auth-handler'; -import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS, ExecutorConfig } from '../types'; -import { configureProviderModel, getCurrentModel } from '../config/model-config'; -import { reconcileCodexModelForActivePlan } from '../ai-providers/codex-plan-compatibility'; -import { resolveProxyConfig, PROXY_CLI_FLAGS } from '../proxy/proxy-config-resolver'; -import { - supportsModelConfig, - isModelBroken, - getModelIssueUrl, - findModel, - getSuggestedReplacementModel, -} from '../model-catalog'; +import { configureProviderModel } from '../config/model-config'; +import { supportsModelConfig } from '../model-catalog'; +import { CLIProxyProvider, ExecutorConfig } from '../types'; import { CodexReasoningProxy } from '../ai-providers/codex-reasoning-proxy'; import { ToolSanitizationProxy } from '../proxy/tool-sanitization-proxy'; -import { - findAccountByQuery, - getProviderAccounts, - setDefaultAccount, - touchAccount, - renameAccount, - getDefaultAccount, -} from '../accounts/account-manager'; -import { formatAccountDisplayName } from '../accounts/email-account-identity'; -import { - ensureWebSearchMcpOrThrow, - displayWebSearchStatus, - appendThirdPartyWebSearchToolArgs, - createWebSearchTraceContext, -} from '../../utils/websearch-manager'; +import { ensureWebSearchMcpOrThrow, displayWebSearchStatus } from '../../utils/websearch-manager'; import { ensureImageAnalysisMcpOrThrow, syncImageAnalysisMcpToConfigDir, - appendThirdPartyImageAnalysisToolArgs, } from '../../utils/image-analysis'; -import { - appendBrowserToolArgs, - type BrowserLaunchOverride, - ensureBrowserMcpOrThrow, - getBlockedBrowserOverrideWarning, - getEffectiveClaudeBrowserAttachConfig, - resolveBrowserExposure, - resolveBrowserLaunchFlagResolution, - resolveOptionalBrowserAttachRuntime, - syncBrowserMcpToConfigDir, -} from '../../utils/browser'; -import { - getBrowserConfig, - loadOrCreateUnifiedConfig, - getThinkingConfig, -} from '../../config/unified-config-loader'; +import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/config-loader-facade'; import { HttpsTunnelProxy } from '../proxy/https-tunnel-proxy'; -import { - isKiroAuthMethod, - isKiroIDCFlow, - KiroAuthMethod, - KiroIDCFlow, - normalizeKiroAuthMethod, - normalizeKiroIDCFlow, -} from '../auth/auth-types'; import { resolveProfileContinuityInheritance } from '../../auth/profile-continuity-inheritance'; // Import modular components +import { resolveBrowserLaunchFlags, resolveBrowserRuntime } from './browser-launch-setup'; +import { + resolveRuntimeQuotaMonitorProviders as _resolveRuntimeQuotaMonitorProviders, + resolveAccounts, +} from './account-resolution'; import { waitForProxyReadyWithSpinner, spawnProxy } from './lifecycle-manager'; import { buildClaudeEnvironment, logEnvironment, resolveCliproxyImageAnalysisEnv, } from './env-resolver'; -import { - isNetworkError, - handleNetworkError, - handleTokenExpiration, - handleQuotaCheck, -} from './retry-handler'; -import { MANAGED_QUOTA_PROVIDERS, type ManagedQuotaProvider } from '../quota/quota-manager'; -import { checkOrJoinProxy, registerProxySession, setupCleanupHandlers } from './session-bridge'; -import { parseThinkingOverride } from './thinking-arg-parser'; -import { - warnCrossProviderDuplicates, - warnOAuthBanRisk, - cleanupStaleAutoPauses, - enforceProviderIsolation, - restoreAutoPausedAccounts, -} from '../accounts/account-safety'; -import { - ensureCliAntigravityResponsibility, - hasAntigravityRiskAcceptanceFlag, - ANTIGRAVITY_ACCEPT_RISK_FLAGS, -} from '../auth/antigravity-responsibility'; +import { checkOrJoinProxy, registerProxySession } from './session-bridge'; import { getWebSearchHookEnv } from '../../utils/websearch-manager'; +import { + handleLogout, + handleImport, + resolveSkipLocalAuth, + runAntigravityGate, + ensureProviderAuthentication, + runPreflightQuotaCheck, + runAccountSafetyGuards, + ensureModelConfiguration, + ensureProviderSettingsFile, +} from './auth-coordinator'; import { buildThinkingStartupStatus, resolveRuntimeThinkingOverride, - shouldDisableCodexReasoning, } from './thinking-override-resolver'; import { shouldStartHttpsTunnel } from './https-tunnel-policy'; +import { filterCcsFlags, parseExecutorFlags, validateFlagCombinations } from './arg-parser'; +import { resolveExecutorProxy } from './proxy-resolver'; +import { buildProxyChain } from './proxy-chain-builder'; +import { warnBrokenModels } from './model-warnings'; +import { launchClaude } from './claude-launcher'; -function resolveRuntimeQuotaMonitorProviders( - provider: CLIProxyProvider, - compositeProviders: CLIProxyProvider[] -): ManagedQuotaProvider[] { - const candidates = compositeProviders.length > 0 ? compositeProviders : [provider]; - const resolved: ManagedQuotaProvider[] = []; - - for (const candidate of candidates) { - if ( - MANAGED_QUOTA_PROVIDERS.includes(candidate as ManagedQuotaProvider) && - !resolved.includes(candidate as ManagedQuotaProvider) - ) { - resolved.push(candidate as ManagedQuotaProvider); - } - } - - return resolved; -} +/** Local alias so internal call sites need no change */ +const resolveRuntimeQuotaMonitorProviders = _resolveRuntimeQuotaMonitorProviders; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -156,41 +79,9 @@ const DEFAULT_CONFIG: ExecutorConfig = { pollInterval: 100, }; -export function readOptionValue( - args: string[], - flag: string -): { present: boolean; value?: string; missingValue: boolean } { - const inlinePrefix = `${flag}=`; - const inlineArg = args.find((arg) => arg.startsWith(inlinePrefix)); - if (inlineArg !== undefined) { - const value = inlineArg.slice(inlinePrefix.length).trim(); - return { - present: true, - value: value.length > 0 ? value : undefined, - missingValue: value.length === 0, - }; - } - - const index = args.indexOf(flag); - if (index === -1) { - return { present: false, missingValue: false }; - } - - const next = args[index + 1]; - if (!next || next.startsWith('-')) { - return { present: true, missingValue: true }; - } - - return { present: true, value: next.trim(), missingValue: false }; -} - -export function hasGitLabTokenLoginFlag(args: string[]): boolean { - return args.includes('--gitlab-token-login') || args.includes('--token-login'); -} - -function getGitLabTokenLoginFlagName(args: string[]): '--gitlab-token-login' | '--token-login' { - return args.includes('--gitlab-token-login') ? '--gitlab-token-login' : '--token-login'; -} +// readOptionValue, hasGitLabTokenLoginFlag, CCS_FLAGS, filterCcsFlags are +// re-exported from ./arg-parser via the export block at the bottom of this file +// for backwards compatibility with external callers. /** * Execute Claude CLI with CLIProxy (main entry point) @@ -238,368 +129,50 @@ export async function execClaudeWithCLIProxy( // Collect all providers to validate (default + composite tiers) const allProviders = [provider, ...compositeProviders]; - const cliproxyServerConfig = unifiedConfig.cliproxy_server; - const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args, { - remote: cliproxyServerConfig?.remote - ? { - enabled: cliproxyServerConfig.remote.enabled, - host: cliproxyServerConfig.remote.host, - port: cliproxyServerConfig.remote.port, - protocol: cliproxyServerConfig.remote.protocol, - auth_token: cliproxyServerConfig.remote.auth_token, - management_key: cliproxyServerConfig.remote.management_key, - timeout: cliproxyServerConfig.remote.timeout, - } - : undefined, - local: cliproxyServerConfig?.local - ? { - port: cliproxyServerConfig.local.port, - auto_start: cliproxyServerConfig.local.auto_start, - } - : undefined, - }); - let browserLaunchOverride: BrowserLaunchOverride | undefined; - let argsWithoutBrowserFlags = argsWithoutProxy; - try { - const browserLaunchFlags = resolveBrowserLaunchFlagResolution(argsWithoutProxy); - browserLaunchOverride = browserLaunchFlags.override; - argsWithoutBrowserFlags = browserLaunchFlags.argsWithoutFlags; - } catch (error) { - console.error(fail((error as Error).message)); - process.exit(1); - return; - } - const browserConfig = getBrowserConfig(); - const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(browserConfig); - const claudeBrowserExposure = resolveBrowserExposure( - { - enabled: browserAttachConfig.enabled, - policy: browserConfig.claude.policy, - }, - browserLaunchOverride - ); - const blockedBrowserOverrideWarning = getBlockedBrowserOverrideWarning( - 'Claude Browser Attach', - claudeBrowserExposure - ); - if (blockedBrowserOverrideWarning) { - console.error(warn(blockedBrowserOverrideWarning)); - } + const { proxyConfig, useRemoteProxy, localBackend, binaryPath, argsWithoutProxy } = + await resolveExecutorProxy(args, { + unifiedConfig, + allProviders, + verbose, + cfg, + log, + }); - // Port resolution and validation - if (cfg.port && cfg.port !== CLIPROXY_DEFAULT_PORT) { - if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) { - cfg.port = proxyConfig.port; - } - } else if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) { - cfg.port = proxyConfig.port; - } - cfg.port = validatePort(cfg.port); - - log(`Proxy mode: ${proxyConfig.mode}`); - if (proxyConfig.mode === 'remote') { - log(`Remote host: ${proxyConfig.host}:${proxyConfig.port} (${proxyConfig.protocol})`); - } + const { browserLaunchOverride, argsWithoutBrowserFlags } = + resolveBrowserLaunchFlags(argsWithoutProxy); // Setup first-class CCS WebSearch runtime ensureWebSearchMcpOrThrow(); const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); - const browserAttachRuntime = - browserAttachConfig.enabled && claudeBrowserExposure.exposeForLaunch - ? await resolveOptionalBrowserAttachRuntime(browserAttachConfig) - : undefined; - const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; - if (browserAttachRuntime?.warning) { - process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`); - } - if (browserRuntimeEnv) { - ensureBrowserMcpOrThrow(); - } displayWebSearchStatus(); const providerConfig = getProviderConfig(provider); log(`Provider: ${providerConfig.displayName}`); - warnOAuthBanRisk(provider); - - // Check remote proxy if configured - let useRemoteProxy = false; - let localBackend: CLIProxyBackend = 'original'; - if (proxyConfig.mode === 'remote' && proxyConfig.host) { - const status = await checkRemoteProxy({ - host: proxyConfig.host, - port: proxyConfig.port, - protocol: proxyConfig.protocol, - authToken: proxyConfig.authToken, - timeout: proxyConfig.timeout ?? 2000, - allowSelfSigned: proxyConfig.allowSelfSigned ?? false, - }); - - if (status.reachable) { - useRemoteProxy = true; - console.log( - ok( - `Connected to remote proxy at ${proxyConfig.host}:${proxyConfig.port} (${status.latencyMs}ms)` - ) - ); - } else { - console.error(warn(`Remote proxy unreachable: ${status.error}`)); - - if (proxyConfig.remoteOnly) { - throw new Error('Remote proxy unreachable and --remote-only specified'); - } - - if (proxyConfig.fallbackEnabled) { - if (proxyConfig.autoStartLocal) { - console.log(info('Falling back to local proxy...')); - } else { - if (process.stdin.isTTY) { - const readline = await import('readline'); - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - const answer = await new Promise((resolve) => { - rl.question('Start local proxy instead? [Y/n] ', resolve); - }); - rl.close(); - if (answer.toLowerCase() === 'n') { - throw new Error('Remote proxy unreachable and user declined fallback'); - } - } - console.log(info('Starting local proxy...')); - } - } else { - throw new Error('Remote proxy unreachable and fallback disabled'); - } - } - } - - if (!useRemoteProxy) { - localBackend = getConfiguredBackend({ notifyOnPlus: true }); - - for (const p of allProviders) { - if (localBackend === 'original' && PLUS_ONLY_PROVIDERS.includes(p as CLIProxyProvider)) { - console.error(''); - console.error(fail(getPlusBackendUnavailableMessage(p))); - console.error(''); - throw new Error(`Provider ${p} requires local CLIProxy Plus backend`); - } - } - } // Variables for local proxy mode - let binaryPath: string | undefined; let sessionId: string | undefined; - // 1. Ensure binary exists (downloads if needed) - SKIP for remote mode - if (!useRemoteProxy) { - const spinner = new ProgressIndicator('Preparing CLIProxy'); - spinner.start(); + // 2. Parse all CCS executor flags (extracted to arg-parser.ts) + const parsedFlags = parseExecutorFlags(argsWithoutProxy, { + provider, + compositeProviders, + unifiedConfig, + }); + if (process.exitCode === 1) return; - try { - binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true }); - spinner.succeed('CLIProxy binary ready'); - } catch (error) { - spinner.fail('Failed to prepare CLIProxy'); - const err = error as Error; + // Validate cross-flag combinations (exits with code 1 on violation) + validateFlagCombinations(parsedFlags, { provider, compositeProviders }, argsWithoutProxy); + if (process.exitCode === 1) return; - if (isNetworkError(err)) { - handleNetworkError(err); - } - - throw error; - } - } - - // 2. Handle special flags (simplified flag parsing - full implementation continues below) - const forceAuth = argsWithoutProxy.includes('--auth'); - const pasteCallback = argsWithoutProxy.includes('--paste-callback'); - const portForward = argsWithoutProxy.includes('--port-forward'); - const forceHeadless = argsWithoutProxy.includes('--headless'); - - if (pasteCallback && portForward) { - console.error(fail('Cannot use --paste-callback with --port-forward')); - console.error(' --paste-callback: Manually paste OAuth redirect URL'); - console.error(' --port-forward: Use SSH port forwarding for callback'); - process.exit(1); - } - - const forceLogout = argsWithoutProxy.includes('--logout'); - const forceConfig = argsWithoutProxy.includes('--config'); - const addAccount = argsWithoutProxy.includes('--add'); - const showAccounts = argsWithoutProxy.includes('--accounts'); - const forceImport = argsWithoutProxy.includes('--import'); - const gitlabTokenLogin = hasGitLabTokenLoginFlag(argsWithoutProxy); - const acceptAgyRisk = hasAntigravityRiskAcceptanceFlag(argsWithoutProxy); - - const incognitoFlag = argsWithoutProxy.includes('--incognito'); - const noIncognitoFlag = argsWithoutProxy.includes('--no-incognito'); - const kiroNoIncognitoConfig = - provider === 'kiro' ? (unifiedConfig.cliproxy?.kiro_no_incognito ?? true) : false; - const noIncognito = incognitoFlag ? false : noIncognitoFlag || kiroNoIncognitoConfig; - - // Parse --use flag - let useAccount: string | undefined; - const useIdx = argsWithoutProxy.indexOf('--use'); - if ( - useIdx !== -1 && - argsWithoutProxy[useIdx + 1] && - !argsWithoutProxy[useIdx + 1].startsWith('-') - ) { - useAccount = argsWithoutProxy[useIdx + 1]; - } - - // Parse --nickname flag - let setNickname: string | undefined; - const nicknameIdx = argsWithoutProxy.indexOf('--nickname'); - if ( - nicknameIdx !== -1 && - argsWithoutProxy[nicknameIdx + 1] && - !argsWithoutProxy[nicknameIdx + 1].startsWith('-') - ) { - setNickname = argsWithoutProxy[nicknameIdx + 1]; - } - - // Parse --kiro-auth-method flag - let kiroAuthMethod: KiroAuthMethod | undefined; - const kiroMethodValue = readOptionValue(argsWithoutProxy, '--kiro-auth-method'); - if (kiroMethodValue.present) { - const rawMethod = kiroMethodValue.value; - if (kiroMethodValue.missingValue || !rawMethod) { - console.error(fail('--kiro-auth-method requires a value')); - console.error(' Supported values: aws, aws-authcode, google, github, idc'); - process.exitCode = 1; - return; - } - const normalized = rawMethod.trim().toLowerCase(); - if (!isKiroAuthMethod(normalized)) { - console.error(fail(`Invalid --kiro-auth-method value: ${rawMethod}`)); - console.error(' Supported values: aws, aws-authcode, google, github, idc'); - process.exitCode = 1; - return; - } - kiroAuthMethod = normalizeKiroAuthMethod(normalized); - } - - let kiroIDCStartUrl: string | undefined; - const kiroIDCStartUrlValue = readOptionValue(argsWithoutProxy, '--kiro-idc-start-url'); - if (kiroIDCStartUrlValue.present && kiroIDCStartUrlValue.value) { - kiroIDCStartUrl = kiroIDCStartUrlValue.value; - } else if (kiroIDCStartUrlValue.present) { - console.error(fail('--kiro-idc-start-url requires a value')); - process.exitCode = 1; - return; - } - - let kiroIDCRegion: string | undefined; - const kiroIDCRegionValue = readOptionValue(argsWithoutProxy, '--kiro-idc-region'); - if (kiroIDCRegionValue.present && kiroIDCRegionValue.value) { - kiroIDCRegion = kiroIDCRegionValue.value; - } else if (kiroIDCRegionValue.present) { - console.error(fail('--kiro-idc-region requires a value')); - process.exitCode = 1; - return; - } - - let kiroIDCFlow: KiroIDCFlow | undefined; - const kiroIDCFlowValue = readOptionValue(argsWithoutProxy, '--kiro-idc-flow'); - if (kiroIDCFlowValue.present) { - const rawFlow = kiroIDCFlowValue.value; - if (kiroIDCFlowValue.missingValue || !rawFlow) { - console.error(fail('--kiro-idc-flow requires a value')); - console.error(' Supported values: authcode, device'); - process.exitCode = 1; - return; - } - const normalized = rawFlow.trim().toLowerCase(); - if (!isKiroIDCFlow(normalized)) { - console.error(fail(`Invalid --kiro-idc-flow value: ${rawFlow}`)); - console.error(' Supported values: authcode, device'); - process.exitCode = 1; - return; - } - kiroIDCFlow = normalizeKiroIDCFlow(normalized); - } - - let gitlabBaseUrl: string | undefined; - const gitlabBaseUrlValue = readOptionValue(argsWithoutProxy, '--gitlab-url'); - if (gitlabBaseUrlValue.present && gitlabBaseUrlValue.value) { - gitlabBaseUrl = gitlabBaseUrlValue.value.trim(); - } else if (gitlabBaseUrlValue.present) { - console.error(fail('--gitlab-url requires a value')); - process.exitCode = 1; - return; - } - - if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) { - console.error(fail('--kiro-auth-method is only valid for ccs kiro')); - process.exitCode = 1; - return; - } - - if ( - (kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow) && - provider !== 'kiro' && - !compositeProviders.includes('kiro') - ) { - console.error( - fail( - '--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow are only valid for ccs kiro' - ) - ); - process.exitCode = 1; - return; - } - - if (!kiroAuthMethod && (kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow)) { - kiroAuthMethod = 'idc'; - } - - if (kiroAuthMethod === 'idc' && !kiroIDCStartUrl) { - console.error(fail('Kiro IDC login requires --kiro-idc-start-url')); - console.error( - ' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start' - ); - process.exitCode = 1; - return; - } - - if ( - kiroAuthMethod && - kiroAuthMethod !== 'idc' && - (kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow) - ) { - console.error( - fail( - '--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow require --kiro-auth-method idc' - ) - ); - process.exitCode = 1; - return; - } - - if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') { - const flagName = gitlabTokenLogin - ? getGitLabTokenLoginFlagName(argsWithoutProxy) - : '--gitlab-url'; - console.error(fail(`${flagName} is only valid for ccs gitlab`)); - process.exitCode = 1; - return; - } - - // Parse --thinking / --effort flags (aliases; first occurrence wins) - const thinkingParse = parseThinkingOverride(argsWithoutProxy); - if (thinkingParse.error) { - const { flag } = thinkingParse.error; - console.error(fail(`${flag} requires a value`)); - - if (provider === 'codex') { - console.error(' Codex examples: --effort xhigh, --effort high, --effort medium'); - console.error(' Alias: --thinking xhigh (same behavior)'); - } else { - console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); - console.error(' Levels: minimal, low, medium, high, xhigh, max, auto'); - } - - process.exit(1); - } + const { + forceConfig, + addAccount, + showAccounts, + useAccount, + setNickname, + extendedContextOverride, + thinkingParse, + } = parsedFlags; const { thinkingOverride, thinkingSource } = resolveRuntimeThinkingOverride( thinkingParse.value, @@ -621,88 +194,8 @@ export async function execClaudeWithCLIProxy( ); } - // Parse --1m / --no-1m flags for extended context (1M token window) - let extendedContextOverride: boolean | undefined; - const has1mFlag = - argsWithoutProxy.includes('--1m') || argsWithoutProxy.some((arg) => arg.startsWith('--1m=')); - const hasNo1mFlag = - argsWithoutProxy.includes('--no-1m') || - argsWithoutProxy.some((arg) => arg.startsWith('--no-1m=')); - - if (has1mFlag && hasNo1mFlag) { - console.error(fail('Cannot use both --1m and --no-1m flags')); - process.exit(1); - } else if (has1mFlag) { - extendedContextOverride = true; - } else if (hasNo1mFlag) { - extendedContextOverride = false; - } - // undefined = auto behavior (Gemini: on, others: off) - - // Handle --accounts - if (showAccounts) { - const accounts = getProviderAccounts(provider); - if (accounts.length === 0) { - console.log(info(`No accounts registered for ${providerConfig.displayName}`)); - console.log(` Run "ccs ${provider} --auth" to add an account`); - } else { - console.log(`\n${providerConfig.displayName} Accounts:\n`); - for (const acct of accounts) { - const defaultMark = acct.isDefault ? ' (default)' : ''; - const nickname = acct.nickname ? `[${acct.nickname}]` : ''; - console.log(` ${nickname.padEnd(12)} ${formatAccountDisplayName(acct)}${defaultMark}`); - } - console.log(`\n Use "ccs ${provider} --use " to switch accounts`); - } - process.exit(0); - } - - // Handle --use - if (useAccount) { - const account = findAccountByQuery(provider, useAccount); - if (!account) { - console.error(fail(`Account not found: "${useAccount}"`)); - const accounts = getProviderAccounts(provider); - if (accounts.length > 0) { - console.error(` Available accounts:`); - for (const acct of accounts) { - const displayName = formatAccountDisplayName(acct); - const label = acct.nickname ? `${acct.nickname} (${displayName})` : displayName; - console.error(` - ${label}`); - } - } - process.exit(1); - } - setDefaultAccount(provider, account.id); - touchAccount(provider, account.id); - const switchedLabel = account.nickname - ? `${account.nickname} (${formatAccountDisplayName(account)})` - : formatAccountDisplayName(account); - console.log(ok(`Switched to account: ${switchedLabel}`)); - } - - // Handle --nickname (rename account) - if (setNickname && !addAccount) { - const defaultAccount = getDefaultAccount(provider); - if (!defaultAccount) { - console.error(fail(`No account found for ${providerConfig.displayName}`)); - console.error(` Run "ccs ${provider} --auth" to add an account first`); - process.exit(1); - } - try { - const success = renameAccount(provider, defaultAccount.id, setNickname); - if (success) { - console.log(ok(`Renamed account to: ${setNickname}`)); - } else { - console.error(fail('Failed to rename account')); - process.exit(1); - } - } catch (err) { - console.error(fail(err instanceof Error ? err.message : 'Failed to rename account')); - process.exit(1); - } - process.exit(0); - } + // Handle --accounts / --use / --nickname (warnOAuthBanRisk emitted inside) + await resolveAccounts({ provider, showAccounts, useAccount, setNickname, addAccount }); // Handle --config if (forceConfig && supportsModelConfig(provider)) { @@ -720,280 +213,58 @@ export async function execClaudeWithCLIProxy( } } - // Handle --logout - if (forceLogout) { - const { clearAuth } = await import('../auth/auth-handler'); - if (clearAuth(provider)) { - console.log(ok(`Logged out from ${providerConfig.displayName}`)); - } else { - console.log(info(`No authentication found for ${providerConfig.displayName}`)); - } - process.exit(0); - } + // Build auth coordination context (used for logout/import/antigravity/oauth) + const authCtx = { + provider, + compositeProviders, + parsedFlags, + cfg, + unifiedConfig, + verbose, + log, + }; - // Handle --import (Kiro only) - if (forceImport) { - if (provider !== 'kiro') { - console.error(fail('--import is only available for Kiro')); - console.error(` Run "ccs ${provider} --auth" to authenticate`); - process.exit(1); - } - if (forceAuth) { - console.error(fail('Cannot use --import with --auth')); - console.error(' --import: Import existing token from Kiro IDE'); - console.error(' --auth: Trigger new OAuth flow in browser'); - process.exit(1); - } - if (forceLogout) { - console.error(fail('Cannot use --import with --logout')); - process.exit(1); - } - const { triggerOAuth } = await import('../auth/auth-handler'); - const authSuccess = await triggerOAuth(provider, { - verbose, - import: true, - ...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}), - ...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}), - ...(kiroIDCRegion ? { kiroIDCRegion } : {}), - ...(kiroIDCFlow ? { kiroIDCFlow } : {}), - ...(setNickname ? { nickname: setNickname } : {}), - }); - if (!authSuccess) { - console.error(fail('Failed to import Kiro token from IDE')); - console.error(' Make sure you are logged into Kiro IDE first'); - process.exit(1); - } - process.exit(0); - } + // Handle --logout (early exit) + await handleLogout(authCtx); + + // Handle --import (early exit, Kiro only) + await handleImport(authCtx); // 3. Ensure OAuth completed (if provider requires it) const remoteAuthToken = proxyConfig.authToken?.trim(); - const skipLocalAuth = useRemoteProxy && !!remoteAuthToken; + const skipLocalAuth = resolveSkipLocalAuth(remoteAuthToken, useRemoteProxy); if (skipLocalAuth) { log(`Using remote proxy authentication (skipping local OAuth)`); } - if (provider === 'agy' && forceAuth && skipLocalAuth) { - const acknowledged = await ensureCliAntigravityResponsibility({ - context: 'oauth', - acceptedByFlag: acceptAgyRisk, - }); - if (!acknowledged) { - throw new Error( - `Antigravity auth blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.` - ); - } - console.error(info('Remote proxy mode is active; local OAuth flow is skipped in --auth mode.')); - return; - } - - if (provider === 'agy' && !forceAuth) { - const requiresAuthNow = providerConfig.requiresOAuth && !isAuthenticated(provider); - if (skipLocalAuth || !requiresAuthNow) { - const acknowledged = await ensureCliAntigravityResponsibility({ - context: 'run', - acceptedByFlag: acceptAgyRisk, - }); - if (!acknowledged) { - console.error( - fail( - `Antigravity session blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.` - ) - ); - process.exit(1); - } - } - } + // Antigravity gate (runs before OAuth check) + const { earlyReturn: agyEarlyReturn } = await runAntigravityGate(authCtx, skipLocalAuth); + if (agyEarlyReturn) return; if (providerConfig.requiresOAuth && !skipLocalAuth) { - log(`Checking authentication for ${provider}`); - - // Multi-provider auth check for composite variants - if (compositeProviders.length > 0) { - // Handle forceAuth for composite providers - if (forceAuth) { - const { triggerOAuth } = await import('../auth/auth-handler'); - const failures: string[] = []; - for (const p of compositeProviders) { - const authSuccess = await triggerOAuth(p, { - verbose, - add: addAccount, - ...(acceptAgyRisk ? { acceptAgyRisk: true } : {}), - ...(kiroAuthMethod && p === 'kiro' ? { kiroMethod: kiroAuthMethod } : {}), - ...(kiroIDCStartUrl && p === 'kiro' ? { kiroIDCStartUrl } : {}), - ...(kiroIDCRegion && p === 'kiro' ? { kiroIDCRegion } : {}), - ...(kiroIDCFlow && p === 'kiro' ? { kiroIDCFlow } : {}), - ...(gitlabTokenLogin && p === 'gitlab' ? { gitlabAuthMode: 'pat' as const } : {}), - ...(gitlabBaseUrl && p === 'gitlab' ? { gitlabBaseUrl } : {}), - ...(forceHeadless ? { headless: true } : {}), - ...(setNickname ? { nickname: setNickname } : {}), - ...(noIncognito ? { noIncognito: true } : {}), - ...(pasteCallback ? { pasteCallback: true } : {}), - ...(portForward ? { portForward: true } : {}), - }); - if (!authSuccess) { - failures.push(p); - } - } - if (failures.length > 0) { - const succeeded = compositeProviders.filter((p) => !failures.includes(p)); - console.error(fail(`Auth failed for: ${failures.join(', ')}`)); - if (succeeded.length > 0) { - console.error(info(`Succeeded: ${succeeded.join(', ')}`)); - } - process.exit(1); - } - process.exit(0); - } - - // Check for unauthenticated providers - const unauthenticatedProviders: string[] = []; - for (const p of compositeProviders) { - if (!isAuthenticated(p)) { - unauthenticatedProviders.push(p); - } - } - if (unauthenticatedProviders.length > 0) { - console.error(fail('Composite variant requires authentication for multiple providers:')); - for (const p of unauthenticatedProviders) { - console.error(` - ${p} (run "ccs ${p} --auth")`); - } - process.exit(1); - } - } else if (forceAuth || !isAuthenticated(provider)) { - const { triggerOAuth } = await import('../auth/auth-handler'); - const authSuccess = await triggerOAuth(provider, { - verbose, - add: addAccount, - ...(acceptAgyRisk ? { acceptAgyRisk: true } : {}), - ...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}), - ...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}), - ...(kiroIDCRegion ? { kiroIDCRegion } : {}), - ...(kiroIDCFlow ? { kiroIDCFlow } : {}), - ...(gitlabTokenLogin ? { gitlabAuthMode: 'pat' as const } : {}), - ...(gitlabBaseUrl ? { gitlabBaseUrl } : {}), - ...(forceHeadless ? { headless: true } : {}), - ...(setNickname ? { nickname: setNickname } : {}), - ...(noIncognito ? { noIncognito: true } : {}), - ...(pasteCallback ? { pasteCallback: true } : {}), - ...(portForward ? { portForward: true } : {}), - }); - if (!authSuccess) { - throw new Error(`Authentication required for ${providerConfig.displayName}`); - } - if (forceAuth) { - process.exit(0); - } - } else { - log(`${provider} already authenticated`); - } - - // 3a. Proactive token refresh (multi-provider for composite) - if (compositeProviders.length > 0) { - for (const p of compositeProviders) { - await handleTokenExpiration(p, verbose); - } - } else { - await handleTokenExpiration(provider, verbose); - } - - // 3a-1. Update lastUsedAt - const usedAccount = getDefaultAccount(provider); - if (usedAccount) { - touchAccount(provider, usedAccount.id); - } + await ensureProviderAuthentication(authCtx); } // 3b. Preflight quota check (providers with quota-based rotation) if (!skipLocalAuth) { - // Multi-tier quota check for composite variants (check if any tier uses a managed provider) - if (compositeProviders.length > 0) { - for (const managedProvider of MANAGED_QUOTA_PROVIDERS) { - if (compositeProviders.includes(managedProvider)) { - await handleQuotaCheck(managedProvider); - } - } - } else { - await handleQuotaCheck(provider); - } + await runPreflightQuotaCheck(provider, compositeProviders); } // 3c. Account safety: enforce cross-provider isolation if (!skipLocalAuth) { - cleanupStaleAutoPauses(); - const isolated = enforceProviderIsolation(provider); - if (isolated === 0) { - // No enforcement — still warn about duplicates for awareness - warnCrossProviderDuplicates(provider); - } else { - // 'exit' handlers must be synchronous — restoreAutoPausedAccounts uses sync fs APIs - process.on('exit', () => { - restoreAutoPausedAccounts(provider); - }); - } + runAccountSafetyGuards(provider, compositeProviders); } - // 4. First-run model configuration - if (!cfg.isComposite && supportsModelConfig(provider) && !skipLocalAuth) { - await configureProviderModel(provider, false, cfg.customSettingsPath); + // 4. First-run model configuration + codex plan reconcile + if (!skipLocalAuth) { + await ensureModelConfiguration(provider, cfg, verbose); } // 5. Check for broken models (multi-tier for composite) - if (compositeProviders.length > 0 && cfg.compositeTiers) { - // Check all tier models in composite variant - const tiers: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku']; - for (const tier of tiers) { - const tierConfig = cfg.compositeTiers[tier]; - if (tierConfig && isModelBroken(tierConfig.provider, tierConfig.model)) { - const modelEntry = findModel(tierConfig.provider, tierConfig.model); - const issueUrl = getModelIssueUrl(tierConfig.provider, tierConfig.model); - console.error(''); - console.error( - warn( - `${tier} tier: ${modelEntry?.name || tierConfig.model} has known issues with Claude Code` - ) - ); - console.error(' Tool calls will fail. Consider changing the model in config.yaml.'); - if (issueUrl) { - console.error(` Tracking: ${issueUrl}`); - } - console.error(''); - } - } - } else { - const currentModel = getCurrentModel(provider, cfg.customSettingsPath); - if (currentModel && isModelBroken(provider, currentModel)) { - const modelEntry = findModel(provider, currentModel); - const issueUrl = getModelIssueUrl(provider, currentModel); - const replacementModel = getSuggestedReplacementModel(provider, currentModel); - console.error(''); - console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`)); - if (replacementModel) { - console.error(` Tool calls will fail. Use "${replacementModel}" instead.`); - } else { - console.error(' Tool calls will fail. Consider changing the model in config.yaml.'); - } - if (issueUrl) { - console.error(` Tracking: ${issueUrl}`); - } - if (skipLocalAuth) { - console.error(' Note: Model may be overridden by remote proxy configuration.'); - } else { - console.error(` Run "ccs ${provider} --config" to change model.`); - } - console.error(''); - } - } + warnBrokenModels({ provider, cfg, compositeProviders, skipLocalAuth }); // 6. Ensure user settings file exists - ensureProviderSettings(provider); - - if (provider === 'codex' && !cfg.isComposite && !skipLocalAuth) { - await reconcileCodexModelForActivePlan({ - currentModel: getCurrentModel(provider, cfg.customSettingsPath), - verbose, - }); - } + ensureProviderSettingsFile(provider); // Local proxy mode: generate config, spawn/join proxy, track session let proxy: ChildProcess | null = null; @@ -1033,7 +304,7 @@ export async function execClaudeWithCLIProxy( } } - // 8. Setup HTTPS tunnel if needed + // 8. Setup HTTPS tunnel if needed (tunnelPort used by imageAnalysisProxyTarget below) let httpsTunnel: HttpsTunnelProxy | null = null; let tunnelPort: number | null = null; @@ -1103,9 +374,11 @@ export async function execClaudeWithCLIProxy( ? 'ImageAnalysis MCP provisioning failed. This session will use compatibility fallback when available.' : imageAnalysisResolution.warning; - // 9. Setup tool sanitization proxy + // 9. Resolve config dir + browser runtime (needed before proxy chain) let toolSanitizationProxy: ToolSanitizationProxy | null = null; let toolSanitizationPort: number | null = null; + let codexReasoningProxy: CodexReasoningProxy | null = null; + let codexReasoningPort: number | null = null; let inheritedClaudeConfigDir = cfg.claudeConfigDir; if (!inheritedClaudeConfigDir && cfg.profileName) { @@ -1123,15 +396,12 @@ export async function execClaudeWithCLIProxy( } 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.' - ); - } + + // Resolve browser attach runtime and sync browser MCP (needs inheritedClaudeConfigDir) + const { browserRuntimeEnv } = await resolveBrowserRuntime( + browserLaunchOverride, + inheritedClaudeConfigDir + ); // Build initial env vars to get ANTHROPIC_BASE_URL const initialEnvVars = buildClaudeEnvironment({ @@ -1159,74 +429,19 @@ export async function execClaudeWithCLIProxy( imageAnalysisEnv, }); - if (initialEnvVars.ANTHROPIC_BASE_URL) { - try { - toolSanitizationProxy = new ToolSanitizationProxy({ - upstreamBaseUrl: initialEnvVars.ANTHROPIC_BASE_URL, - verbose, - warnOnSanitize: true, - allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false, - }); - toolSanitizationPort = await toolSanitizationProxy.start(); - log(`Tool sanitization proxy active on port ${toolSanitizationPort}`); - } catch (error) { - const err = error as Error; - toolSanitizationProxy = null; - toolSanitizationPort = null; - if (verbose) { - console.error(warn(`Tool sanitization proxy disabled: ${err.message}`)); - } - } - } - - const postSanitizationBaseUrl = toolSanitizationPort - ? `http://127.0.0.1:${toolSanitizationPort}` - : initialEnvVars.ANTHROPIC_BASE_URL; - - // 10. Setup Codex reasoning proxy (single-provider Codex only) - let codexReasoningProxy: CodexReasoningProxy | null = null; - let codexReasoningPort: number | null = null; - - // Composite variants require root model-routed endpoints, never provider-pinned codex endpoints. - if (provider === 'codex' && !cfg.isComposite) { - if (!postSanitizationBaseUrl) { - log('ANTHROPIC_BASE_URL not set for Codex, reasoning proxy disabled'); - } else { - try { - const traceEnabled = - process.env.CCS_CODEX_REASONING_TRACE === '1' || - process.env.CCS_CODEX_REASONING_TRACE === 'true'; - const stripPathPrefix = useRemoteProxy ? '/api/provider/codex' : undefined; - const codexThinkingOff = shouldDisableCodexReasoning(thinkingCfg, thinkingOverride); - codexReasoningProxy = new CodexReasoningProxy({ - upstreamBaseUrl: postSanitizationBaseUrl, - verbose, - defaultEffort: 'medium', - disableEffort: codexThinkingOff, - traceFilePath: traceEnabled ? path.join(getCcsDir(), 'codex-reasoning-proxy.log') : '', - allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false, - modelMap: { - defaultModel: initialEnvVars.ANTHROPIC_MODEL, - opusModel: initialEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL, - sonnetModel: initialEnvVars.ANTHROPIC_DEFAULT_SONNET_MODEL, - haikuModel: initialEnvVars.ANTHROPIC_DEFAULT_HAIKU_MODEL, - }, - stripPathPrefix, - }); - codexReasoningPort = await codexReasoningProxy.start(); - log( - `Codex reasoning proxy active: http://127.0.0.1:${codexReasoningPort}/api/provider/codex` - ); - } catch (error) { - const err = error as Error; - codexReasoningProxy = null; - codexReasoningPort = null; - if (verbose) { - console.error(warn(`Codex reasoning proxy disabled: ${err.message}`)); - } - } - } - } + // 9b. Build env-dependent proxy chain (tool-sanitization + codex-reasoning) + ({ toolSanitizationProxy, toolSanitizationPort, codexReasoningProxy, codexReasoningPort } = + await buildProxyChain({ + provider, + useRemoteProxy, + proxyConfig, + cfg, + initialEnvVars, + thinkingOverride, + thinkingCfg, + verbose, + log, + })); // 11. Build final environment with all proxy chains const env = buildClaudeEnvironment({ @@ -1297,130 +512,37 @@ export async function execClaudeWithCLIProxy( console.error(`[i] Thinking: ${thinkingLabel} (${sourceLabel})`); } - // 12. Filter CCS-specific flags before passing to Claude CLI - const ccsFlags = [ - '--auth', - '--paste-callback', - '--port-forward', - '--headless', - '--logout', - '--config', - '--add', - '--accounts', - '--use', - '--nickname', - '--kiro-auth-method', - '--kiro-idc-start-url', - '--kiro-idc-region', - '--kiro-idc-flow', - '--thinking', - '--effort', - '--1m', - '--no-1m', - '--incognito', - '--no-incognito', - '--import', - '--accept-agr-risk', - '--accept-antigravity-risk', - '--settings', - ...PROXY_CLI_FLAGS, - ]; - const claudeArgs = argsWithoutBrowserFlags.filter((arg, idx) => { - if (ccsFlags.includes(arg)) return false; - if (arg.startsWith('--kiro-auth-method=')) return false; - if (arg.startsWith('--kiro-idc-start-url=')) return false; - if (arg.startsWith('--kiro-idc-region=')) return false; - if (arg.startsWith('--kiro-idc-flow=')) return false; - if (arg.startsWith('--thinking=')) return false; - if (arg.startsWith('--effort=')) return false; - if (arg.startsWith('--1m=') || arg.startsWith('--no-1m=')) return false; - if ( - argsWithoutBrowserFlags[idx - 1] === '--use' || - argsWithoutBrowserFlags[idx - 1] === '--nickname' || - argsWithoutBrowserFlags[idx - 1] === '--kiro-auth-method' || - argsWithoutBrowserFlags[idx - 1] === '--kiro-idc-start-url' || - argsWithoutBrowserFlags[idx - 1] === '--kiro-idc-region' || - argsWithoutBrowserFlags[idx - 1] === '--kiro-idc-flow' || - argsWithoutBrowserFlags[idx - 1] === '--thinking' || - argsWithoutBrowserFlags[idx - 1] === '--effort' - ) - return false; - return true; - }); - - const isWindows = process.platform === 'win32'; - const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); - - const settingsPath = cfg.customSettingsPath - ? cfg.customSettingsPath.replace(/^~/, os.homedir()) - : getProviderSettingsPath(provider); - - let claude: ChildProcess; - const imageAnalysisArgs = imageAnalysisMcpReady - ? appendThirdPartyImageAnalysisToolArgs(claudeArgs) - : claudeArgs; - const browserArgs = browserRuntimeEnv - ? appendBrowserToolArgs(imageAnalysisArgs) - : imageAnalysisArgs; - const launchArgs = [ - '--settings', - settingsPath, - ...appendThirdPartyWebSearchToolArgs(browserArgs), - ]; - const traceEnv = createWebSearchTraceContext({ - launcher: 'cliproxy.executor', - args: launchArgs, - profile: cfg.profileName || provider, - profileType: 'cliproxy', - settingsPath, - claudeConfigDir: inheritedClaudeConfigDir, - }); - const tracedEnv = { ...env, ...traceEnv }; - if (needsShell) { - const cmdString = [claudeCli, ...launchArgs].map(escapeShellArg).join(' '); - claude = spawn(cmdString, { - stdio: 'inherit', - windowsHide: true, - shell: getWindowsEscapedCommandShell(), - env: tracedEnv, - }); - } else { - claude = spawn(claudeCli, launchArgs, { - stdio: 'inherit', - windowsHide: true, - env: tracedEnv, - }); - } - - // 12b. Start runtime quota monitor (adaptive polling during session) - if (!skipLocalAuth) { - const { startQuotaMonitor } = await import('../quota/quota-manager'); - for (const monitorProvider of resolveRuntimeQuotaMonitorProviders( - provider, - compositeProviders - )) { - const monitorAccount = getDefaultAccount(monitorProvider); - if (monitorAccount) { - startQuotaMonitor(monitorProvider, monitorAccount.id); - } - } - } - - // 13. Setup cleanup handlers - setupCleanupHandlers( - claude, + // 12. Filter CCS flags, spawn Claude CLI, start quota monitor, wire cleanup + const claudeArgs = filterCcsFlags(argsWithoutBrowserFlags); + await launchClaude({ + claudeCli, + claudeArgs, + env, + cfg, + provider, + compositeProviders, + skipLocalAuth, sessionId, - cfg.port, + imageAnalysisMcpReady, + browserRuntimeEnv, + inheritedClaudeConfigDir, codexReasoningProxy, toolSanitizationProxy, httpsTunnel, - verbose - ); + verbose, + }); } // Re-export utility functions for backwards compatibility export { isPortAvailable, findAvailablePort } from './lifecycle-manager'; +// Re-export arg-parser helpers (previously inlined here; external callers can +// import from index or directly from ./arg-parser) +export { readOptionValue, hasGitLabTokenLoginFlag, CCS_FLAGS, filterCcsFlags } from './arg-parser'; + +// Re-export account-resolution helpers for backwards compat with __testExports consumers +export { resolveRuntimeQuotaMonitorProviders as _resolveRuntimeQuotaMonitorProviders } from './account-resolution'; + export const __testExports = { resolveRuntimeQuotaMonitorProviders, }; diff --git a/src/cliproxy/executor/model-warnings.ts b/src/cliproxy/executor/model-warnings.ts new file mode 100644 index 00000000..f7c1bfab --- /dev/null +++ b/src/cliproxy/executor/model-warnings.ts @@ -0,0 +1,80 @@ +/** + * Model Warnings — Concern G + * + * Emits console warnings when the active model (or any tier model in composite + * variants) is flagged as broken in the model catalog. + */ + +import { warn } from '../../utils/ui'; +import { getCurrentModel } from '../config/model-config'; +import { + isModelBroken, + getModelIssueUrl, + findModel, + getSuggestedReplacementModel, +} from '../model-catalog'; +import { CLIProxyProvider, ExecutorConfig } from '../types'; + +export interface ModelWarningsContext { + provider: CLIProxyProvider; + cfg: ExecutorConfig; + compositeProviders: CLIProxyProvider[]; + skipLocalAuth: boolean; + customSettingsPath?: string; +} + +/** + * Check all active models for known issues and emit warnings. + * + * For composite variants, checks every tier model. + * For simple providers, checks the currently configured model. + */ +export function warnBrokenModels(context: ModelWarningsContext): void { + const { provider, cfg, skipLocalAuth } = context; + + if (cfg.isComposite && cfg.compositeTiers) { + // Check all tier models in composite variant + const tiers: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku']; + for (const tier of tiers) { + const tierConfig = cfg.compositeTiers[tier]; + if (tierConfig && isModelBroken(tierConfig.provider, tierConfig.model)) { + const modelEntry = findModel(tierConfig.provider, tierConfig.model); + const issueUrl = getModelIssueUrl(tierConfig.provider, tierConfig.model); + console.error(''); + console.error( + warn( + `${tier} tier: ${modelEntry?.name || tierConfig.model} has known issues with Claude Code` + ) + ); + console.error(' Tool calls will fail. Consider changing the model in config.yaml.'); + if (issueUrl) { + console.error(` Tracking: ${issueUrl}`); + } + console.error(''); + } + } + } else { + const currentModel = getCurrentModel(provider, cfg.customSettingsPath); + if (currentModel && isModelBroken(provider, currentModel)) { + const modelEntry = findModel(provider, currentModel); + const issueUrl = getModelIssueUrl(provider, currentModel); + const replacementModel = getSuggestedReplacementModel(provider, currentModel); + console.error(''); + console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`)); + if (replacementModel) { + console.error(` Tool calls will fail. Use "${replacementModel}" instead.`); + } else { + console.error(' Tool calls will fail. Consider changing the model in config.yaml.'); + } + if (issueUrl) { + console.error(` Tracking: ${issueUrl}`); + } + if (skipLocalAuth) { + console.error(' Note: Model may be overridden by remote proxy configuration.'); + } else { + console.error(` Run "ccs ${provider} --config" to change model.`); + } + console.error(''); + } + } +} diff --git a/src/cliproxy/executor/proxy-chain-builder.ts b/src/cliproxy/executor/proxy-chain-builder.ts new file mode 100644 index 00000000..e76b6519 --- /dev/null +++ b/src/cliproxy/executor/proxy-chain-builder.ts @@ -0,0 +1,185 @@ +/** + * Proxy Chain Builder (Concern F — tool-sanitization + codex-reasoning layers) + * + * Orchestrates the two env-dependent proxy layers that sit between the + * Claude CLI process and the upstream CLIProxy / remote endpoint: + * + * 1. ToolSanitizationProxy — wraps ANTHROPIC_BASE_URL to sanitize tool names/schemas + * 2. CodexReasoningProxy — only for single-provider codex, wraps tool-san URL + * + * Placement in the execution sequence + * ───────────────────────────────────── + * The HTTPS tunnel (HttpsTunnelProxy) is started BEFORE this function is called, + * because its tunnelPort is needed by both imageAnalysisResolution and the + * first-pass buildClaudeEnvironment. This function receives the result of that + * first-pass env as `initialEnvVars`, from which it extracts ANTHROPIC_BASE_URL + * to wire up the tool-sanitization proxy. + * + * Lifecycle: proxies are started (spawned) but NOT stopped here. The caller + * is responsible for registering cleanup handlers (setupCleanupHandlers). + */ + +import * as path from 'path'; +import { warn } from '../../utils/ui'; +import { getCcsDir } from '../../config/config-loader-facade'; +import { + ToolSanitizationProxy, + type ToolSanitizationProxyConfig, +} from '../proxy/tool-sanitization-proxy'; +import { + CodexReasoningProxy, + type CodexReasoningProxyConfig, +} from '../ai-providers/codex-reasoning-proxy'; +import { shouldDisableCodexReasoning } from './thinking-override-resolver'; +import type { CLIProxyProvider, ExecutorConfig, ResolvedProxyConfig } from '../types'; +import type { ThinkingConfig } from '../../config/unified-config-types'; + +// ── Proxy constructor types (for dependency injection in tests) ─────────────── + +type ToolSanitizationProxyCtor = new (config: ToolSanitizationProxyConfig) => ToolSanitizationProxy; +type CodexReasoningProxyCtor = new (config: CodexReasoningProxyConfig) => CodexReasoningProxy; + +// ── Public types ────────────────────────────────────────────────────────────── + +export interface ProxyChainContext { + /** Provider being executed */ + provider: CLIProxyProvider; + /** True when routing to a remote proxy host */ + useRemoteProxy: boolean; + /** Resolved proxy configuration (host, port, protocol, auth, …) */ + proxyConfig: ResolvedProxyConfig; + /** Executor configuration */ + cfg: ExecutorConfig; + /** + * Initial environment from first-pass buildClaudeEnvironment. + * ANTHROPIC_BASE_URL and ANTHROPIC_MODEL* keys are consumed here. + */ + initialEnvVars: Partial>; + /** Thinking mode override (value or undefined for default) */ + thinkingOverride: string | number | undefined; + /** Thinking configuration from unified config */ + thinkingCfg: ThinkingConfig; + verbose: boolean; + log: (msg: string) => void; + /** + * Optional constructor overrides for unit testing. + * Production code omits these; tests inject stubs to avoid real HTTP servers. + */ + _ToolSanitizationProxy?: ToolSanitizationProxyCtor; + _CodexReasoningProxy?: CodexReasoningProxyCtor; +} + +export interface ProxyChainResult { + toolSanitizationProxy: ToolSanitizationProxy | null; + toolSanitizationPort: number | null; + codexReasoningProxy: CodexReasoningProxy | null; + codexReasoningPort: number | null; +} + +// ── Implementation ──────────────────────────────────────────────────────────── + +/** + * Build and start the two env-dependent proxy layers (tool-sanitization and + * codex-reasoning). Each layer is started independently; a failed start is + * swallowed with a verbose warning so the session can continue degraded. + * + * Note: HttpsTunnelProxy is started inline in the orchestrator (index.ts) + * before this function is called, because tunnelPort is required for + * imageAnalysisResolution and the first-pass buildClaudeEnvironment. + */ +export async function buildProxyChain(context: ProxyChainContext): Promise { + const { + provider, + useRemoteProxy, + proxyConfig, + cfg, + initialEnvVars, + thinkingOverride, + thinkingCfg, + verbose, + log, + // Allow test injection of proxy constructors so tests never spin up real servers + _ToolSanitizationProxy: ToolSanitizationProxyImpl = ToolSanitizationProxy, + _CodexReasoningProxy: CodexReasoningProxyImpl = CodexReasoningProxy, + } = context; + + // ── Step 1: Tool sanitization proxy ──────────────────────────────────────── + let toolSanitizationProxy: ToolSanitizationProxy | null = null; + let toolSanitizationPort: number | null = null; + + if (initialEnvVars.ANTHROPIC_BASE_URL) { + try { + toolSanitizationProxy = new ToolSanitizationProxyImpl({ + upstreamBaseUrl: initialEnvVars.ANTHROPIC_BASE_URL, + verbose, + warnOnSanitize: true, + allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false, + }); + toolSanitizationPort = await toolSanitizationProxy.start(); + log(`Tool sanitization proxy active on port ${toolSanitizationPort}`); + } catch (error) { + const err = error as Error; + toolSanitizationProxy = null; + toolSanitizationPort = null; + if (verbose) { + console.error(warn(`Tool sanitization proxy disabled: ${err.message}`)); + } + } + } + + // ── Step 2: Codex reasoning proxy ────────────────────────────────────────── + let codexReasoningProxy: CodexReasoningProxy | null = null; + let codexReasoningPort: number | null = null; + + // Composite variants require root model-routed endpoints, never provider-pinned codex endpoints. + if (provider === 'codex' && !cfg.isComposite) { + const postSanitizationBaseUrl = toolSanitizationPort + ? `http://127.0.0.1:${toolSanitizationPort}` + : initialEnvVars.ANTHROPIC_BASE_URL; + + if (!postSanitizationBaseUrl) { + log('ANTHROPIC_BASE_URL not set for Codex, reasoning proxy disabled'); + } else { + try { + const traceEnabled = + process.env.CCS_CODEX_REASONING_TRACE === '1' || + process.env.CCS_CODEX_REASONING_TRACE === 'true'; + const stripPathPrefix = useRemoteProxy ? '/api/provider/codex' : undefined; + const codexThinkingOff = shouldDisableCodexReasoning(thinkingCfg, thinkingOverride); + codexReasoningProxy = new CodexReasoningProxyImpl({ + upstreamBaseUrl: postSanitizationBaseUrl, + verbose, + defaultEffort: 'medium', + disableEffort: codexThinkingOff, + traceFilePath: traceEnabled ? path.join(getCcsDir(), 'codex-reasoning-proxy.log') : '', + allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false, + modelMap: { + defaultModel: initialEnvVars.ANTHROPIC_MODEL, + opusModel: initialEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL, + sonnetModel: initialEnvVars.ANTHROPIC_DEFAULT_SONNET_MODEL, + haikuModel: initialEnvVars.ANTHROPIC_DEFAULT_HAIKU_MODEL, + }, + stripPathPrefix, + }); + codexReasoningPort = await codexReasoningProxy.start(); + log( + `Codex reasoning proxy active: http://127.0.0.1:${codexReasoningPort}/api/provider/codex` + ); + } catch (error) { + const err = error as Error; + codexReasoningProxy = null; + codexReasoningPort = null; + if (verbose) { + console.error(warn(`Codex reasoning proxy disabled: ${err.message}`)); + } + } + } + } + + return { + toolSanitizationProxy, + toolSanitizationPort, + codexReasoningProxy, + codexReasoningPort, + }; +} diff --git a/src/cliproxy/executor/proxy-resolver.ts b/src/cliproxy/executor/proxy-resolver.ts new file mode 100644 index 00000000..eae39abb --- /dev/null +++ b/src/cliproxy/executor/proxy-resolver.ts @@ -0,0 +1,196 @@ +/** + * Proxy Resolver — Concern D + * + * Handles proxy configuration resolution, remote proxy reachability check, + * local-backend selection, and CLIProxy binary acquisition. + * + * Extracted from executor/index.ts to isolate the proxy-resolution concern. + * All log messages, error messages, and exit semantics are byte-identical to + * the original implementation. + */ + +import { ProgressIndicator } from '../../utils/progress-indicator'; +import { ok, fail, info, warn } from '../../utils/ui'; +import { + ensureCLIProxyBinary, + getConfiguredBackend, + getPlusBackendUnavailableMessage, +} from '../binary-manager'; +import { checkRemoteProxy } from '../services/remote-proxy-client'; +import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS, ExecutorConfig } from '../types'; +import { resolveProxyConfig } from '../proxy/proxy-config-resolver'; +import { CLIPROXY_DEFAULT_PORT, validatePort } from '../config/config-generator'; +import type { ResolvedProxyConfig } from '../types'; +import type { UnifiedConfig } from '../../config/schemas/unified-config'; +import { isNetworkError, handleNetworkError } from './retry-handler'; + +/** Result returned from resolveExecutorProxy */ +export interface ResolvedProxy { + /** Resolved proxy config after merging CLI > ENV > config.yaml > defaults */ + proxyConfig: ResolvedProxyConfig; + /** Whether to use the remote proxy (vs spawning a local one) */ + useRemoteProxy: boolean; + /** Which local backend binary to use ('original' | 'plus') */ + localBackend: CLIProxyBackend; + /** Absolute path to CLIProxy binary; undefined when useRemoteProxy=true */ + binaryPath: string | undefined; + /** Args after proxy-related flags are stripped out */ + argsWithoutProxy: string[]; + /** Mutated executor config (port resolved and validated) */ + cfg: ExecutorConfig; +} + +/** Dependencies injected by the orchestrator */ +export interface ResolveExecutorProxyContext { + unifiedConfig: UnifiedConfig; + allProviders: CLIProxyProvider[]; + verbose: boolean; + cfg: ExecutorConfig; + log: (msg: string) => void; +} + +/** + * Resolves proxy configuration, checks remote reachability, selects the local + * backend, and ensures the CLIProxy binary is present when running locally. + * + * Mutates `context.cfg.port` in-place (same as original orchestrator behaviour). + */ +export async function resolveExecutorProxy( + args: string[], + context: ResolveExecutorProxyContext +): Promise { + const { unifiedConfig, allProviders, verbose: _verbose, cfg, log } = context; + + // Resolve proxy config from CLI flags > ENV > config.yaml > defaults + const cliproxyServerConfig = unifiedConfig.cliproxy_server; + const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args, { + remote: cliproxyServerConfig?.remote + ? { + enabled: cliproxyServerConfig.remote.enabled, + host: cliproxyServerConfig.remote.host, + port: cliproxyServerConfig.remote.port, + protocol: cliproxyServerConfig.remote.protocol, + auth_token: cliproxyServerConfig.remote.auth_token, + management_key: cliproxyServerConfig.remote.management_key, + timeout: cliproxyServerConfig.remote.timeout, + } + : undefined, + local: cliproxyServerConfig?.local + ? { + port: cliproxyServerConfig.local.port, + auto_start: cliproxyServerConfig.local.auto_start, + } + : undefined, + }); + + // Port resolution and validation (mutates cfg in-place) + if (cfg.port && cfg.port !== CLIPROXY_DEFAULT_PORT) { + if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) { + cfg.port = proxyConfig.port; + } + } else if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) { + cfg.port = proxyConfig.port; + } + cfg.port = validatePort(cfg.port); + + log(`Proxy mode: ${proxyConfig.mode}`); + if (proxyConfig.mode === 'remote') { + log(`Remote host: ${proxyConfig.host}:${proxyConfig.port} (${proxyConfig.protocol})`); + } + + // Check remote proxy reachability + let useRemoteProxy = false; + let localBackend: CLIProxyBackend = 'original'; + + if (proxyConfig.mode === 'remote' && proxyConfig.host) { + const status = await checkRemoteProxy({ + host: proxyConfig.host, + port: proxyConfig.port, + protocol: proxyConfig.protocol, + authToken: proxyConfig.authToken, + timeout: proxyConfig.timeout ?? 2000, + allowSelfSigned: proxyConfig.allowSelfSigned ?? false, + }); + + if (status.reachable) { + useRemoteProxy = true; + console.log( + ok( + `Connected to remote proxy at ${proxyConfig.host}:${proxyConfig.port} (${status.latencyMs}ms)` + ) + ); + } else { + console.error(warn(`Remote proxy unreachable: ${status.error}`)); + + if (proxyConfig.remoteOnly) { + throw new Error('Remote proxy unreachable and --remote-only specified'); + } + + if (proxyConfig.fallbackEnabled) { + if (proxyConfig.autoStartLocal) { + console.log(info('Falling back to local proxy...')); + } else { + if (process.stdin.isTTY) { + const readline = await import('readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => { + rl.question('Start local proxy instead? [Y/n] ', resolve); + }); + rl.close(); + if (answer.toLowerCase() === 'n') { + throw new Error('Remote proxy unreachable and user declined fallback'); + } + } + console.log(info('Starting local proxy...')); + } + } else { + throw new Error('Remote proxy unreachable and fallback disabled'); + } + } + } + + // Local backend selection (only when not using remote proxy) + if (!useRemoteProxy) { + localBackend = getConfiguredBackend({ notifyOnPlus: true }); + + for (const p of allProviders) { + if (localBackend === 'original' && PLUS_ONLY_PROVIDERS.includes(p as CLIProxyProvider)) { + console.error(''); + console.error(fail(getPlusBackendUnavailableMessage(p))); + console.error(''); + throw new Error(`Provider ${p} requires local CLIProxy Plus backend`); + } + } + } + + // Binary acquisition — skipped when using remote proxy + let binaryPath: string | undefined; + + if (!useRemoteProxy) { + const spinner = new ProgressIndicator('Preparing CLIProxy'); + spinner.start(); + + try { + binaryPath = await ensureCLIProxyBinary(_verbose, { skipAutoUpdate: true }); + spinner.succeed('CLIProxy binary ready'); + } catch (error) { + spinner.fail('Failed to prepare CLIProxy'); + const err = error as Error; + + if (isNetworkError(err)) { + handleNetworkError(err); + } + + throw error; + } + } + + return { + proxyConfig, + useRemoteProxy, + localBackend, + binaryPath, + argsWithoutProxy, + cfg, + }; +} diff --git a/src/cliproxy/proxy/proxy-target-resolver.ts b/src/cliproxy/proxy/proxy-target-resolver.ts index 0f6e0e01..1f7875d8 100644 --- a/src/cliproxy/proxy/proxy-target-resolver.ts +++ b/src/cliproxy/proxy/proxy-target-resolver.ts @@ -5,7 +5,6 @@ * based on unified config. Used by stats-fetcher, auth-routes, and UI. */ -import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import type { CliproxyServerConfig } from '../../config/unified-config-types'; import { CLIPROXY_DEFAULT_PORT, @@ -15,6 +14,7 @@ import { } from '../config/port-manager'; import { getProxyEnvVars } from './proxy-config-resolver'; import { getEffectiveManagementSecret } from '../auth/auth-token-manager'; +import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade'; /** Resolved proxy target for making requests */ export interface ProxyTarget { diff --git a/src/cliproxy/proxy/tool-sanitization-proxy.ts b/src/cliproxy/proxy/tool-sanitization-proxy.ts index bb3d99dd..3fed56e4 100644 --- a/src/cliproxy/proxy/tool-sanitization-proxy.ts +++ b/src/cliproxy/proxy/tool-sanitization-proxy.ts @@ -25,8 +25,9 @@ import { stripCodexEffortSuffix, } from '../ai-providers/model-id-normalizer'; import { getModelMaxLevel } from '../model-catalog'; -import { getCcsDir } from '../../utils/config-manager'; + import { createLogger } from '../../services/logging'; +import { getCcsDir } from '../../config/config-loader-facade'; export interface ToolSanitizationProxyConfig { /** Upstream CLIProxy URL */ diff --git a/src/cliproxy/quota/quota-manager.ts b/src/cliproxy/quota/quota-manager.ts index f715ee47..4493aeb6 100644 --- a/src/cliproxy/quota/quota-manager.ts +++ b/src/cliproxy/quota/quota-manager.ts @@ -32,8 +32,9 @@ import { touchAccount, type AccountInfo, } from '../accounts/account-manager'; -import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; + import type { RuntimeMonitorConfig } from '../../config/unified-config-types'; +import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade'; export type ManagedQuotaProvider = 'agy' | 'claude' | 'codex' | 'gemini' | 'ghcp'; type ManagedQuotaResult = diff --git a/src/cliproxy/routing/routing-strategy.ts b/src/cliproxy/routing/routing-strategy.ts index 65c44d86..d6ed1ec2 100644 --- a/src/cliproxy/routing/routing-strategy.ts +++ b/src/cliproxy/routing/routing-strategy.ts @@ -1,4 +1,3 @@ -import { mutateUnifiedConfig, loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { regenerateConfig } from '../config/generator'; import { getAuthDir, getConfigPathForPort } from '../config/path-resolver'; import { @@ -7,6 +6,7 @@ import { getRoutingErrorMessage, } from './routing-strategy-http'; import type { CliproxyRoutingStrategy } from '../types'; +import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade'; export const DEFAULT_CLIPROXY_ROUTING_STRATEGY: CliproxyRoutingStrategy = 'round-robin'; export const DEFAULT_CLIPROXY_SESSION_AFFINITY_ENABLED = false; @@ -223,7 +223,7 @@ export async function applyCliproxyRoutingStrategy( }; } - mutateUnifiedConfig((config) => { + mutateConfig((config) => { if (config.cliproxy) { config.cliproxy.routing = { ...config.cliproxy.routing, strategy }; } @@ -278,7 +278,7 @@ export async function applyCliproxySessionAffinitySettings( current.ttl ?? DEFAULT_CLIPROXY_SESSION_AFFINITY_TTL; - mutateUnifiedConfig((config) => { + mutateConfig((config) => { if (config.cliproxy) { config.cliproxy.routing = { ...config.cliproxy.routing, diff --git a/src/cliproxy/services/binary-service.ts b/src/cliproxy/services/binary-service.ts index 530571ce..7c91fd09 100644 --- a/src/cliproxy/services/binary-service.ts +++ b/src/cliproxy/services/binary-service.ts @@ -22,7 +22,7 @@ import { } from '../binary-manager'; import { BACKEND_CONFIG, DEFAULT_BACKEND } from '../binary/platform-detector'; import { CLIProxyBackend } from '../types'; -import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade'; /** Binary status result */ export interface BinaryStatusResult { diff --git a/src/cliproxy/services/catalog-cache.ts b/src/cliproxy/services/catalog-cache.ts index 2a456459..925d7370 100644 --- a/src/cliproxy/services/catalog-cache.ts +++ b/src/cliproxy/services/catalog-cache.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import { getCcsDir } from '../../utils/config-manager'; + import type { CLIProxyProvider } from '../types'; import type { ModelEntry, ProviderCatalog, ThinkingSupport } from '../model-catalog'; import { MODEL_CATALOG } from '../model-catalog'; @@ -15,6 +15,7 @@ import { buildProxyUrl, getProxyTarget, } from '../proxy/proxy-target-resolver'; +import { getCcsDir } from '../../config/config-loader-facade'; const CACHE_FILE_NAME = 'model-catalog-cache.json'; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours diff --git a/src/cliproxy/services/variant-config-adapter.ts b/src/cliproxy/services/variant-config-adapter.ts index 2425cec1..a24d0df9 100644 --- a/src/cliproxy/services/variant-config-adapter.ts +++ b/src/cliproxy/services/variant-config-adapter.ts @@ -1,5 +1,5 @@ import * as fs from 'fs'; -import { getConfigPath, loadConfigSafe } from '../../utils/config-manager'; +import { getConfigPath } from '../../utils/config-manager'; import { CLIProxyProvider } from '../types'; import type { TargetType } from '../../targets/target-adapter'; import { @@ -8,12 +8,14 @@ import { CompositeTierConfig, CLIPROXY_SUPPORTED_PROVIDERS, } from '../../config/unified-config-types'; -import { - loadOrCreateUnifiedConfig, - mutateUnifiedConfig, - isUnifiedMode, -} from '../../config/unified-config-loader'; + import { CLIPROXY_DEFAULT_PORT } from '../config/config-generator'; +import { + isUnifiedMode, + loadConfigSafe, + loadOrCreateUnifiedConfig, + mutateConfig, +} from '../../config/config-loader-facade'; export const VARIANT_PORT_BASE = CLIPROXY_DEFAULT_PORT + 1; export const VARIANT_PORT_MAX_OFFSET = 100; @@ -171,7 +173,7 @@ export function listVariantsFromConfig(): Record { } export function saveCompositeVariantUnified(name: string, config: CompositeVariantConfig): void { - mutateUnifiedConfig((unifiedConfig) => { + mutateConfig((unifiedConfig) => { if (!unifiedConfig.cliproxy) { unifiedConfig.cliproxy = { oauth_accounts: {}, @@ -195,7 +197,7 @@ export function saveVariantUnified( port?: number, target: TargetType = 'claude' ): void { - mutateUnifiedConfig((config) => { + mutateConfig((config) => { if (!config.cliproxy) { config.cliproxy = { oauth_accounts: {}, @@ -266,7 +268,7 @@ export function saveVariantLegacy( export function removeVariantFromUnifiedConfig(name: string): VariantConfig | null { let removedVariant: CLIProxyVariantConfig | CompositeVariantConfig | null = null; - mutateUnifiedConfig((config) => { + mutateConfig((config) => { if (!config.cliproxy?.variants || !(name in config.cliproxy.variants)) { return; } diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index 629d9a09..39f5ed67 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -12,11 +12,11 @@ import { CLIProxyProvider, PLUS_ONLY_PROVIDERS } from '../types'; import { CompositeTierConfig, CompositeVariantConfig } from '../../config/unified-config-types'; import type { TargetType } from '../../targets/target-adapter'; import { isReservedName, isWindowsReservedName } from '../../config/reserved-names'; -import { isUnifiedMode } from '../../config/unified-config-loader'; + import { deleteConfigForPort } from '../config/config-generator'; import { hasActiveSessions, deleteSessionLockForPort } from '../session-tracker'; import { warn } from '../../utils/ui'; -import { getCcsDir } from '../../utils/config-manager'; + import { validateCompositeTiers } from '../config/composite-validator'; import { canonicalizeModelIdForProvider, @@ -43,6 +43,7 @@ import { getNextAvailablePort, } from './variant-config-adapter'; import { getConfiguredBackend, getPlusBackendUnavailableMessage } from '../binary-manager'; +import { getCcsDir, isUnifiedMode } from '../../config/config-loader-facade'; // Re-export VariantConfig from adapter export type { VariantConfig } from './variant-config-adapter'; diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index 88b9c6a0..2d27c4b4 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -9,7 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { CLIProxyProfileName } from '../../auth/profile-detector'; -import { getCcsDir } from '../../utils/config-manager'; + import { expandPath } from '../../utils/helpers'; import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config/config-generator'; import { CLIProxyProvider } from '../types'; @@ -24,6 +24,7 @@ import { prepareImageAnalysisFallbackHook } from '../../utils/hooks'; import { getEffectiveApiKey } from '../auth/auth-token-manager'; import { warn } from '../../utils/ui'; import { normalizeModelIdForProvider } from '../ai-providers/model-id-normalizer'; +import { getCcsDir } from '../../config/config-loader-facade'; /** Environment settings structure */ interface SettingsEnv { diff --git a/src/cliproxy/sync/auto-sync-watcher.ts b/src/cliproxy/sync/auto-sync-watcher.ts index bd264861..d5427342 100644 --- a/src/cliproxy/sync/auto-sync-watcher.ts +++ b/src/cliproxy/sync/auto-sync-watcher.ts @@ -7,9 +7,9 @@ import * as chokidar from 'chokidar'; import * as path from 'path'; -import { getCcsDir } from '../../utils/config-manager'; -import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; + import { syncToLocalConfig } from './local-config-sync'; +import { getCcsDir, loadOrCreateUnifiedConfig } from '../../config/config-loader-facade'; /** Debounce delay in milliseconds */ const DEBOUNCE_MS = 3000; diff --git a/src/cliproxy/sync/profile-mapper.ts b/src/cliproxy/sync/profile-mapper.ts index a092c982..d8b538aa 100644 --- a/src/cliproxy/sync/profile-mapper.ts +++ b/src/cliproxy/sync/profile-mapper.ts @@ -6,10 +6,11 @@ import * as fs from 'fs'; import * as path from 'path'; -import { getCcsDir } from '../../utils/config-manager'; + import { expandPath } from '../../utils/helpers'; import { listApiProfiles, isApiProfileConfigured } from '../../api/services/profile-reader'; import type { ClaudeKey } from '../management/management-api-types'; +import { getCcsDir } from '../../config/config-loader-facade'; /** * Profile info with settings for sync. diff --git a/src/commands/browser-command.ts b/src/commands/browser-command.ts index 3633f67e..8cf94e89 100644 --- a/src/commands/browser-command.ts +++ b/src/commands/browser-command.ts @@ -1,9 +1,10 @@ import * as browserUtils from '../utils/browser'; -import { getBrowserConfig, mutateUnifiedConfig } from '../config/unified-config-loader'; + import type { BrowserToolPolicy } from '../config/unified-config-types'; import { getCcsPathDisplay } from '../utils/config-manager'; import { getNodePlatformKey } from '../utils/browser/platform'; import { color, dim, header, initUI, subheader } from '../utils/ui'; +import { getBrowserConfig, mutateConfig } from '../config/config-loader-facade'; type HelpWriter = (line: string) => void; type BrowserLane = 'claude' | 'codex' | 'all'; @@ -216,7 +217,7 @@ function updateBrowserPolicies(updates: { claude?: BrowserToolPolicy; codex?: BrowserToolPolicy; }): void { - mutateUnifiedConfig((config) => { + mutateConfig((config) => { const current = getBrowserConfig(); config.browser = { claude: { @@ -235,7 +236,7 @@ function updateBrowserPolicies(updates: { function updateBrowserEnabled(subcommand: 'enable' | 'disable', lane: BrowserLane): void { const nextEnabled = subcommand === 'enable'; - mutateUnifiedConfig((config) => { + mutateConfig((config) => { const current = getBrowserConfig(); config.browser = { claude: { diff --git a/src/commands/cliproxy/resolve-lifecycle-port.ts b/src/commands/cliproxy/resolve-lifecycle-port.ts index e250d5e6..04294739 100644 --- a/src/commands/cliproxy/resolve-lifecycle-port.ts +++ b/src/commands/cliproxy/resolve-lifecycle-port.ts @@ -1,6 +1,7 @@ import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager'; -import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; + import type { UnifiedConfig } from '../../config/unified-config-types'; +import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade'; type LifecyclePortConfig = Pick; diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index 1c8b7833..aedf6001 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -17,7 +17,7 @@ import type { CliproxyProviderRoutingHints } from '../../shared/cliproxy-model-r import { CLIProxyProvider, CLIProxyBackend } from '../../cliproxy/types'; import type { TargetType } from '../../targets/target-adapter'; import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata'; -import { isUnifiedMode } from '../../config/unified-config-loader'; + import { initUI, header, color, ok, fail, warn, info, infoBox, dim } from '../../utils/ui'; import { InteractivePrompt } from '../../utils/prompt'; import { @@ -32,6 +32,7 @@ import { import { DEFAULT_BACKEND } from '../../cliproxy/binary/platform-detector'; import { CompositeTierConfig } from '../../config/unified-config-types'; import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity'; +import { isUnifiedMode } from '../../config/config-loader-facade'; interface CliproxyProfileArgs { name?: string; diff --git a/src/commands/config-auth/disable-command.ts b/src/commands/config-auth/disable-command.ts index d6efb0c2..e6d5b2fa 100644 --- a/src/commands/config-auth/disable-command.ts +++ b/src/commands/config-auth/disable-command.ts @@ -5,8 +5,9 @@ */ import { InteractivePrompt } from '../../utils/prompt'; -import { getDashboardAuthConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; + import { initUI, header, ok, info, warn, dim } from '../../utils/ui'; +import { getDashboardAuthConfig, mutateConfig } from '../../config/config-loader-facade'; /** * Handle disable command - disable auth with confirmation @@ -53,7 +54,7 @@ export async function handleDisable(): Promise { } // Disable auth - mutateUnifiedConfig((fullConfig) => { + mutateConfig((fullConfig) => { fullConfig.dashboard_auth = { enabled: false, username: fullConfig.dashboard_auth?.username ?? '', diff --git a/src/commands/config-auth/setup-command.ts b/src/commands/config-auth/setup-command.ts index 75df5d39..c7e6f448 100644 --- a/src/commands/config-auth/setup-command.ts +++ b/src/commands/config-auth/setup-command.ts @@ -8,9 +8,10 @@ import bcrypt from 'bcrypt'; import { InteractivePrompt } from '../../utils/prompt'; -import { mutateUnifiedConfig } from '../../config/unified-config-loader'; + import { initUI, header, subheader, ok, fail, info, warn, dim } from '../../utils/ui'; import type { AuthSetupResult } from './types'; +import { mutateConfig } from '../../config/config-loader-facade'; const BCRYPT_ROUNDS = 10; const MIN_PASSWORD_LENGTH = 8; @@ -99,7 +100,7 @@ export async function handleSetup(): Promise { // Hash password const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS); - const config = mutateUnifiedConfig((currentConfig) => { + const config = mutateConfig((currentConfig) => { currentConfig.dashboard_auth = { enabled: true, username, diff --git a/src/commands/config-auth/show-command.ts b/src/commands/config-auth/show-command.ts index 8bbdc1bd..54cf117a 100644 --- a/src/commands/config-auth/show-command.ts +++ b/src/commands/config-auth/show-command.ts @@ -4,9 +4,9 @@ * Display current dashboard authentication status. */ -import { getDashboardAuthConfig } from '../../config/unified-config-loader'; import { initUI, header, subheader, ok, info, warn, dim, color } from '../../utils/ui'; import type { AuthStatusInfo } from './types'; +import { getDashboardAuthConfig } from '../../config/config-loader-facade'; /** * Get auth status info with ENV override detection diff --git a/src/commands/config-channels-command.ts b/src/commands/config-channels-command.ts index b06c654d..c31cc9ab 100644 --- a/src/commands/config-channels-command.ts +++ b/src/commands/config-channels-command.ts @@ -1,9 +1,5 @@ import { initUI, header, ok, info, warn, fail, subheader, color, dim } from '../utils/ui'; -import { - getOfficialChannelsConfig, - loadOrCreateUnifiedConfig, - updateUnifiedConfig, -} from '../config/unified-config-loader'; + import type { OfficialChannelId } from '../config/unified-config-types'; import { DEFAULT_OFFICIAL_CHANNELS_CONFIG } from '../config/unified-config-types'; import { @@ -42,6 +38,11 @@ import { isOfficialChannelSelectionValid, } from '../channels/official-channels-runtime'; import { extractOption, hasAnyFlag } from './arg-extractor'; +import { + getOfficialChannelsConfig, + loadOrCreateUnifiedConfig, + updateConfig, +} from '../config/config-loader-facade'; interface ChannelsCommandOptions { enable: boolean; @@ -440,7 +441,7 @@ export async function handleConfigChannelsCommand(args: string[]): Promise try { if (hasConfigMutation) { - updateUnifiedConfig({ channels: nextConfig }); + updateConfig({ channels: nextConfig }); } if (options.setToken) { diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index bf47b324..12360b4a 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -12,7 +12,7 @@ import { startServer } from '../web-server'; import { setupGracefulShutdown } from '../web-server/shutdown'; import { ensureCliproxyService } from '../cliproxy/service-manager'; import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/config-generator'; -import { getDashboardAuthConfig } from '../config/unified-config-loader'; + import { initUI, header, ok, info, warn, fail } from '../utils/ui'; import { resolveNamedCommand, type NamedCommandRoute } from './named-command-router'; import { @@ -23,6 +23,7 @@ import { } from './config-dashboard-host'; import { parseConfigCommandArgs, showConfigCommandHelp } from './config-command-options'; import { createLogger } from '../services/logging'; +import { getDashboardAuthConfig } from '../config/config-loader-facade'; const logger = createLogger('command:config'); diff --git a/src/commands/config-image-analysis-command.ts b/src/commands/config-image-analysis-command.ts index b9ed3f6a..ce796ae6 100644 --- a/src/commands/config-image-analysis-command.ts +++ b/src/commands/config-image-analysis-command.ts @@ -6,11 +6,7 @@ */ import { initUI, header, ok, info, warn, fail, subheader, color, dim } from '../utils/ui'; -import { - getImageAnalysisConfig, - updateUnifiedConfig, - loadOrCreateUnifiedConfig, -} from '../config/unified-config-loader'; + import { DEFAULT_IMAGE_ANALYSIS_CONFIG } from '../config/unified-config-types'; import { CLIPROXY_PROVIDER_IDS, @@ -19,6 +15,11 @@ import { } from '../cliproxy/provider-capabilities'; import { extractOption, hasAnyFlag } from './arg-extractor'; import { normalizeImageAnalysisBackendId } from '../utils/hooks'; +import { + getImageAnalysisConfig, + loadOrCreateUnifiedConfig, + updateConfig, +} from '../config/config-loader-facade'; interface ImageAnalysisCommandOptions { enable?: boolean; @@ -346,7 +347,7 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise< } if (hasChanges) { - updateUnifiedConfig({ image_analysis: imageConfig }); + updateConfig({ image_analysis: imageConfig }); console.log(ok('Configuration updated')); console.log(''); } diff --git a/src/commands/config-thinking-command.ts b/src/commands/config-thinking-command.ts index 646d4571..a33dd42e 100644 --- a/src/commands/config-thinking-command.ts +++ b/src/commands/config-thinking-command.ts @@ -6,11 +6,7 @@ */ import { initUI, header, ok, info, warn, fail, subheader, color, dim } from '../utils/ui'; -import { - getThinkingConfig, - updateUnifiedConfig, - loadOrCreateUnifiedConfig, -} from '../config/unified-config-loader'; + import { DEFAULT_THINKING_TIER_DEFAULTS } from '../config/unified-config-types'; import { VALID_THINKING_LEVELS } from '../cliproxy/thinking-validator'; import { @@ -18,6 +14,11 @@ import { parseThinkingCommandArgs, parseThinkingOverrideInput, } from './config-thinking-parser'; +import { + getThinkingConfig, + loadOrCreateUnifiedConfig, + updateConfig, +} from '../config/config-loader-facade'; const VALID_THINKING_MODES = ['auto', 'off', 'manual'] as const; @@ -293,7 +294,7 @@ export async function handleConfigThinkingCommand(args: string[]): Promise } if (hasChanges) { - updateUnifiedConfig({ thinking: thinkingConfig }); + updateConfig({ thinking: thinkingConfig }); console.log(ok('Configuration updated')); console.log(''); } diff --git a/src/commands/copilot-command.ts b/src/commands/copilot-command.ts index f25ebd52..4483f045 100644 --- a/src/commands/copilot-command.ts +++ b/src/commands/copilot-command.ts @@ -15,10 +15,11 @@ import { normalizeCopilotConfigWithWarnings, } from '../copilot'; import type { CopilotModel } from '../copilot'; -import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../config/unified-config-loader'; + import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types'; import { ok, fail, info, color, warn } from '../utils/ui'; import { normalizeCopilotSubcommand } from '../copilot/constants'; +import { loadOrCreateUnifiedConfig, mutateConfig } from '../config/config-loader-facade'; /** * Handle copilot subcommand. @@ -361,7 +362,7 @@ async function handleStop(): Promise { * Handle enable subcommand. */ async function handleEnable(): Promise { - mutateUnifiedConfig((config) => { + mutateConfig((config) => { if (!config.copilot) { config.copilot = { ...DEFAULT_COPILOT_CONFIG }; } @@ -383,7 +384,7 @@ async function handleEnable(): Promise { * Handle disable subcommand. */ async function handleDisable(): Promise { - mutateUnifiedConfig((config) => { + mutateConfig((config) => { if (config.copilot) { config.copilot.enabled = false; } diff --git a/src/commands/cursor-command.ts b/src/commands/cursor-command.ts index 80c2f260..7510f4a7 100644 --- a/src/commands/cursor-command.ts +++ b/src/commands/cursor-command.ts @@ -16,7 +16,7 @@ import { getDefaultModel, probeCursorRuntime, } from '../cursor'; -import { getCursorConfig, mutateUnifiedConfig } from '../config/unified-config-loader'; + import { DEFAULT_CURSOR_CONFIG } from '../config/unified-config-types'; import { renderCursorHelp, @@ -25,6 +25,7 @@ import { renderCursorStatus, } from './cursor-command-display'; import { ok, fail, info } from '../utils/ui'; +import { getCursorConfig, mutateConfig } from '../config/config-loader-facade'; const LEGACY_CURSOR_COMMAND = 'ccs legacy cursor'; const CLIPROXY_CURSOR_COMMAND = 'ccs cursor'; @@ -286,7 +287,7 @@ async function handleStop(): Promise { */ async function handleEnable(): Promise { printLegacyCursorDeprecationNotice(); - mutateUnifiedConfig((config) => { + mutateConfig((config) => { if (!config.cursor) { config.cursor = { ...DEFAULT_CURSOR_CONFIG }; } @@ -310,7 +311,7 @@ async function handleEnable(): Promise { */ async function handleDisable(): Promise { printLegacyCursorDeprecationNotice(); - mutateUnifiedConfig((config) => { + mutateConfig((config) => { if (config.cursor) { config.cursor.enabled = false; } diff --git a/src/commands/env-command.ts b/src/commands/env-command.ts index bfb35103..ca3129b0 100644 --- a/src/commands/env-command.ts +++ b/src/commands/env-command.ts @@ -6,12 +6,13 @@ */ import { initUI, header, dim, color, subheader, fail, warn } from '../utils/ui'; -import { getCcsDir } from '../utils/config-manager'; + import { CLAUDE_EXTENSION_HOSTS, type ClaudeExtensionHost } from '../shared/claude-extension-hosts'; import { renderClaudeExtensionSettingsJson, resolveClaudeExtensionSetup, } from '../shared/claude-extension-setup'; +import { getCcsDir } from '../config/config-loader-facade'; type ShellType = 'bash' | 'fish' | 'powershell'; type OutputFormat = 'openai' | 'anthropic' | 'raw' | 'claude-extension'; diff --git a/src/commands/migrate-command.ts b/src/commands/migrate-command.ts index 3d643689..c7865222 100644 --- a/src/commands/migrate-command.ts +++ b/src/commands/migrate-command.ts @@ -16,8 +16,9 @@ import { needsMigration, getBackupDirectories, } from '../config/migration-manager'; -import { hasUnifiedConfig } from '../config/unified-config-loader'; + import { initUI, ok, fail, info, warn, infoBox, dim } from '../utils/ui'; +import { hasUnifiedConfig } from '../config/config-loader-facade'; export async function handleMigrateCommand(args: string[]): Promise { await initUI(); diff --git a/src/commands/proxy-command.ts b/src/commands/proxy-command.ts index 258e5526..53a11b11 100644 --- a/src/commands/proxy-command.ts +++ b/src/commands/proxy-command.ts @@ -1,5 +1,5 @@ import { detectShell, formatExportLine } from './env-command'; -import { getSettingsPath, loadSettings } from '../utils/config-manager'; +import { getSettingsPath } from '../utils/config-manager'; import { expandPath } from '../utils/helpers'; import { fail, info, ok } from '../utils/ui'; import { @@ -10,6 +10,7 @@ import { startOpenAICompatProxy, stopOpenAICompatProxy, } from '../proxy'; +import { loadSettings } from '../config/config-loader-facade'; function parseOptionValue(args: string[], key: string): string | undefined { const exactIndex = args.findIndex((arg) => arg === key); diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts index ed1f884d..d5cccc2f 100644 --- a/src/commands/setup-command.ts +++ b/src/commands/setup-command.ts @@ -16,15 +16,17 @@ import * as readline from 'readline'; import * as fs from 'fs'; import * as path from 'path'; import { initUI, header, ok, info, warn } from '../utils/ui'; + +import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types'; + +import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; import { + getCcsDir, + hasUnifiedConfig, loadOrCreateUnifiedConfig, loadUnifiedConfig, - mutateUnifiedConfig, - hasUnifiedConfig, -} from '../config/unified-config-loader'; -import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types'; -import { getCcsDir } from '../utils/config-manager'; -import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; + mutateConfig, +} from '../config/config-loader-facade'; /** Custom error for user cancellation (Ctrl+C) */ class UserCancelledError extends Error { @@ -377,7 +379,7 @@ async function runSetupWizard(force: boolean = false): Promise { console.log(' After creating, edit the settings file to add your API key.'); } - mutateUnifiedConfig((currentConfig) => { + mutateConfig((currentConfig) => { currentConfig.setup_completed = true; currentConfig.cliproxy_server = config.cliproxy_server; }); diff --git a/src/commands/version-command.ts b/src/commands/version-command.ts index 3e8e350c..e355fbc8 100644 --- a/src/commands/version-command.ts +++ b/src/commands/version-command.ts @@ -7,9 +7,10 @@ import * as path from 'path'; import * as fs from 'fs'; import { initUI, header, subheader, color, warn } from '../utils/ui'; -import { getActiveConfigPath, getCcsDir } from '../utils/config-manager'; +import { getActiveConfigPath } from '../utils/config-manager'; import { getVersion } from '../utils/version'; import { getProfileLookupCandidates } from '../utils/profile-compat'; +import { getCcsDir } from '../config/config-loader-facade'; /** * Handle version command diff --git a/src/config/loader/config-getters.ts b/src/config/loader/config-getters.ts new file mode 100644 index 00000000..28d87436 --- /dev/null +++ b/src/config/loader/config-getters.ts @@ -0,0 +1,339 @@ +/** + * config-getters.ts + * + * Typed sub-config accessor functions extracted from unified-config-loader.ts + * (Phase 5 split — issue #1164). + * + * All functions read the loaded config via loadOrCreateUnifiedConfig and + * return typed sub-configs with defaults applied. + * + * No I/O beyond what loadOrCreateUnifiedConfig performs internally. + */ + +import { + DEFAULT_CLIPROXY_SAFETY_CONFIG, + DEFAULT_CURSOR_CONFIG, + DEFAULT_GLOBAL_ENV, + DEFAULT_IMAGE_ANALYSIS_CONFIG, + DEFAULT_LOGGING_CONFIG, + DEFAULT_OFFICIAL_CHANNELS_CONFIG, + DEFAULT_THINKING_CONFIG, +} from '../unified-config-types'; +import type { + BrowserConfig, + CLIProxySafetyConfig, + CursorConfig, + DashboardAuthConfig, + GlobalEnvConfig, + ImageAnalysisConfig, + LoggingConfig, + OfficialChannelsConfig, + ThinkingConfig, +} from '../unified-config-types'; +import { canonicalizeBrowserConfig } from './normalizers'; +import { canonicalizeImageAnalysisConfig } from '../../utils/hooks/image-analysis-backend-resolver'; +import { normalizeOfficialChannelIds } from '../../channels/official-channels-ids'; +import { normalizeSearxngBaseUrl } from '../../utils/websearch/types'; + +// --------------------------------------------------------------------------- +// Circular-import safety: loadOrCreateUnifiedConfig lives in +// unified-config-loader.ts which imports this file. We break the cycle by +// using a lazy require() inside getConfig() so the module is resolved at +// call time (after both modules have finished loading) rather than at import +// time. This also preserves spy/mock compatibility: test spies replace the +// function on the module namespace object, and require() returns that live +// namespace, so the spy is always picked up. +// --------------------------------------------------------------------------- + +function getConfig(): import('../unified-config-types').UnifiedConfig { + const loader = require('../unified-config-loader') as { + loadOrCreateUnifiedConfig: () => import('../unified-config-types').UnifiedConfig; + }; + return loader.loadOrCreateUnifiedConfig(); +} + +// --------------------------------------------------------------------------- +// GeminiWebSearchInfo interface +// --------------------------------------------------------------------------- + +/** + * Gemini CLI WebSearch configuration + */ +export interface GeminiWebSearchInfo { + enabled: boolean; + model: string; + timeout: number; +} + +// --------------------------------------------------------------------------- +// Accessor functions +// --------------------------------------------------------------------------- + +/** + * Get websearch configuration. + * Returns defaults if not configured. + * Supports deterministic providers and optional Gemini/OpenCode/Grok CLI fallbacks. + */ +export function getWebSearchConfig(): { + enabled: boolean; + providers?: { + exa?: { enabled?: boolean; max_results?: number }; + tavily?: { enabled?: boolean; max_results?: number }; + brave?: { enabled?: boolean; max_results?: number }; + searxng?: { enabled?: boolean; url?: string; max_results?: number }; + duckduckgo?: { enabled?: boolean; max_results?: number }; + gemini?: GeminiWebSearchInfo; + opencode?: { enabled?: boolean; model?: string; timeout?: number }; + grok?: { enabled?: boolean; timeout?: number }; + }; + // Legacy fields (deprecated) + gemini?: { enabled?: boolean; timeout?: number }; +} { + const config = getConfig(); + + // Build provider configs + const exaConfig = { + enabled: config.websearch?.providers?.exa?.enabled ?? false, + max_results: config.websearch?.providers?.exa?.max_results ?? 5, + }; + + const tavilyConfig = { + enabled: config.websearch?.providers?.tavily?.enabled ?? false, + max_results: config.websearch?.providers?.tavily?.max_results ?? 5, + }; + + const duckDuckGoConfig = { + enabled: config.websearch?.providers?.duckduckgo?.enabled ?? true, + max_results: config.websearch?.providers?.duckduckgo?.max_results ?? 5, + }; + + const braveConfig = { + enabled: config.websearch?.providers?.brave?.enabled ?? false, + max_results: config.websearch?.providers?.brave?.max_results ?? 5, + }; + + const searxngConfig = { + enabled: config.websearch?.providers?.searxng?.enabled ?? false, + url: normalizeSearxngBaseUrl(config.websearch?.providers?.searxng?.url) ?? '', + max_results: config.websearch?.providers?.searxng?.max_results ?? 5, + }; + + const geminiConfig: GeminiWebSearchInfo = { + enabled: + config.websearch?.providers?.gemini?.enabled ?? config.websearch?.gemini?.enabled ?? false, + model: config.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash', + timeout: + config.websearch?.providers?.gemini?.timeout ?? config.websearch?.gemini?.timeout ?? 55, + }; + + const opencodeConfig = { + enabled: config.websearch?.providers?.opencode?.enabled ?? false, + model: config.websearch?.providers?.opencode?.model ?? 'opencode/grok-code', + timeout: config.websearch?.providers?.opencode?.timeout ?? 90, + }; + + const grokConfig = { + enabled: config.websearch?.providers?.grok?.enabled ?? false, + timeout: config.websearch?.providers?.grok?.timeout ?? 55, + }; + + // Auto-enable master switch if ANY provider is enabled + const anyProviderEnabled = + exaConfig.enabled || + tavilyConfig.enabled || + braveConfig.enabled || + searxngConfig.enabled || + duckDuckGoConfig.enabled || + geminiConfig.enabled || + opencodeConfig.enabled || + grokConfig.enabled; + const enabled = anyProviderEnabled && (config.websearch?.enabled ?? true); + + return { + enabled, + providers: { + exa: exaConfig, + tavily: tavilyConfig, + brave: braveConfig, + searxng: searxngConfig, + duckduckgo: duckDuckGoConfig, + gemini: geminiConfig, + opencode: opencodeConfig, + grok: grokConfig, + }, + // Legacy field for backwards compatibility + gemini: config.websearch?.gemini, + }; +} + +/** + * Get global_env configuration. + * Returns defaults if not configured. + */ +export function getGlobalEnvConfig(): GlobalEnvConfig { + const config = getConfig(); + return { + enabled: config.global_env?.enabled ?? true, + env: config.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV }, + }; +} + +/** + * Get continuity inheritance mapping. + * Returns empty mapping when not configured. + */ +export function getContinuityInheritanceMap(): Record { + const config = getConfig(); + return config.continuity?.inherit_from_account ?? {}; +} + +/** + * Get cliproxy safety configuration. + * Returns defaults if not configured. + */ +export function getCliproxySafetyConfig(): CLIProxySafetyConfig { + const config = getConfig(); + return { + antigravity_ack_bypass: + config.cliproxy?.safety?.antigravity_ack_bypass ?? + DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass, + }; +} + +/** + * Get thinking configuration. + * Returns defaults if not configured. + */ +export function getThinkingConfig(): ThinkingConfig { + const config = getConfig(); + + // W2: Check for invalid thinking config (e.g., thinking: true instead of object) + if (config.thinking !== undefined && typeof config.thinking !== 'object') { + console.warn( + `[!] Invalid thinking config: expected object, got ${typeof config.thinking}. Using defaults.` + ); + console.warn(` Tip: Use 'thinking: { mode: auto }' instead of 'thinking: true'`); + return DEFAULT_THINKING_CONFIG; + } + + return { + mode: config.thinking?.mode ?? DEFAULT_THINKING_CONFIG.mode, + override: config.thinking?.override, + tier_defaults: { + opus: config.thinking?.tier_defaults?.opus ?? DEFAULT_THINKING_CONFIG.tier_defaults.opus, + sonnet: + config.thinking?.tier_defaults?.sonnet ?? DEFAULT_THINKING_CONFIG.tier_defaults.sonnet, + haiku: config.thinking?.tier_defaults?.haiku ?? DEFAULT_THINKING_CONFIG.tier_defaults.haiku, + }, + provider_overrides: config.thinking?.provider_overrides, + show_warnings: config.thinking?.show_warnings ?? DEFAULT_THINKING_CONFIG.show_warnings, + }; +} + +/** + * Get Official Channels configuration. + * Returns defaults if not configured. + */ +export function getOfficialChannelsConfig(): OfficialChannelsConfig { + const config = getConfig(); + + return { + selected: + config.channels?.selected && config.channels.selected.length > 0 + ? normalizeOfficialChannelIds(config.channels.selected) + : DEFAULT_OFFICIAL_CHANNELS_CONFIG.selected, + unattended: config.channels?.unattended ?? DEFAULT_OFFICIAL_CHANNELS_CONFIG.unattended, + }; +} + +/** + * Check if dashboard auth is enabled. + * Priority: ENV vars > config.yaml > defaults + */ +export function isDashboardAuthEnabled(): boolean { + const envEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; + + if (envEnabled !== undefined) { + return envEnabled === 'true' || envEnabled === '1'; + } + + const config = getConfig(); + return config.dashboard_auth?.enabled ?? false; +} + +/** + * Get dashboard_auth configuration with ENV var override. + * Priority: ENV vars > config.yaml > defaults + */ +export function getDashboardAuthConfig(): DashboardAuthConfig { + const config = getConfig(); + + // ENV vars take precedence + const envEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; + const envUsername = process.env.CCS_DASHBOARD_USERNAME; + const envPasswordHash = process.env.CCS_DASHBOARD_PASSWORD_HASH; + + return { + enabled: + envEnabled !== undefined + ? envEnabled === 'true' || envEnabled === '1' + : (config.dashboard_auth?.enabled ?? false), + username: envUsername ?? config.dashboard_auth?.username ?? '', + password_hash: envPasswordHash ?? config.dashboard_auth?.password_hash ?? '', + session_timeout_hours: config.dashboard_auth?.session_timeout_hours ?? 24, + }; +} + +/** + * Get browser automation configuration. + * Returns canonicalized defaults if not configured. + */ +export function getBrowserConfig(): BrowserConfig { + const config = getConfig(); + return canonicalizeBrowserConfig(config.browser); +} + +/** + * Get image_analysis configuration. + * Returns defaults if not configured. + */ +export function getImageAnalysisConfig(): ImageAnalysisConfig { + const config = getConfig(); + + return canonicalizeImageAnalysisConfig({ + enabled: config.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled, + timeout: config.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout, + provider_models: + config.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models, + fallback_backend: + config.image_analysis?.fallback_backend ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend, + profile_backends: + config.image_analysis?.profile_backends ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.profile_backends, + }); +} + +/** + * Get logging configuration. + * Returns defaults if not configured. + */ +export function getLoggingConfig(): LoggingConfig { + const config = getConfig(); + + return { + enabled: config.logging?.enabled ?? DEFAULT_LOGGING_CONFIG.enabled, + level: config.logging?.level ?? DEFAULT_LOGGING_CONFIG.level, + rotate_mb: config.logging?.rotate_mb ?? DEFAULT_LOGGING_CONFIG.rotate_mb, + retain_days: config.logging?.retain_days ?? DEFAULT_LOGGING_CONFIG.retain_days, + redact: config.logging?.redact ?? DEFAULT_LOGGING_CONFIG.redact, + live_buffer_size: config.logging?.live_buffer_size ?? DEFAULT_LOGGING_CONFIG.live_buffer_size, + }; +} + +/** + * Get cursor configuration. + * Returns defaults if not configured. + */ +export function getCursorConfig(): CursorConfig { + const config = getConfig(); + return config.cursor ?? { ...DEFAULT_CURSOR_CONFIG }; +} diff --git a/src/config/loader/defaults-merger.ts b/src/config/loader/defaults-merger.ts new file mode 100644 index 00000000..98dfa461 --- /dev/null +++ b/src/config/loader/defaults-merger.ts @@ -0,0 +1,323 @@ +/** + * defaults-merger.ts + * + * mergeWithDefaults function extracted from unified-config-loader.ts + * (Phase 4 split — issue #1164). + * + * Pure transform: no I/O, no side effects. Merges a partial UnifiedConfig + * with defaults, filling in missing sections. + * + * Circular-import note: this module imports from normalizers.ts (Phase 2) + * and io-locks.ts (Phase 1) is NOT imported here, so there is no cycle. + * io-locks.ts callbacks (mergeWithDefaults, validateCompositeVariants) are + * now replaced with direct imports in unified-config-loader.ts (Phase 6). + */ + +import { + createEmptyUnifiedConfig, + DEFAULT_COPILOT_CONFIG, + DEFAULT_CURSOR_CONFIG, + DEFAULT_GLOBAL_ENV, + DEFAULT_CLIPROXY_SERVER_CONFIG, + DEFAULT_CLIPROXY_SAFETY_CONFIG, + DEFAULT_OPENAI_COMPAT_PROXY_CONFIG, + DEFAULT_QUOTA_MANAGEMENT_CONFIG, + DEFAULT_THINKING_CONFIG, + DEFAULT_DASHBOARD_AUTH_CONFIG, + DEFAULT_IMAGE_ANALYSIS_CONFIG, + DEFAULT_LOGGING_CONFIG, +} from '../unified-config-types'; +import type { UnifiedConfig } from '../unified-config-types'; +import { canonicalizeBrowserConfig, normalizeSessionAffinityTtl } from './normalizers'; +import { normalizeContinuityConfig, normalizeOfficialChannelsConfig } from './normalizers'; +import type { LegacyDiscordChannelsConfig } from './normalizers'; +import { canonicalizeImageAnalysisConfig } from '../../utils/hooks/image-analysis-backend-resolver'; +import { normalizeSearxngBaseUrl } from '../../utils/websearch/types'; + +// --------------------------------------------------------------------------- +// mergeWithDefaults +// --------------------------------------------------------------------------- + +/** + * Merge partial config with defaults. + * Preserves existing data while filling in missing sections. + */ +export function mergeWithDefaults(partial: Partial): UnifiedConfig { + const defaults = createEmptyUnifiedConfig(); + const continuity = normalizeContinuityConfig(partial); + return { + version: partial.version ?? defaults.version, + setup_completed: partial.setup_completed, + default: partial.default ?? defaults.default, + accounts: partial.accounts ?? defaults.accounts, + profiles: partial.profiles ?? defaults.profiles, + cliproxy: { + ...partial.cliproxy, + oauth_accounts: partial.cliproxy?.oauth_accounts ?? defaults.cliproxy.oauth_accounts, + providers: defaults.cliproxy.providers, // Always use defaults for providers + variants: partial.cliproxy?.variants ?? defaults.cliproxy.variants, + logging: { + enabled: partial.cliproxy?.logging?.enabled ?? defaults.cliproxy.logging?.enabled ?? false, + request_log: + partial.cliproxy?.logging?.request_log ?? defaults.cliproxy.logging?.request_log ?? false, + }, + safety: { + antigravity_ack_bypass: + partial.cliproxy?.safety?.antigravity_ack_bypass ?? + DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass, + }, + // Kiro browser behavior setting (optional) + kiro_no_incognito: partial.cliproxy?.kiro_no_incognito, + // Auth config - preserve user values, no defaults (uses constants as fallback) + auth: partial.cliproxy?.auth, + // Background token refresh config (optional) + token_refresh: partial.cliproxy?.token_refresh, + // Backend selection - validate and preserve user choice (original vs plus) + backend: + partial.cliproxy?.backend === 'original' || partial.cliproxy?.backend === 'plus' + ? partial.cliproxy.backend + : undefined, // Invalid values become undefined (defaults to 'original' at runtime) + // Auto-sync - default to true + auto_sync: partial.cliproxy?.auto_sync ?? defaults.cliproxy.auto_sync ?? true, + routing: { + strategy: + partial.cliproxy?.routing?.strategy === 'fill-first' || + partial.cliproxy?.routing?.strategy === 'round-robin' + ? partial.cliproxy.routing.strategy + : defaults.cliproxy.routing?.strategy, + session_affinity: + typeof partial.cliproxy?.routing?.session_affinity === 'boolean' + ? partial.cliproxy.routing.session_affinity + : defaults.cliproxy.routing?.session_affinity, + session_affinity_ttl: normalizeSessionAffinityTtl( + partial.cliproxy?.routing?.session_affinity_ttl, + defaults.cliproxy.routing?.session_affinity_ttl ?? '1h' + ), + }, + }, + proxy: { + port: partial.proxy?.port ?? DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.port, + profile_ports: partial.proxy?.profile_ports ?? { + ...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.profile_ports, + }, + routing: { + default: partial.proxy?.routing?.default ?? defaults.proxy?.routing?.default, + background: partial.proxy?.routing?.background ?? defaults.proxy?.routing?.background, + think: partial.proxy?.routing?.think ?? defaults.proxy?.routing?.think, + longContext: partial.proxy?.routing?.longContext ?? defaults.proxy?.routing?.longContext, + webSearch: partial.proxy?.routing?.webSearch ?? defaults.proxy?.routing?.webSearch, + longContextThreshold: + partial.proxy?.routing?.longContextThreshold ?? + defaults.proxy?.routing?.longContextThreshold, + }, + }, + logging: { + enabled: partial.logging?.enabled ?? DEFAULT_LOGGING_CONFIG.enabled, + level: partial.logging?.level ?? DEFAULT_LOGGING_CONFIG.level, + rotate_mb: partial.logging?.rotate_mb ?? DEFAULT_LOGGING_CONFIG.rotate_mb, + retain_days: partial.logging?.retain_days ?? DEFAULT_LOGGING_CONFIG.retain_days, + redact: partial.logging?.redact ?? DEFAULT_LOGGING_CONFIG.redact, + live_buffer_size: + partial.logging?.live_buffer_size ?? DEFAULT_LOGGING_CONFIG.live_buffer_size, + }, + preferences: { + ...defaults.preferences, + ...partial.preferences, + }, + websearch: { + enabled: partial.websearch?.enabled ?? defaults.websearch?.enabled ?? true, + providers: { + exa: { + enabled: partial.websearch?.providers?.exa?.enabled ?? false, + max_results: partial.websearch?.providers?.exa?.max_results ?? 5, + }, + tavily: { + enabled: partial.websearch?.providers?.tavily?.enabled ?? false, + max_results: partial.websearch?.providers?.tavily?.max_results ?? 5, + }, + brave: { + enabled: partial.websearch?.providers?.brave?.enabled ?? false, + max_results: partial.websearch?.providers?.brave?.max_results ?? 5, + }, + searxng: { + enabled: partial.websearch?.providers?.searxng?.enabled ?? false, + url: normalizeSearxngBaseUrl(partial.websearch?.providers?.searxng?.url) ?? '', + max_results: partial.websearch?.providers?.searxng?.max_results ?? 5, + }, + duckduckgo: { + enabled: partial.websearch?.providers?.duckduckgo?.enabled ?? true, + max_results: partial.websearch?.providers?.duckduckgo?.max_results ?? 5, + }, + gemini: { + enabled: + partial.websearch?.providers?.gemini?.enabled ?? + partial.websearch?.gemini?.enabled ?? // Legacy fallback + false, + model: partial.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash', + timeout: + partial.websearch?.providers?.gemini?.timeout ?? + partial.websearch?.gemini?.timeout ?? // Legacy fallback + 55, + }, + opencode: { + enabled: partial.websearch?.providers?.opencode?.enabled ?? false, + model: partial.websearch?.providers?.opencode?.model ?? 'opencode/grok-code', + timeout: partial.websearch?.providers?.opencode?.timeout ?? 90, + }, + grok: { + enabled: partial.websearch?.providers?.grok?.enabled ?? false, + timeout: partial.websearch?.providers?.grok?.timeout ?? 55, + }, + }, + // Legacy fields (keep for backwards compatibility during read) + gemini: partial.websearch?.gemini, + }, + // Copilot config - strictly opt-in, merge with defaults + copilot: { + enabled: partial.copilot?.enabled ?? DEFAULT_COPILOT_CONFIG.enabled, + auto_start: partial.copilot?.auto_start ?? DEFAULT_COPILOT_CONFIG.auto_start, + port: partial.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port, + account_type: partial.copilot?.account_type ?? DEFAULT_COPILOT_CONFIG.account_type, + rate_limit: partial.copilot?.rate_limit ?? DEFAULT_COPILOT_CONFIG.rate_limit, + wait_on_limit: partial.copilot?.wait_on_limit ?? DEFAULT_COPILOT_CONFIG.wait_on_limit, + model: partial.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model, + }, + // Cursor config - disabled by default, merge with defaults + cursor: { + enabled: partial.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled, + port: partial.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port, + auto_start: partial.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start, + ghost_mode: partial.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode, + model: partial.cursor?.model ?? DEFAULT_CURSOR_CONFIG.model, + opus_model: partial.cursor?.opus_model, + sonnet_model: partial.cursor?.sonnet_model, + haiku_model: partial.cursor?.haiku_model, + }, + // Global env - injected into all non-Claude subscription profiles + global_env: { + enabled: partial.global_env?.enabled ?? true, + env: partial.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV }, + }, + continuity, + // CLIProxy server config - remote/local CLIProxyAPI settings + cliproxy_server: { + remote: { + enabled: + partial.cliproxy_server?.remote?.enabled ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.enabled, + host: partial.cliproxy_server?.remote?.host ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.host, + // Port is optional - undefined means use protocol default (443 for HTTPS, 8317 for HTTP) + port: partial.cliproxy_server?.remote?.port, + protocol: + partial.cliproxy_server?.remote?.protocol ?? + DEFAULT_CLIPROXY_SERVER_CONFIG.remote.protocol, + auth_token: + partial.cliproxy_server?.remote?.auth_token ?? + DEFAULT_CLIPROXY_SERVER_CONFIG.remote.auth_token, + // management_key is optional - falls back to auth_token when not set + management_key: partial.cliproxy_server?.remote?.management_key, + }, + fallback: { + enabled: + partial.cliproxy_server?.fallback?.enabled ?? + DEFAULT_CLIPROXY_SERVER_CONFIG.fallback.enabled, + auto_start: + partial.cliproxy_server?.fallback?.auto_start ?? + DEFAULT_CLIPROXY_SERVER_CONFIG.fallback.auto_start, + }, + local: { + port: partial.cliproxy_server?.local?.port ?? DEFAULT_CLIPROXY_SERVER_CONFIG.local.port, + auto_start: + partial.cliproxy_server?.local?.auto_start ?? + DEFAULT_CLIPROXY_SERVER_CONFIG.local.auto_start, + }, + }, + // Quota management config - hybrid auto+manual account selection + quota_management: { + mode: partial.quota_management?.mode ?? DEFAULT_QUOTA_MANAGEMENT_CONFIG.mode, + auto: { + preflight_check: + partial.quota_management?.auto?.preflight_check ?? + DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.preflight_check, + exhaustion_threshold: + partial.quota_management?.auto?.exhaustion_threshold ?? + DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.exhaustion_threshold, + tier_priority: + partial.quota_management?.auto?.tier_priority ?? + DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.tier_priority, + cooldown_minutes: + partial.quota_management?.auto?.cooldown_minutes ?? + DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.cooldown_minutes, + }, + manual: { + paused_accounts: + partial.quota_management?.manual?.paused_accounts ?? + DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.paused_accounts, + forced_default: + partial.quota_management?.manual?.forced_default ?? + DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.forced_default, + tier_lock: + partial.quota_management?.manual?.tier_lock ?? + DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.tier_lock, + }, + runtime_monitor: { + enabled: + partial.quota_management?.runtime_monitor?.enabled ?? + DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.enabled, + normal_interval_seconds: + partial.quota_management?.runtime_monitor?.normal_interval_seconds ?? + DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.normal_interval_seconds, + critical_interval_seconds: + partial.quota_management?.runtime_monitor?.critical_interval_seconds ?? + DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.critical_interval_seconds, + warn_threshold: + partial.quota_management?.runtime_monitor?.warn_threshold ?? + DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.warn_threshold, + exhaustion_threshold: + partial.quota_management?.runtime_monitor?.exhaustion_threshold ?? + DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.exhaustion_threshold, + cooldown_minutes: + partial.quota_management?.runtime_monitor?.cooldown_minutes ?? + DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.cooldown_minutes, + }, + }, + // Thinking config - auto/manual/off control for reasoning budget + thinking: { + mode: partial.thinking?.mode ?? DEFAULT_THINKING_CONFIG.mode, + override: partial.thinking?.override, + tier_defaults: { + opus: partial.thinking?.tier_defaults?.opus ?? DEFAULT_THINKING_CONFIG.tier_defaults.opus, + sonnet: + partial.thinking?.tier_defaults?.sonnet ?? DEFAULT_THINKING_CONFIG.tier_defaults.sonnet, + haiku: + partial.thinking?.tier_defaults?.haiku ?? DEFAULT_THINKING_CONFIG.tier_defaults.haiku, + }, + provider_overrides: partial.thinking?.provider_overrides, + show_warnings: partial.thinking?.show_warnings ?? DEFAULT_THINKING_CONFIG.show_warnings, + }, + channels: normalizeOfficialChannelsConfig( + partial as Partial & { discord_channels?: LegacyDiscordChannelsConfig } + ), + // Dashboard auth config - disabled by default + dashboard_auth: { + enabled: partial.dashboard_auth?.enabled ?? DEFAULT_DASHBOARD_AUTH_CONFIG.enabled, + username: partial.dashboard_auth?.username ?? DEFAULT_DASHBOARD_AUTH_CONFIG.username, + password_hash: + partial.dashboard_auth?.password_hash ?? DEFAULT_DASHBOARD_AUTH_CONFIG.password_hash, + session_timeout_hours: + partial.dashboard_auth?.session_timeout_hours ?? + DEFAULT_DASHBOARD_AUTH_CONFIG.session_timeout_hours, + }, + browser: canonicalizeBrowserConfig(partial.browser), + // Image analysis config - enabled by default for CLIProxy providers + image_analysis: canonicalizeImageAnalysisConfig({ + enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled, + timeout: partial.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout, + provider_models: + partial.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models, + fallback_backend: + partial.image_analysis?.fallback_backend ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend, + profile_backends: + partial.image_analysis?.profile_backends ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.profile_backends, + }), + }; +} diff --git a/src/config/loader/io-locks.ts b/src/config/loader/io-locks.ts new file mode 100644 index 00000000..2c5c625c --- /dev/null +++ b/src/config/loader/io-locks.ts @@ -0,0 +1,301 @@ +/** + * io-locks.ts + * + * Path constants, lockfile management, and config I/O helpers extracted from + * unified-config-loader.ts (Phase 1 split — issue #1164). + * + * Forward-reference note: loadUnifiedConfigWithLockHeld and + * writeUnifiedConfigWithLockHeld depend on mergeWithDefaults, + * validateCompositeVariants, generateYamlHeader, and generateYamlWithComments + * which still live in unified-config-loader.ts (extracted in later phases). + * Those are passed as callbacks to avoid a circular import. Once Phases 2–3 + * land in separate modules with no dependency on this file, they can be + * imported directly. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import * as yaml from 'js-yaml'; +import { getCcsDir } from '../../utils/config-manager'; +import { + isUnifiedConfig, + createEmptyUnifiedConfig, + UNIFIED_CONFIG_VERSION, +} from '../unified-config-types'; +import type { UnifiedConfig } from '../unified-config-types'; + +// --------------------------------------------------------------------------- +// Path constants +// --------------------------------------------------------------------------- + +export const CONFIG_YAML = 'config.yaml'; +export const CONFIG_JSON = 'config.json'; +export const CONFIG_LOCK = 'config.yaml.lock'; +/** Lock is stale after this many milliseconds */ +export const LOCK_STALE_MS = 5000; +export const GO_DURATION_SEGMENT = String.raw`(?:\d+(?:\.\d+)?(?:ns|us|µs|μs|ms|s|m|h))`; +export const GO_DURATION_PATTERN = new RegExp(`^${GO_DURATION_SEGMENT}+$`); + +// --------------------------------------------------------------------------- +// Path helpers +// --------------------------------------------------------------------------- + +/** + * Get path to unified config.yaml + */ +export function getConfigYamlPath(): string { + return path.join(getCcsDir(), CONFIG_YAML); +} + +/** + * Get path to legacy config.json + */ +export function getConfigJsonPath(): string { + return path.join(getCcsDir(), CONFIG_JSON); +} + +/** + * Get path to config lockfile (internal) + */ +function getLockFilePath(): string { + return path.join(getCcsDir(), CONFIG_LOCK); +} + +// --------------------------------------------------------------------------- +// Process check +// --------------------------------------------------------------------------- + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Lockfile primitives +// --------------------------------------------------------------------------- + +/** + * Acquire lockfile for config write operations. + * Returns a lock token if acquired, null if already locked by another process. + * Cleans up stale locks (older than LOCK_STALE_MS). + */ +export function acquireLock(): string | null { + const lockPath = getLockFilePath(); + const lockDir = path.dirname(lockPath); + const lockToken = crypto.randomUUID(); + const lockData = `${process.pid}\n${Date.now()}\n${lockToken}`; + + try { + if (!fs.existsSync(lockDir)) { + fs.mkdirSync(lockDir, { recursive: true, mode: 0o700 }); + } + + // Check if lock exists + if (fs.existsSync(lockPath)) { + const content = fs.readFileSync(lockPath, 'utf8'); + const [pidStr, timestampStr] = content.trim().split('\n'); + const pid = Number.parseInt(pidStr, 10); + const timestamp = Number.parseInt(timestampStr, 10); + const hasLiveOwner = Number.isInteger(pid) && pid > 0 && processExists(pid); + const isStale = !Number.isFinite(timestamp) || Date.now() - timestamp > LOCK_STALE_MS; + + if (hasLiveOwner) { + return null; + } + + if (isStale || !hasLiveOwner) { + fs.unlinkSync(lockPath); + } + } + + // Acquire lock + fs.writeFileSync(lockPath, lockData, { flag: 'wx', mode: 0o600 }); + return lockToken; + } catch (error) { + // EEXIST means another process acquired the lock between our check and write + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + return null; + } + return null; + } +} + +/** + * Release lockfile after config write operation. + */ +export function releaseLock(lockToken: string): void { + const lockPath = getLockFilePath(); + try { + if (fs.existsSync(lockPath)) { + const content = fs.readFileSync(lockPath, 'utf8'); + const fileToken = content.trim().split('\n')[2]; + if (fileToken === lockToken) { + fs.unlinkSync(lockPath); + } + } + } catch { + // Ignore cleanup errors + } +} + +// --------------------------------------------------------------------------- +// Config format detection +// --------------------------------------------------------------------------- + +/** + * Check if unified config.yaml exists + */ +export function hasUnifiedConfig(): boolean { + return fs.existsSync(getConfigYamlPath()); +} + +/** + * Check if legacy config.json exists + */ +export function hasLegacyConfig(): boolean { + return fs.existsSync(getConfigJsonPath()); +} + +// --------------------------------------------------------------------------- +// Sync sleep +// --------------------------------------------------------------------------- + +/** + * Sync sleep helper for lock retry loops. + * Uses Atomics.wait when available to avoid CPU-intensive busy-wait. + */ +export function sleepSync(ms: number): void { + try { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + } catch { + const end = Date.now() + ms; + while (Date.now() < end) { + /* busy-wait */ + } + } +} + +// --------------------------------------------------------------------------- +// Lock-aware execution wrapper +// --------------------------------------------------------------------------- + +/** + * Execute a callback while holding the config lock. + */ +export function withConfigWriteLock(callback: () => T): T { + // Acquire lock (retry for up to 1 second) + const maxRetries = 10; + const retryDelayMs = 100; + let lockToken: string | null = null; + for (let i = 0; i < maxRetries; i++) { + const acquiredToken = acquireLock(); + if (acquiredToken) { + lockToken = acquiredToken; + break; + } + sleepSync(retryDelayMs); + } + + if (!lockToken) { + throw new Error('Config file is locked by another process. Wait a moment and try again.'); + } + + try { + return callback(); + } finally { + // Always release lock + releaseLock(lockToken); + } +} + +// --------------------------------------------------------------------------- +// Lock-held read/write helpers +// --------------------------------------------------------------------------- + +/** + * Load unified config directly from disk while lock is already held. + * Falls back to empty config when file doesn't exist. + * + * Forward-reference: mergeWithDefaults and validateCompositeVariants are + * passed as callbacks to avoid a circular dependency. They will be imported + * directly once all phases are complete. + */ +export function loadUnifiedConfigWithLockHeld( + mergeWithDefaults: (partial: Partial) => UnifiedConfig, + validateCompositeVariants: (config: UnifiedConfig) => void +): UnifiedConfig { + const yamlPath = getConfigYamlPath(); + if (!fs.existsSync(yamlPath)) { + return createEmptyUnifiedConfig(); + } + + const content = fs.readFileSync(yamlPath, 'utf8'); + const parsed = yaml.load(content); + + if (!isUnifiedConfig(parsed)) { + throw new Error(`Invalid config format in ${yamlPath}`); + } + + const merged = mergeWithDefaults(parsed); + validateCompositeVariants(merged); + return merged; +} + +/** + * Write unified config to disk while lock is already held. + * Uses atomic write (temp file + rename) to prevent corruption. + * + * Forward-reference: generateYamlHeader and generateYamlWithComments are + * passed as callbacks to avoid a circular dependency. They will be imported + * directly once all phases are complete. + */ +export function writeUnifiedConfigWithLockHeld( + config: UnifiedConfig, + generateYamlHeader: () => string, + generateYamlWithComments: (config: UnifiedConfig) => string +): void { + const yamlPath = getConfigYamlPath(); + const dir = path.dirname(yamlPath); + + // Ensure directory exists + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + // Ensure version is set + config.version = UNIFIED_CONFIG_VERSION; + + // Generate YAML with section comments + const yamlContent = generateYamlWithComments(config); + const content = generateYamlHeader() + yamlContent; + + // Atomic write: write to temp file, then rename + const tempPath = `${yamlPath}.tmp.${process.pid}`; + + try { + fs.writeFileSync(tempPath, content, { mode: 0o600 }); + fs.renameSync(tempPath, yamlPath); + } catch (error) { + // Clean up temp file on error + if (fs.existsSync(tempPath)) { + try { + fs.unlinkSync(tempPath); + } catch { + // Ignore cleanup errors + } + } + // Classify filesystem errors + const err = error as NodeJS.ErrnoException; + if (err.code === 'ENOSPC') { + throw new Error('Disk full - cannot save config. Free up space and try again.'); + } else if (err.code === 'EROFS' || err.code === 'EACCES') { + throw new Error(`Cannot write config - check file permissions: ${err.message}`); + } + throw error; + } +} diff --git a/src/config/loader/normalizers.ts b/src/config/loader/normalizers.ts new file mode 100644 index 00000000..093ae434 --- /dev/null +++ b/src/config/loader/normalizers.ts @@ -0,0 +1,228 @@ +/** + * normalizers.ts + * + * Input normalization and validation helpers extracted from + * unified-config-loader.ts (Phase 2 split — issue #1164). + * + * Contains: browser config normalizers, session affinity TTL normalizer, + * composite variant validator, continuity config normalizer, and official + * channels config normalizer. + */ + +import { DEFAULT_BROWSER_CONFIG, DEFAULT_OFFICIAL_CHANNELS_CONFIG } from '../unified-config-types'; +import type { + UnifiedConfig, + BrowserConfig, + BrowserEvalMode, + BrowserToolPolicy, + OfficialChannelsConfig, + OfficialChannelId, + ContinuityConfig, +} from '../unified-config-types'; +import { validateCompositeTiers } from '../../cliproxy/config/composite-validator'; +import { + isOfficialChannelId, + normalizeOfficialChannelIds, + resolveLegacyDiscordSelection, +} from '../../channels/official-channels-ids'; +import { getRecommendedBrowserUserDataDir } from '../../utils/browser/browser-settings'; +import { GO_DURATION_PATTERN, GO_DURATION_SEGMENT } from './io-locks'; + +// --------------------------------------------------------------------------- +// Browser normalizers +// --------------------------------------------------------------------------- + +export function normalizeBrowserDevtoolsPort(value: number | undefined): number { + if (!Number.isFinite(value)) { + return DEFAULT_BROWSER_CONFIG.claude.devtools_port; + } + + const port = Math.floor(value as number); + if (port < 1 || port > 65535) { + return DEFAULT_BROWSER_CONFIG.claude.devtools_port; + } + + return port; +} + +export function normalizeBrowserPolicy(value: string | undefined): BrowserToolPolicy { + return value === 'auto' || value === 'manual' ? value : DEFAULT_BROWSER_CONFIG.claude.policy; +} + +export function normalizeBrowserEvalMode(value: string | undefined): BrowserEvalMode { + if (value === 'disabled' || value === 'readonly' || value === 'readwrite') { + return value; + } + + return DEFAULT_BROWSER_CONFIG.claude.eval_mode ?? 'readonly'; +} + +export function canonicalizeBrowserConfig( + config?: BrowserConfig, + fallback: BrowserConfig = DEFAULT_BROWSER_CONFIG +): BrowserConfig { + const claudeUserDataDir = + config?.claude?.user_data_dir === undefined + ? fallback.claude.user_data_dir || getRecommendedBrowserUserDataDir() + : config.claude.user_data_dir.trim() || getRecommendedBrowserUserDataDir(); + + return { + claude: { + enabled: config?.claude?.enabled ?? fallback.claude.enabled, + policy: normalizeBrowserPolicy(config?.claude?.policy ?? fallback.claude.policy), + user_data_dir: claudeUserDataDir, + devtools_port: normalizeBrowserDevtoolsPort( + config?.claude?.devtools_port ?? fallback.claude.devtools_port + ), + eval_mode: normalizeBrowserEvalMode(config?.claude?.eval_mode ?? fallback.claude.eval_mode), + }, + codex: { + enabled: config?.codex?.enabled ?? fallback.codex.enabled, + policy: normalizeBrowserPolicy(config?.codex?.policy ?? fallback.codex.policy), + eval_mode: normalizeBrowserEvalMode(config?.codex?.eval_mode ?? fallback.codex.eval_mode), + }, + }; +} + +// --------------------------------------------------------------------------- +// Session affinity TTL normalizer +// --------------------------------------------------------------------------- + +export function normalizeSessionAffinityTtl(value: unknown, fallback: string): string { + if (typeof value !== 'string') { + return fallback; + } + + const trimmed = value.trim(); + if (!trimmed || !GO_DURATION_PATTERN.test(trimmed) || !hasPositiveDuration(trimmed)) { + return fallback; + } + + return trimmed; +} + +export function hasPositiveDuration(value: string): boolean { + const segments = value.match(new RegExp(GO_DURATION_SEGMENT, 'g')); + if (!segments) { + return false; + } + + return segments.some((segment) => { + const numeric = parseFloat(segment); + return Number.isFinite(numeric) && numeric > 0; + }); +} + +// --------------------------------------------------------------------------- +// Composite variant validator +// --------------------------------------------------------------------------- + +/** + * Validate composite variant provider strings. + * Warns about invalid providers in composite variant configurations. + */ +export function validateCompositeVariants(config: UnifiedConfig): void { + const variants = config.cliproxy?.variants; + if (!variants) return; + + for (const [name, variant] of Object.entries(variants)) { + if ('type' in variant && variant.type === 'composite') { + const error = validateCompositeTiers(variant.tiers, { + defaultTier: variant.default_tier, + requireAllTiers: true, + }); + if (error) { + console.warn(`[!] Variant '${name}': invalid composite config (${error})`); + } + } + } +} + +// --------------------------------------------------------------------------- +// Continuity config normalizer +// --------------------------------------------------------------------------- + +/** + * Normalize continuity inheritance mapping payload. + * Keeps only non-empty string keys and values. + */ +export function normalizeContinuityInheritanceMap( + value: unknown +): Record | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return undefined; + } + + const normalized: Record = {}; + for (const [profileName, accountName] of Object.entries(value as Record)) { + const normalizedProfile = profileName.trim(); + const normalizedAccount = typeof accountName === 'string' ? accountName.trim() : ''; + + if (!normalizedProfile || !normalizedAccount) { + continue; + } + + normalized[normalizedProfile] = normalizedAccount; + } + + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +/** + * Normalize continuity section. + * Supports legacy root key: continuity_inherit_from_account. + */ +export function normalizeContinuityConfig( + partial: Partial +): ContinuityConfig | undefined { + const legacyMap = normalizeContinuityInheritanceMap( + (partial as Partial & { continuity_inherit_from_account?: unknown }) + .continuity_inherit_from_account + ); + const continuityMap = normalizeContinuityInheritanceMap(partial.continuity?.inherit_from_account); + + if (!legacyMap && !continuityMap) { + return undefined; + } + + return { + inherit_from_account: { + ...(legacyMap ?? {}), + ...(continuityMap ?? {}), + }, + }; +} + +// --------------------------------------------------------------------------- +// Official channels config normalizer +// --------------------------------------------------------------------------- + +export interface LegacyDiscordChannelsConfig { + enabled?: boolean; + unattended?: boolean; +} + +export function normalizeOfficialChannelsConfig( + partial: Partial & { discord_channels?: LegacyDiscordChannelsConfig } +): OfficialChannelsConfig { + const hasCanonicalChannelsSection = partial.channels !== undefined; + const hasExplicitSelectedField = + hasCanonicalChannelsSection && + Object.prototype.hasOwnProperty.call(partial.channels, 'selected'); + const rawSelected = + hasExplicitSelectedField && Array.isArray(partial.channels?.selected) + ? partial.channels.selected.filter((value): value is OfficialChannelId => + isOfficialChannelId(value) + ) + : []; + + return { + selected: hasCanonicalChannelsSection + ? normalizeOfficialChannelIds(rawSelected) + : resolveLegacyDiscordSelection(partial.discord_channels?.enabled), + unattended: + partial.channels?.unattended ?? + partial.discord_channels?.unattended ?? + DEFAULT_OFFICIAL_CHANNELS_CONFIG.unattended, + }; +} diff --git a/src/config/loader/yaml-serializer.ts b/src/config/loader/yaml-serializer.ts new file mode 100644 index 00000000..7993c8cb --- /dev/null +++ b/src/config/loader/yaml-serializer.ts @@ -0,0 +1,349 @@ +/** + * yaml-serializer.ts + * + * YAML generation helpers extracted from unified-config-loader.ts + * (Phase 3 split — issue #1164). + * + * Contains: generateYamlHeader, generateYamlWithComments. + * These produce the commented YAML written to config.yaml on every save. + */ + +import * as yaml from 'js-yaml'; +import type { UnifiedConfig } from '../unified-config-types'; + +/** + * Generate YAML header with helpful comments. + */ +export function generateYamlHeader(): string { + return `# CCS Unified Configuration +# Docs: https://github.com/kaitranntt/ccs +`; +} + +/** + * Generate YAML content with section comments for better readability. + */ +export function generateYamlWithComments(config: UnifiedConfig): string { + const lines: string[] = []; + + // Version + lines.push(`version: ${config.version}`); + if (config.setup_completed !== undefined) { + lines.push(`setup_completed: ${config.setup_completed}`); + } + lines.push(''); + + // Default + if (config.default) { + lines.push(`# Default profile used when running 'ccs' without arguments`); + lines.push(`default: "${config.default}"`); + lines.push(''); + } + + // Accounts section + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Accounts: Isolated Claude instances (each with separate auth/sessions)'); + lines.push('# Manage with: ccs auth add , ccs auth list, ccs auth remove '); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ accounts: config.accounts }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + + // Profiles section + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Profiles: API-based providers (GLM, Kimi, custom endpoints)'); + lines.push('# Each profile points to a *.settings.json file containing env vars.'); + lines.push('# Edit the settings file directly to customize (ANTHROPIC_MAX_TOKENS, etc.)'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ profiles: config.profiles }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + + // CLIProxy section + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# CLIProxy: OAuth-based providers (gemini, codex, agy, qwen, iflow)'); + lines.push('# Each variant can reference a *.settings.json file for custom env vars.'); + lines.push('# Edit the settings file directly to customize model or other settings.'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ cliproxy: config.cliproxy }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + + if (config.proxy?.routing) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Proxy Routing: OpenAI-compatible local proxy model selection rules'); + lines.push('# Use profile:model selectors to force a target profile and upstream model.'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ proxy: config.proxy }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + } + + if (config.logging) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Logging: CCS-owned structured runtime logs'); + lines.push('# Current file: ~/.ccs/logs/current.jsonl'); + lines.push('# Archives rotate automatically and are pruned by retain_days.'); + lines.push('# This is separate from cliproxy.logging, which controls CLIProxy runtime files.'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ logging: config.logging }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + } + + // CLIProxy Server section (remote proxy configuration) - placed right after cliproxy + if (config.cliproxy_server) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# CLIProxy Server: Remote proxy connection settings'); + lines.push('# Configure via Dashboard (`ccs config`) > Proxy tab.'); + lines.push('#'); + lines.push('# remote: Connect to a remote CLIProxyAPI instance'); + lines.push('# fallback: Use local proxy if remote is unreachable'); + lines.push('# local: Local proxy settings (port, auto-start)'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump( + { cliproxy_server: config.cliproxy_server }, + { indent: 2, lineWidth: -1, quotingType: '"' } + ) + .trim() + ); + lines.push(''); + } + + // Preferences section + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Preferences: User settings'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump({ preferences: config.preferences }, { indent: 2, lineWidth: -1, quotingType: '"' }) + .trim() + ); + lines.push(''); + + // WebSearch section + if (config.websearch) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# WebSearch: real search backends for third-party profiles'); + lines.push('# Dashboard (`ccs config`) is the source of truth for provider selection.'); + lines.push('#'); + lines.push('# Third-party providers (gemini, codex, agy, etc.) do not have access to'); + lines.push("# Anthropic's WebSearch tool. CCS intercepts that tool and runs local search."); + lines.push('#'); + lines.push( + '# Priority: Exa -> Tavily -> Brave -> DuckDuckGo -> optional legacy AI CLI fallbacks' + ); + lines.push('#'); + lines.push('# Exa requires EXA_API_KEY in your environment.'); + lines.push('# Tavily requires TAVILY_API_KEY in your environment.'); + lines.push('# Brave requires BRAVE_API_KEY in your environment.'); + lines.push('# DuckDuckGo works with zero extra setup and is enabled by default.'); + lines.push('#'); + lines.push('# Legacy LLM fallbacks remain optional if you still want them:'); + lines.push('# gemini: npm i -g @google/gemini-cli'); + lines.push('# opencode: curl -fsSL https://opencode.ai/install | bash'); + lines.push('# grok: npm i -g @vibe-kit/grok-cli'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump({ websearch: config.websearch }, { indent: 2, lineWidth: -1, quotingType: '"' }) + .trim() + ); + lines.push(''); + } + + // Copilot section (GitHub Copilot proxy) + if (config.copilot) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Copilot: GitHub Copilot API proxy (via copilot-api)'); + lines.push('# Uses your existing GitHub Copilot subscription with Claude Code.'); + lines.push('#'); + lines.push('# !! DISCLAIMER - USE AT YOUR OWN RISK !!'); + lines.push('# This uses an UNOFFICIAL reverse-engineered API.'); + lines.push('# Excessive usage may trigger GitHub account restrictions.'); + lines.push('# CCS provides NO WARRANTY and accepts NO RESPONSIBILITY for consequences.'); + lines.push('#'); + lines.push('# Setup: npx copilot-api auth (authenticate with GitHub)'); + lines.push('# Usage: ccs copilot (switch to copilot profile)'); + lines.push('#'); + lines.push('# Models: claude-sonnet-4.5, claude-opus-4.5, gpt-5.1, gemini-2.5-pro'); + lines.push('# Account types: individual, business, enterprise'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ copilot: config.copilot }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + } + + // Cursor section (Cursor IDE proxy daemon) + if (config.cursor) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Cursor: Cursor IDE proxy daemon'); + lines.push('# Enables Cursor IDE integration via local proxy daemon.'); + lines.push('#'); + lines.push('# enabled: Enable/disable Cursor integration (default: false)'); + lines.push('# port: Port for cursor proxy daemon (default: 20129)'); + lines.push('# auto_start: Auto-start daemon when CCS starts (default: false)'); + lines.push('# ghost_mode: Disable telemetry for privacy (default: true)'); + lines.push('# model: Default model ID (used for ANTHROPIC_MODEL)'); + lines.push('# opus_model/sonnet_model/haiku_model: Optional tier model mapping'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ cursor: config.cursor }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + } + + // Global env section + if (config.global_env) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + '# Global Environment Variables: Injected into all non-Claude subscription profiles' + ); + lines.push('# These env vars disable telemetry/reporting for third-party providers.'); + lines.push('# Configure via Dashboard (`ccs config`) > Global Env tab.'); + lines.push('#'); + lines.push('# Default variables:'); + lines.push('# DISABLE_BUG_COMMAND: Disables /bug command (not supported by proxy)'); + lines.push('# DISABLE_ERROR_REPORTING: Disables error reporting to Anthropic'); + lines.push('# DISABLE_TELEMETRY: Disables usage telemetry'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump({ global_env: config.global_env }, { indent: 2, lineWidth: -1, quotingType: '"' }) + .trim() + ); + lines.push(''); + } + + // Continuity inheritance section + if (config.continuity?.inherit_from_account) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Continuity Inheritance: Reuse account continuity artifacts across profiles'); + lines.push('# Map execution profile names to source account profiles (CLAUDE_CONFIG_DIR).'); + lines.push('# Applies to Claude target only; credentials remain profile-specific.'); + lines.push('# Example: continuity.inherit_from_account.glm: pro'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump({ continuity: config.continuity }, { indent: 2, lineWidth: -1, quotingType: '"' }) + .trim() + ); + lines.push(''); + } + + // Thinking section (extended thinking/reasoning configuration) + if (config.thinking) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Thinking: Extended thinking/reasoning budget configuration'); + lines.push('# Controls reasoning depth for supported providers (agy, gemini, codex).'); + lines.push('#'); + lines.push( + '# Modes: auto (use tier_defaults), off (disable), manual (--thinking/--effort flags)' + ); + lines.push( + '# Levels: minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), max (adaptive ceiling), auto' + ); + lines.push('# Override: Set global override value (number or level name)'); + lines.push('# Provider overrides: Per-provider tier defaults'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump({ thinking: config.thinking }, { indent: 2, lineWidth: -1, quotingType: '"' }) + .trim() + ); + lines.push(''); + } + + // Official Channels section + if (config.channels) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Official Channels: Runtime auto-enable for Anthropic official channel plugins'); + lines.push('# Supported channels: telegram, discord, imessage'); + lines.push('# Runtime-only: CCS injects --channels at launch for compatible Claude sessions.'); + lines.push('# Bot tokens live in Claude channel env files, not in config.yaml.'); + lines.push('# Use selected: [telegram, discord, imessage] to choose channels.'); + lines.push( + '# unattended adds --dangerously-skip-permissions only when channel auto-enable is active.' + ); + lines.push('# Compatible sessions: native Claude default/account profiles only.'); + lines.push('# Configure via: ccs config channels or the Settings > Channels dashboard tab.'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump({ channels: config.channels }, { indent: 2, lineWidth: -1, quotingType: '"' }) + .trim() + ); + lines.push(''); + } + + // Dashboard auth section (only if configured) + if (config.dashboard_auth?.enabled) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Dashboard Auth: Optional login protection for CCS dashboard'); + lines.push('# Generate password hash: npx bcrypt-cli hash "your-password"'); + lines.push( + '# ENV override: CCS_DASHBOARD_AUTH_ENABLED, CCS_DASHBOARD_USERNAME, CCS_DASHBOARD_PASSWORD_HASH' + ); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump( + { dashboard_auth: config.dashboard_auth }, + { indent: 2, lineWidth: -1, quotingType: '"' } + ) + .trim() + ); + lines.push(''); + } + + // Browser automation section + if (config.browser) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Browser Automation: Claude browser attach and Codex browser tooling'); + lines.push('# Claude attach reuses a running Chrome/Chromium session with remote debugging.'); + lines.push('# Codex tooling controls whether CCS injects Playwright MCP overrides.'); + lines.push('#'); + lines.push('# claude.user_data_dir should point at the Chrome user-data directory for the'); + lines.push('# dedicated attach session. claude.devtools_port is the expected debugging port.'); + lines.push('# Configure via: Settings > Browser or `ccs browser ...`.'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ browser: config.browser }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + } + + // Image analysis section + if (config.image_analysis) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Image Analysis: Vision-based analysis for images and PDFs'); + lines.push('# Routes Read tool requests for images/PDFs through CLIProxy vision API.'); + lines.push('#'); + lines.push('# When enabled: Image files trigger vision analysis instead of raw file read'); + lines.push('# Provider models: Vision model used for each CLIProxy provider'); + lines.push('# Timeout: Maximum seconds to wait for analysis (10-600)'); + lines.push('#'); + lines.push('# Supported formats: .jpg, .jpeg, .png, .gif, .webp, .heic, .bmp, .tiff, .pdf'); + lines.push('# Configure via: ccs config image-analysis'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump( + { image_analysis: config.image_analysis }, + { indent: 2, lineWidth: -1, quotingType: '"' } + ) + .trim() + ); + lines.push(''); + } + + return lines.join('\n'); +} diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index bca681f8..fcd943bd 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -1,252 +1,114 @@ /** - * Unified Config Loader + * Unified Config Loader — orchestrator * * Loads and saves the unified YAML configuration. * Provides fallback to legacy JSON format for backward compatibility. + * + * Phase 1-6 refactor (issue #1164): + * Phase 1 → src/config/loader/io-locks.ts + * Phase 2 → src/config/loader/normalizers.ts + * Phase 3 → src/config/loader/yaml-serializer.ts + * Phase 4 → src/config/loader/defaults-merger.ts + * Phase 5 → src/config/loader/config-getters.ts + * + * This file re-exports the full public API so all existing import sites + * continue to work without modification. */ import * as fs from 'fs'; -import * as path from 'path'; -import * as crypto from 'crypto'; import * as yaml from 'js-yaml'; -import { getCcsDir } from '../utils/config-manager'; import { isUnifiedConfig, createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION, - DEFAULT_COPILOT_CONFIG, - DEFAULT_CURSOR_CONFIG, - DEFAULT_GLOBAL_ENV, - DEFAULT_CLIPROXY_SERVER_CONFIG, - DEFAULT_CLIPROXY_SAFETY_CONFIG, - DEFAULT_OPENAI_COMPAT_PROXY_CONFIG, - DEFAULT_QUOTA_MANAGEMENT_CONFIG, - DEFAULT_THINKING_CONFIG, - DEFAULT_OFFICIAL_CHANNELS_CONFIG, - DEFAULT_DASHBOARD_AUTH_CONFIG, - DEFAULT_BROWSER_CONFIG, - DEFAULT_IMAGE_ANALYSIS_CONFIG, - DEFAULT_LOGGING_CONFIG, } from './unified-config-types'; -import type { - UnifiedConfig, - CLIProxySafetyConfig, - GlobalEnvConfig, - ThinkingConfig, - OfficialChannelsConfig, - OfficialChannelId, - DashboardAuthConfig, - BrowserConfig, - BrowserEvalMode, - BrowserToolPolicy, - ImageAnalysisConfig, - LoggingConfig, - CursorConfig, - ContinuityConfig, -} from './unified-config-types'; -import { validateCompositeTiers } from '../cliproxy/config/composite-validator'; +import type { UnifiedConfig } from './unified-config-types'; import { isUnifiedConfigEnabled } from './feature-flags'; + +// --------------------------------------------------------------------------- +// Phase 1 re-exports: io-locks +// --------------------------------------------------------------------------- + +export { + CONFIG_YAML, + CONFIG_JSON, + CONFIG_LOCK, + LOCK_STALE_MS, + GO_DURATION_SEGMENT, + GO_DURATION_PATTERN, + getConfigYamlPath, + getConfigJsonPath, + acquireLock, + releaseLock, + hasUnifiedConfig, + hasLegacyConfig, + sleepSync, + withConfigWriteLock, +} from './loader/io-locks'; import { - isOfficialChannelId, - normalizeOfficialChannelIds, - resolveLegacyDiscordSelection, -} from '../channels/official-channels-runtime'; -import { getRecommendedBrowserUserDataDir } from '../utils/browser/browser-settings'; -import { canonicalizeImageAnalysisConfig } from '../utils/hooks/image-analysis-backend-resolver'; -import { normalizeSearxngBaseUrl } from '../utils/websearch/types'; + getConfigYamlPath, + hasUnifiedConfig, + hasLegacyConfig, + withConfigWriteLock, + loadUnifiedConfigWithLockHeld, + writeUnifiedConfigWithLockHeld, +} from './loader/io-locks'; -const CONFIG_YAML = 'config.yaml'; -const CONFIG_JSON = 'config.json'; -const CONFIG_LOCK = 'config.yaml.lock'; -const LOCK_STALE_MS = 5000; // Lock is stale after 5 seconds -const GO_DURATION_SEGMENT = String.raw`(?:\d+(?:\.\d+)?(?:ns|us|µs|μs|ms|s|m|h))`; -const GO_DURATION_PATTERN = new RegExp(`^${GO_DURATION_SEGMENT}+$`); +// --------------------------------------------------------------------------- +// Phase 2 re-exports: normalizers +// --------------------------------------------------------------------------- -function normalizeBrowserDevtoolsPort(value: number | undefined): number { - if (!Number.isFinite(value)) { - return DEFAULT_BROWSER_CONFIG.claude.devtools_port; - } +export { + normalizeBrowserDevtoolsPort, + normalizeBrowserPolicy, + normalizeBrowserEvalMode, + canonicalizeBrowserConfig, + normalizeSessionAffinityTtl, + hasPositiveDuration, + validateCompositeVariants, + normalizeContinuityInheritanceMap, + normalizeContinuityConfig, + normalizeOfficialChannelsConfig, +} from './loader/normalizers'; +import { canonicalizeBrowserConfig, validateCompositeVariants } from './loader/normalizers'; - const port = Math.floor(value as number); - if (port < 1 || port > 65535) { - return DEFAULT_BROWSER_CONFIG.claude.devtools_port; - } +// --------------------------------------------------------------------------- +// Phase 3 re-exports: yaml-serializer +// --------------------------------------------------------------------------- - return port; -} +export { generateYamlHeader, generateYamlWithComments } from './loader/yaml-serializer'; +import { generateYamlHeader, generateYamlWithComments } from './loader/yaml-serializer'; -function normalizeBrowserPolicy(value: string | undefined): BrowserToolPolicy { - return value === 'auto' || value === 'manual' ? value : DEFAULT_BROWSER_CONFIG.claude.policy; -} +// --------------------------------------------------------------------------- +// Phase 4 re-exports: defaults-merger +// --------------------------------------------------------------------------- -function normalizeBrowserEvalMode(value: string | undefined): BrowserEvalMode { - if (value === 'disabled' || value === 'readonly' || value === 'readwrite') { - return value; - } +export { mergeWithDefaults } from './loader/defaults-merger'; +import { mergeWithDefaults } from './loader/defaults-merger'; - return DEFAULT_BROWSER_CONFIG.claude.eval_mode ?? 'readonly'; -} +// --------------------------------------------------------------------------- +// Phase 5 re-exports: config-getters +// --------------------------------------------------------------------------- -function canonicalizeBrowserConfig( - config?: BrowserConfig, - fallback: BrowserConfig = DEFAULT_BROWSER_CONFIG -): BrowserConfig { - const claudeUserDataDir = - config?.claude?.user_data_dir === undefined - ? fallback.claude.user_data_dir || getRecommendedBrowserUserDataDir() - : config.claude.user_data_dir.trim() || getRecommendedBrowserUserDataDir(); +export type { GeminiWebSearchInfo } from './loader/config-getters'; +export { + getWebSearchConfig, + getGlobalEnvConfig, + getContinuityInheritanceMap, + getCliproxySafetyConfig, + getThinkingConfig, + getOfficialChannelsConfig, + isDashboardAuthEnabled, + getDashboardAuthConfig, + getBrowserConfig, + getImageAnalysisConfig, + getLoggingConfig, + getCursorConfig, +} from './loader/config-getters'; - return { - claude: { - enabled: config?.claude?.enabled ?? fallback.claude.enabled, - policy: normalizeBrowserPolicy(config?.claude?.policy ?? fallback.claude.policy), - user_data_dir: claudeUserDataDir, - devtools_port: normalizeBrowserDevtoolsPort( - config?.claude?.devtools_port ?? fallback.claude.devtools_port - ), - eval_mode: normalizeBrowserEvalMode(config?.claude?.eval_mode ?? fallback.claude.eval_mode), - }, - codex: { - enabled: config?.codex?.enabled ?? fallback.codex.enabled, - policy: normalizeBrowserPolicy(config?.codex?.policy ?? fallback.codex.policy), - eval_mode: normalizeBrowserEvalMode(config?.codex?.eval_mode ?? fallback.codex.eval_mode), - }, - }; -} - -function normalizeSessionAffinityTtl(value: unknown, fallback: string): string { - if (typeof value !== 'string') { - return fallback; - } - - const trimmed = value.trim(); - if (!trimmed || !GO_DURATION_PATTERN.test(trimmed) || !hasPositiveDuration(trimmed)) { - return fallback; - } - - return trimmed; -} - -function hasPositiveDuration(value: string): boolean { - const segments = value.match(new RegExp(GO_DURATION_SEGMENT, 'g')); - if (!segments) { - return false; - } - - return segments.some((segment) => { - const numeric = parseFloat(segment); - return Number.isFinite(numeric) && numeric > 0; - }); -} - -/** - * Get path to unified config.yaml - */ -export function getConfigYamlPath(): string { - return path.join(getCcsDir(), CONFIG_YAML); -} - -/** - * Get path to legacy config.json - */ -export function getConfigJsonPath(): string { - return path.join(getCcsDir(), CONFIG_JSON); -} - -/** - * Get path to config lockfile - */ -function getLockFilePath(): string { - return path.join(getCcsDir(), CONFIG_LOCK); -} - -/** - * Acquire lockfile for config write operations. - * Returns a lock token if acquired, null if already locked by another process. - * Cleans up stale locks (older than LOCK_STALE_MS). - */ - -function acquireLock(): string | null { - const lockPath = getLockFilePath(); - const lockDir = path.dirname(lockPath); - const lockToken = crypto.randomUUID(); - const lockData = `${process.pid}\n${Date.now()}\n${lockToken}`; - - try { - if (!fs.existsSync(lockDir)) { - fs.mkdirSync(lockDir, { recursive: true, mode: 0o700 }); - } - - // Check if lock exists - if (fs.existsSync(lockPath)) { - const content = fs.readFileSync(lockPath, 'utf8'); - const [pidStr, timestampStr] = content.trim().split('\n'); - const pid = Number.parseInt(pidStr, 10); - const timestamp = Number.parseInt(timestampStr, 10); - const hasLiveOwner = Number.isInteger(pid) && pid > 0 && processExists(pid); - const isStale = !Number.isFinite(timestamp) || Date.now() - timestamp > LOCK_STALE_MS; - - if (hasLiveOwner) { - return null; - } - - if (isStale || !hasLiveOwner) { - fs.unlinkSync(lockPath); - } - } - - // Acquire lock - fs.writeFileSync(lockPath, lockData, { flag: 'wx', mode: 0o600 }); - return lockToken; - } catch (error) { - // EEXIST means another process acquired the lock between our check and write - if ((error as NodeJS.ErrnoException).code === 'EEXIST') { - return null; - } - return null; - } -} - -/** - * Release lockfile after config write operation. - */ -function releaseLock(lockToken: string): void { - const lockPath = getLockFilePath(); - try { - if (fs.existsSync(lockPath)) { - const content = fs.readFileSync(lockPath, 'utf8'); - const fileToken = content.trim().split('\n')[2]; - if (fileToken === lockToken) { - fs.unlinkSync(lockPath); - } - } - } catch { - // Ignore cleanup errors - } -} - -function processExists(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -/** - * Check if unified config.yaml exists - */ -export function hasUnifiedConfig(): boolean { - return fs.existsSync(getConfigYamlPath()); -} - -/** - * Check if legacy config.json exists - */ -export function hasLegacyConfig(): boolean { - return fs.existsSync(getConfigJsonPath()); -} +// --------------------------------------------------------------------------- +// getConfigFormat +// --------------------------------------------------------------------------- /** * Determine which config format is active. @@ -261,6 +123,10 @@ export function getConfigFormat(): 'yaml' | 'json' | 'none' { return 'none'; } +// --------------------------------------------------------------------------- +// Core load / save / mutate +// --------------------------------------------------------------------------- + /** * Load unified config from YAML file. * Returns null if file doesn't exist. @@ -269,7 +135,6 @@ export function getConfigFormat(): 'yaml' | 'json' | 'none' { export function loadUnifiedConfig(): UnifiedConfig | null { const yamlPath = getConfigYamlPath(); - // If file doesn't exist, return null if (!fs.existsSync(yamlPath)) { return null; } @@ -284,7 +149,6 @@ export function loadUnifiedConfig(): UnifiedConfig | null { // Auto-upgrade if version is outdated (regenerates YAML with new comments and fields) if ((parsed.version ?? 1) < UNIFIED_CONFIG_VERSION) { - // Merge with defaults to add new fields (e.g., model for websearch providers) const upgraded = mergeWithDefaults(parsed); upgraded.version = UNIFIED_CONFIG_VERSION; try { @@ -322,388 +186,6 @@ export function loadUnifiedConfig(): UnifiedConfig | null { } } -/** - * Validate composite variant provider strings. - * Warns about invalid providers in composite variant configurations. - */ -function validateCompositeVariants(config: UnifiedConfig): void { - const variants = config.cliproxy?.variants; - if (!variants) return; - - for (const [name, variant] of Object.entries(variants)) { - if ('type' in variant && variant.type === 'composite') { - const error = validateCompositeTiers(variant.tiers, { - defaultTier: variant.default_tier, - requireAllTiers: true, - }); - if (error) { - console.warn(`[!] Variant '${name}': invalid composite config (${error})`); - } - } - } -} - -/** - * Normalize continuity inheritance mapping payload. - * Keeps only non-empty string keys and values. - */ -function normalizeContinuityInheritanceMap(value: unknown): Record | undefined { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return undefined; - } - - const normalized: Record = {}; - for (const [profileName, accountName] of Object.entries(value as Record)) { - const normalizedProfile = profileName.trim(); - const normalizedAccount = typeof accountName === 'string' ? accountName.trim() : ''; - - if (!normalizedProfile || !normalizedAccount) { - continue; - } - - normalized[normalizedProfile] = normalizedAccount; - } - - return Object.keys(normalized).length > 0 ? normalized : undefined; -} - -/** - * Normalize continuity section. - * Supports legacy root key: continuity_inherit_from_account. - */ -function normalizeContinuityConfig(partial: Partial): ContinuityConfig | undefined { - const legacyMap = normalizeContinuityInheritanceMap( - (partial as Partial & { continuity_inherit_from_account?: unknown }) - .continuity_inherit_from_account - ); - const continuityMap = normalizeContinuityInheritanceMap(partial.continuity?.inherit_from_account); - - if (!legacyMap && !continuityMap) { - return undefined; - } - - return { - inherit_from_account: { - ...(legacyMap ?? {}), - ...(continuityMap ?? {}), - }, - }; -} - -interface LegacyDiscordChannelsConfig { - enabled?: boolean; - unattended?: boolean; -} - -function normalizeOfficialChannelsConfig( - partial: Partial & { discord_channels?: LegacyDiscordChannelsConfig } -): OfficialChannelsConfig { - const hasCanonicalChannelsSection = partial.channels !== undefined; - const hasExplicitSelectedField = - hasCanonicalChannelsSection && - Object.prototype.hasOwnProperty.call(partial.channels, 'selected'); - const rawSelected = - hasExplicitSelectedField && Array.isArray(partial.channels?.selected) - ? partial.channels.selected.filter((value): value is OfficialChannelId => - isOfficialChannelId(value) - ) - : []; - - return { - selected: hasCanonicalChannelsSection - ? normalizeOfficialChannelIds(rawSelected) - : resolveLegacyDiscordSelection(partial.discord_channels?.enabled), - unattended: - partial.channels?.unattended ?? - partial.discord_channels?.unattended ?? - DEFAULT_OFFICIAL_CHANNELS_CONFIG.unattended, - }; -} - -/** - * Merge partial config with defaults. - * Preserves existing data while filling in missing sections. - */ -function mergeWithDefaults(partial: Partial): UnifiedConfig { - const defaults = createEmptyUnifiedConfig(); - const continuity = normalizeContinuityConfig(partial); - return { - version: partial.version ?? defaults.version, - setup_completed: partial.setup_completed, - default: partial.default ?? defaults.default, - accounts: partial.accounts ?? defaults.accounts, - profiles: partial.profiles ?? defaults.profiles, - cliproxy: { - ...partial.cliproxy, - oauth_accounts: partial.cliproxy?.oauth_accounts ?? defaults.cliproxy.oauth_accounts, - providers: defaults.cliproxy.providers, // Always use defaults for providers - variants: partial.cliproxy?.variants ?? defaults.cliproxy.variants, - logging: { - enabled: partial.cliproxy?.logging?.enabled ?? defaults.cliproxy.logging?.enabled ?? false, - request_log: - partial.cliproxy?.logging?.request_log ?? defaults.cliproxy.logging?.request_log ?? false, - }, - safety: { - antigravity_ack_bypass: - partial.cliproxy?.safety?.antigravity_ack_bypass ?? - DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass, - }, - // Kiro browser behavior setting (optional) - kiro_no_incognito: partial.cliproxy?.kiro_no_incognito, - // Auth config - preserve user values, no defaults (uses constants as fallback) - auth: partial.cliproxy?.auth, - // Background token refresh config (optional) - token_refresh: partial.cliproxy?.token_refresh, - // Backend selection - validate and preserve user choice (original vs plus) - backend: - partial.cliproxy?.backend === 'original' || partial.cliproxy?.backend === 'plus' - ? partial.cliproxy.backend - : undefined, // Invalid values become undefined (defaults to 'original' at runtime) - // Auto-sync - default to true - auto_sync: partial.cliproxy?.auto_sync ?? defaults.cliproxy.auto_sync ?? true, - routing: { - strategy: - partial.cliproxy?.routing?.strategy === 'fill-first' || - partial.cliproxy?.routing?.strategy === 'round-robin' - ? partial.cliproxy.routing.strategy - : defaults.cliproxy.routing?.strategy, - session_affinity: - typeof partial.cliproxy?.routing?.session_affinity === 'boolean' - ? partial.cliproxy.routing.session_affinity - : defaults.cliproxy.routing?.session_affinity, - session_affinity_ttl: normalizeSessionAffinityTtl( - partial.cliproxy?.routing?.session_affinity_ttl, - defaults.cliproxy.routing?.session_affinity_ttl ?? '1h' - ), - }, - }, - proxy: { - port: partial.proxy?.port ?? DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.port, - profile_ports: partial.proxy?.profile_ports ?? { - ...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.profile_ports, - }, - routing: { - default: partial.proxy?.routing?.default ?? defaults.proxy?.routing?.default, - background: partial.proxy?.routing?.background ?? defaults.proxy?.routing?.background, - think: partial.proxy?.routing?.think ?? defaults.proxy?.routing?.think, - longContext: partial.proxy?.routing?.longContext ?? defaults.proxy?.routing?.longContext, - webSearch: partial.proxy?.routing?.webSearch ?? defaults.proxy?.routing?.webSearch, - longContextThreshold: - partial.proxy?.routing?.longContextThreshold ?? - defaults.proxy?.routing?.longContextThreshold, - }, - }, - logging: { - enabled: partial.logging?.enabled ?? DEFAULT_LOGGING_CONFIG.enabled, - level: partial.logging?.level ?? DEFAULT_LOGGING_CONFIG.level, - rotate_mb: partial.logging?.rotate_mb ?? DEFAULT_LOGGING_CONFIG.rotate_mb, - retain_days: partial.logging?.retain_days ?? DEFAULT_LOGGING_CONFIG.retain_days, - redact: partial.logging?.redact ?? DEFAULT_LOGGING_CONFIG.redact, - live_buffer_size: - partial.logging?.live_buffer_size ?? DEFAULT_LOGGING_CONFIG.live_buffer_size, - }, - preferences: { - ...defaults.preferences, - ...partial.preferences, - }, - websearch: { - enabled: partial.websearch?.enabled ?? defaults.websearch?.enabled ?? true, - providers: { - exa: { - enabled: partial.websearch?.providers?.exa?.enabled ?? false, - max_results: partial.websearch?.providers?.exa?.max_results ?? 5, - }, - tavily: { - enabled: partial.websearch?.providers?.tavily?.enabled ?? false, - max_results: partial.websearch?.providers?.tavily?.max_results ?? 5, - }, - brave: { - enabled: partial.websearch?.providers?.brave?.enabled ?? false, - max_results: partial.websearch?.providers?.brave?.max_results ?? 5, - }, - searxng: { - enabled: partial.websearch?.providers?.searxng?.enabled ?? false, - url: normalizeSearxngBaseUrl(partial.websearch?.providers?.searxng?.url) ?? '', - max_results: partial.websearch?.providers?.searxng?.max_results ?? 5, - }, - duckduckgo: { - enabled: partial.websearch?.providers?.duckduckgo?.enabled ?? true, - max_results: partial.websearch?.providers?.duckduckgo?.max_results ?? 5, - }, - gemini: { - enabled: - partial.websearch?.providers?.gemini?.enabled ?? - partial.websearch?.gemini?.enabled ?? // Legacy fallback - false, - model: partial.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash', - timeout: - partial.websearch?.providers?.gemini?.timeout ?? - partial.websearch?.gemini?.timeout ?? // Legacy fallback - 55, - }, - opencode: { - enabled: partial.websearch?.providers?.opencode?.enabled ?? false, - model: partial.websearch?.providers?.opencode?.model ?? 'opencode/grok-code', - timeout: partial.websearch?.providers?.opencode?.timeout ?? 90, - }, - grok: { - enabled: partial.websearch?.providers?.grok?.enabled ?? false, - timeout: partial.websearch?.providers?.grok?.timeout ?? 55, - }, - }, - // Legacy fields (keep for backwards compatibility during read) - gemini: partial.websearch?.gemini, - }, - // Copilot config - strictly opt-in, merge with defaults - copilot: { - enabled: partial.copilot?.enabled ?? DEFAULT_COPILOT_CONFIG.enabled, - auto_start: partial.copilot?.auto_start ?? DEFAULT_COPILOT_CONFIG.auto_start, - port: partial.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port, - account_type: partial.copilot?.account_type ?? DEFAULT_COPILOT_CONFIG.account_type, - rate_limit: partial.copilot?.rate_limit ?? DEFAULT_COPILOT_CONFIG.rate_limit, - wait_on_limit: partial.copilot?.wait_on_limit ?? DEFAULT_COPILOT_CONFIG.wait_on_limit, - model: partial.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model, - }, - // Cursor config - disabled by default, merge with defaults - cursor: { - enabled: partial.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled, - port: partial.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port, - auto_start: partial.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start, - ghost_mode: partial.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode, - model: partial.cursor?.model ?? DEFAULT_CURSOR_CONFIG.model, - opus_model: partial.cursor?.opus_model, - sonnet_model: partial.cursor?.sonnet_model, - haiku_model: partial.cursor?.haiku_model, - }, - // Global env - injected into all non-Claude subscription profiles - global_env: { - enabled: partial.global_env?.enabled ?? true, - env: partial.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV }, - }, - continuity, - // CLIProxy server config - remote/local CLIProxyAPI settings - cliproxy_server: { - remote: { - enabled: - partial.cliproxy_server?.remote?.enabled ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.enabled, - host: partial.cliproxy_server?.remote?.host ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.host, - // Port is optional - undefined means use protocol default (443 for HTTPS, 8317 for HTTP) - port: partial.cliproxy_server?.remote?.port, - protocol: - partial.cliproxy_server?.remote?.protocol ?? - DEFAULT_CLIPROXY_SERVER_CONFIG.remote.protocol, - auth_token: - partial.cliproxy_server?.remote?.auth_token ?? - DEFAULT_CLIPROXY_SERVER_CONFIG.remote.auth_token, - // management_key is optional - falls back to auth_token when not set - management_key: partial.cliproxy_server?.remote?.management_key, - }, - fallback: { - enabled: - partial.cliproxy_server?.fallback?.enabled ?? - DEFAULT_CLIPROXY_SERVER_CONFIG.fallback.enabled, - auto_start: - partial.cliproxy_server?.fallback?.auto_start ?? - DEFAULT_CLIPROXY_SERVER_CONFIG.fallback.auto_start, - }, - local: { - port: partial.cliproxy_server?.local?.port ?? DEFAULT_CLIPROXY_SERVER_CONFIG.local.port, - auto_start: - partial.cliproxy_server?.local?.auto_start ?? - DEFAULT_CLIPROXY_SERVER_CONFIG.local.auto_start, - }, - }, - // Quota management config - hybrid auto+manual account selection - quota_management: { - mode: partial.quota_management?.mode ?? DEFAULT_QUOTA_MANAGEMENT_CONFIG.mode, - auto: { - preflight_check: - partial.quota_management?.auto?.preflight_check ?? - DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.preflight_check, - exhaustion_threshold: - partial.quota_management?.auto?.exhaustion_threshold ?? - DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.exhaustion_threshold, - tier_priority: - partial.quota_management?.auto?.tier_priority ?? - DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.tier_priority, - cooldown_minutes: - partial.quota_management?.auto?.cooldown_minutes ?? - DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.cooldown_minutes, - }, - manual: { - paused_accounts: - partial.quota_management?.manual?.paused_accounts ?? - DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.paused_accounts, - forced_default: - partial.quota_management?.manual?.forced_default ?? - DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.forced_default, - tier_lock: - partial.quota_management?.manual?.tier_lock ?? - DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.tier_lock, - }, - runtime_monitor: { - enabled: - partial.quota_management?.runtime_monitor?.enabled ?? - DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.enabled, - normal_interval_seconds: - partial.quota_management?.runtime_monitor?.normal_interval_seconds ?? - DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.normal_interval_seconds, - critical_interval_seconds: - partial.quota_management?.runtime_monitor?.critical_interval_seconds ?? - DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.critical_interval_seconds, - warn_threshold: - partial.quota_management?.runtime_monitor?.warn_threshold ?? - DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.warn_threshold, - exhaustion_threshold: - partial.quota_management?.runtime_monitor?.exhaustion_threshold ?? - DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.exhaustion_threshold, - cooldown_minutes: - partial.quota_management?.runtime_monitor?.cooldown_minutes ?? - DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.cooldown_minutes, - }, - }, - // Thinking config - auto/manual/off control for reasoning budget - thinking: { - mode: partial.thinking?.mode ?? DEFAULT_THINKING_CONFIG.mode, - override: partial.thinking?.override, - tier_defaults: { - opus: partial.thinking?.tier_defaults?.opus ?? DEFAULT_THINKING_CONFIG.tier_defaults.opus, - sonnet: - partial.thinking?.tier_defaults?.sonnet ?? DEFAULT_THINKING_CONFIG.tier_defaults.sonnet, - haiku: - partial.thinking?.tier_defaults?.haiku ?? DEFAULT_THINKING_CONFIG.tier_defaults.haiku, - }, - provider_overrides: partial.thinking?.provider_overrides, - show_warnings: partial.thinking?.show_warnings ?? DEFAULT_THINKING_CONFIG.show_warnings, - }, - channels: normalizeOfficialChannelsConfig( - partial as Partial & { discord_channels?: LegacyDiscordChannelsConfig } - ), - // Dashboard auth config - disabled by default - dashboard_auth: { - enabled: partial.dashboard_auth?.enabled ?? DEFAULT_DASHBOARD_AUTH_CONFIG.enabled, - username: partial.dashboard_auth?.username ?? DEFAULT_DASHBOARD_AUTH_CONFIG.username, - password_hash: - partial.dashboard_auth?.password_hash ?? DEFAULT_DASHBOARD_AUTH_CONFIG.password_hash, - session_timeout_hours: - partial.dashboard_auth?.session_timeout_hours ?? - DEFAULT_DASHBOARD_AUTH_CONFIG.session_timeout_hours, - }, - browser: canonicalizeBrowserConfig(partial.browser), - // Image analysis config - enabled by default for CLIProxy providers - image_analysis: canonicalizeImageAnalysisConfig({ - enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled, - timeout: partial.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout, - provider_models: - partial.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models, - fallback_backend: - partial.image_analysis?.fallback_backend ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend, - profile_backends: - partial.image_analysis?.profile_backends ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.profile_backends, - }), - }; -} - /** * Load config, preferring YAML if available, falling back to creating empty config. * Merges with defaults to ensure all sections exist. @@ -711,464 +193,11 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { export function loadOrCreateUnifiedConfig(): UnifiedConfig { const existing = loadUnifiedConfig(); if (existing) { - // Merge with defaults to fill any missing sections const merged = mergeWithDefaults(existing); - // Validate composite variant provider strings validateCompositeVariants(merged); return merged; } - - // Create empty config - const config = createEmptyUnifiedConfig(); - return config; -} - -/** - * Generate YAML header with helpful comments. - */ -function generateYamlHeader(): string { - return `# CCS Unified Configuration -# Docs: https://github.com/kaitranntt/ccs -`; -} - -/** - * Generate YAML content with section comments for better readability. - */ -function generateYamlWithComments(config: UnifiedConfig): string { - const lines: string[] = []; - - // Version - lines.push(`version: ${config.version}`); - if (config.setup_completed !== undefined) { - lines.push(`setup_completed: ${config.setup_completed}`); - } - lines.push(''); - - // Default - if (config.default) { - lines.push(`# Default profile used when running 'ccs' without arguments`); - lines.push(`default: "${config.default}"`); - lines.push(''); - } - - // Accounts section - lines.push('# ----------------------------------------------------------------------------'); - lines.push('# Accounts: Isolated Claude instances (each with separate auth/sessions)'); - lines.push('# Manage with: ccs auth add , ccs auth list, ccs auth remove '); - lines.push('# ----------------------------------------------------------------------------'); - lines.push( - yaml.dump({ accounts: config.accounts }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() - ); - lines.push(''); - - // Profiles section - lines.push('# ----------------------------------------------------------------------------'); - lines.push('# Profiles: API-based providers (GLM, Kimi, custom endpoints)'); - lines.push('# Each profile points to a *.settings.json file containing env vars.'); - lines.push('# Edit the settings file directly to customize (ANTHROPIC_MAX_TOKENS, etc.)'); - lines.push('# ----------------------------------------------------------------------------'); - lines.push( - yaml.dump({ profiles: config.profiles }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() - ); - lines.push(''); - - // CLIProxy section - lines.push('# ----------------------------------------------------------------------------'); - lines.push('# CLIProxy: OAuth-based providers (gemini, codex, agy, qwen, iflow)'); - lines.push('# Each variant can reference a *.settings.json file for custom env vars.'); - lines.push('# Edit the settings file directly to customize model or other settings.'); - lines.push('# ----------------------------------------------------------------------------'); - lines.push( - yaml.dump({ cliproxy: config.cliproxy }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() - ); - lines.push(''); - - if (config.proxy?.routing) { - lines.push('# ----------------------------------------------------------------------------'); - lines.push('# Proxy Routing: OpenAI-compatible local proxy model selection rules'); - lines.push('# Use profile:model selectors to force a target profile and upstream model.'); - lines.push('# ----------------------------------------------------------------------------'); - lines.push( - yaml.dump({ proxy: config.proxy }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() - ); - lines.push(''); - } - - if (config.logging) { - lines.push('# ----------------------------------------------------------------------------'); - lines.push('# Logging: CCS-owned structured runtime logs'); - lines.push('# Current file: ~/.ccs/logs/current.jsonl'); - lines.push('# Archives rotate automatically and are pruned by retain_days.'); - lines.push('# This is separate from cliproxy.logging, which controls CLIProxy runtime files.'); - lines.push('# ----------------------------------------------------------------------------'); - lines.push( - yaml.dump({ logging: config.logging }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() - ); - lines.push(''); - } - - // CLIProxy Server section (remote proxy configuration) - placed right after cliproxy - if (config.cliproxy_server) { - lines.push('# ----------------------------------------------------------------------------'); - lines.push('# CLIProxy Server: Remote proxy connection settings'); - lines.push('# Configure via Dashboard (`ccs config`) > Proxy tab.'); - lines.push('#'); - lines.push('# remote: Connect to a remote CLIProxyAPI instance'); - lines.push('# fallback: Use local proxy if remote is unreachable'); - lines.push('# local: Local proxy settings (port, auto-start)'); - lines.push('# ----------------------------------------------------------------------------'); - lines.push( - yaml - .dump( - { cliproxy_server: config.cliproxy_server }, - { indent: 2, lineWidth: -1, quotingType: '"' } - ) - .trim() - ); - lines.push(''); - } - - // Preferences section - lines.push('# ----------------------------------------------------------------------------'); - lines.push('# Preferences: User settings'); - lines.push('# ----------------------------------------------------------------------------'); - lines.push( - yaml - .dump({ preferences: config.preferences }, { indent: 2, lineWidth: -1, quotingType: '"' }) - .trim() - ); - lines.push(''); - - // WebSearch section - if (config.websearch) { - lines.push('# ----------------------------------------------------------------------------'); - lines.push('# WebSearch: real search backends for third-party profiles'); - lines.push('# Dashboard (`ccs config`) is the source of truth for provider selection.'); - lines.push('#'); - lines.push('# Third-party providers (gemini, codex, agy, etc.) do not have access to'); - lines.push("# Anthropic's WebSearch tool. CCS intercepts that tool and runs local search."); - lines.push('#'); - lines.push( - '# Priority: Exa -> Tavily -> Brave -> DuckDuckGo -> optional legacy AI CLI fallbacks' - ); - lines.push('#'); - lines.push('# Exa requires EXA_API_KEY in your environment.'); - lines.push('# Tavily requires TAVILY_API_KEY in your environment.'); - lines.push('# Brave requires BRAVE_API_KEY in your environment.'); - lines.push('# DuckDuckGo works with zero extra setup and is enabled by default.'); - lines.push('#'); - lines.push('# Legacy LLM fallbacks remain optional if you still want them:'); - lines.push('# gemini: npm i -g @google/gemini-cli'); - lines.push('# opencode: curl -fsSL https://opencode.ai/install | bash'); - lines.push('# grok: npm i -g @vibe-kit/grok-cli'); - lines.push('# ----------------------------------------------------------------------------'); - lines.push( - yaml - .dump({ websearch: config.websearch }, { indent: 2, lineWidth: -1, quotingType: '"' }) - .trim() - ); - lines.push(''); - } - - // Copilot section (GitHub Copilot proxy) - if (config.copilot) { - lines.push('# ----------------------------------------------------------------------------'); - lines.push('# Copilot: GitHub Copilot API proxy (via copilot-api)'); - lines.push('# Uses your existing GitHub Copilot subscription with Claude Code.'); - lines.push('#'); - lines.push('# !! DISCLAIMER - USE AT YOUR OWN RISK !!'); - lines.push('# This uses an UNOFFICIAL reverse-engineered API.'); - lines.push('# Excessive usage may trigger GitHub account restrictions.'); - lines.push('# CCS provides NO WARRANTY and accepts NO RESPONSIBILITY for consequences.'); - lines.push('#'); - lines.push('# Setup: npx copilot-api auth (authenticate with GitHub)'); - lines.push('# Usage: ccs copilot (switch to copilot profile)'); - lines.push('#'); - lines.push('# Models: claude-sonnet-4.5, claude-opus-4.5, gpt-5.1, gemini-2.5-pro'); - lines.push('# Account types: individual, business, enterprise'); - lines.push('# ----------------------------------------------------------------------------'); - lines.push( - yaml.dump({ copilot: config.copilot }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() - ); - lines.push(''); - } - - // Cursor section (Cursor IDE proxy daemon) - if (config.cursor) { - lines.push('# ----------------------------------------------------------------------------'); - lines.push('# Cursor: Cursor IDE proxy daemon'); - lines.push('# Enables Cursor IDE integration via local proxy daemon.'); - lines.push('#'); - lines.push('# enabled: Enable/disable Cursor integration (default: false)'); - lines.push('# port: Port for cursor proxy daemon (default: 20129)'); - lines.push('# auto_start: Auto-start daemon when CCS starts (default: false)'); - lines.push('# ghost_mode: Disable telemetry for privacy (default: true)'); - lines.push('# model: Default model ID (used for ANTHROPIC_MODEL)'); - lines.push('# opus_model/sonnet_model/haiku_model: Optional tier model mapping'); - lines.push('# ----------------------------------------------------------------------------'); - lines.push( - yaml.dump({ cursor: config.cursor }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() - ); - lines.push(''); - } - - // Global env section - if (config.global_env) { - lines.push('# ----------------------------------------------------------------------------'); - lines.push( - '# Global Environment Variables: Injected into all non-Claude subscription profiles' - ); - lines.push('# These env vars disable telemetry/reporting for third-party providers.'); - lines.push('# Configure via Dashboard (`ccs config`) > Global Env tab.'); - lines.push('#'); - lines.push('# Default variables:'); - lines.push('# DISABLE_BUG_COMMAND: Disables /bug command (not supported by proxy)'); - lines.push('# DISABLE_ERROR_REPORTING: Disables error reporting to Anthropic'); - lines.push('# DISABLE_TELEMETRY: Disables usage telemetry'); - lines.push('# ----------------------------------------------------------------------------'); - lines.push( - yaml - .dump({ global_env: config.global_env }, { indent: 2, lineWidth: -1, quotingType: '"' }) - .trim() - ); - lines.push(''); - } - - // Continuity inheritance section - if (config.continuity?.inherit_from_account) { - lines.push('# ----------------------------------------------------------------------------'); - lines.push('# Continuity Inheritance: Reuse account continuity artifacts across profiles'); - lines.push('# Map execution profile names to source account profiles (CLAUDE_CONFIG_DIR).'); - lines.push('# Applies to Claude target only; credentials remain profile-specific.'); - lines.push('# Example: continuity.inherit_from_account.glm: pro'); - lines.push('# ----------------------------------------------------------------------------'); - lines.push( - yaml - .dump({ continuity: config.continuity }, { indent: 2, lineWidth: -1, quotingType: '"' }) - .trim() - ); - lines.push(''); - } - - // Thinking section (extended thinking/reasoning configuration) - if (config.thinking) { - lines.push('# ----------------------------------------------------------------------------'); - lines.push('# Thinking: Extended thinking/reasoning budget configuration'); - lines.push('# Controls reasoning depth for supported providers (agy, gemini, codex).'); - lines.push('#'); - lines.push( - '# Modes: auto (use tier_defaults), off (disable), manual (--thinking/--effort flags)' - ); - lines.push( - '# Levels: minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), max (adaptive ceiling), auto' - ); - lines.push('# Override: Set global override value (number or level name)'); - lines.push('# Provider overrides: Per-provider tier defaults'); - lines.push('# ----------------------------------------------------------------------------'); - lines.push( - yaml - .dump({ thinking: config.thinking }, { indent: 2, lineWidth: -1, quotingType: '"' }) - .trim() - ); - lines.push(''); - } - - // Official Channels section - if (config.channels) { - lines.push('# ----------------------------------------------------------------------------'); - lines.push('# Official Channels: Runtime auto-enable for Anthropic official channel plugins'); - lines.push('# Supported channels: telegram, discord, imessage'); - lines.push('# Runtime-only: CCS injects --channels at launch for compatible Claude sessions.'); - lines.push('# Bot tokens live in Claude channel env files, not in config.yaml.'); - lines.push('# Use selected: [telegram, discord, imessage] to choose channels.'); - lines.push( - '# unattended adds --dangerously-skip-permissions only when channel auto-enable is active.' - ); - lines.push('# Compatible sessions: native Claude default/account profiles only.'); - lines.push('# Configure via: ccs config channels or the Settings > Channels dashboard tab.'); - lines.push('# ----------------------------------------------------------------------------'); - lines.push( - yaml - .dump({ channels: config.channels }, { indent: 2, lineWidth: -1, quotingType: '"' }) - .trim() - ); - lines.push(''); - } - - // Dashboard auth section (only if configured) - if (config.dashboard_auth?.enabled) { - lines.push('# ----------------------------------------------------------------------------'); - lines.push('# Dashboard Auth: Optional login protection for CCS dashboard'); - lines.push('# Generate password hash: npx bcrypt-cli hash "your-password"'); - lines.push( - '# ENV override: CCS_DASHBOARD_AUTH_ENABLED, CCS_DASHBOARD_USERNAME, CCS_DASHBOARD_PASSWORD_HASH' - ); - lines.push('# ----------------------------------------------------------------------------'); - lines.push( - yaml - .dump( - { dashboard_auth: config.dashboard_auth }, - { indent: 2, lineWidth: -1, quotingType: '"' } - ) - .trim() - ); - lines.push(''); - } - - // Browser automation section - if (config.browser) { - lines.push('# ----------------------------------------------------------------------------'); - lines.push('# Browser Automation: Claude browser attach and Codex browser tooling'); - lines.push('# Claude attach reuses a running Chrome/Chromium session with remote debugging.'); - lines.push('# Codex tooling controls whether CCS injects Playwright MCP overrides.'); - lines.push('#'); - lines.push('# claude.user_data_dir should point at the Chrome user-data directory for the'); - lines.push('# dedicated attach session. claude.devtools_port is the expected debugging port.'); - lines.push('# Configure via: Settings > Browser or `ccs browser ...`.'); - lines.push('# ----------------------------------------------------------------------------'); - lines.push( - yaml.dump({ browser: config.browser }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() - ); - lines.push(''); - } - - // Image analysis section - if (config.image_analysis) { - lines.push('# ----------------------------------------------------------------------------'); - lines.push('# Image Analysis: Vision-based analysis for images and PDFs'); - lines.push('# Routes Read tool requests for images/PDFs through CLIProxy vision API.'); - lines.push('#'); - lines.push('# When enabled: Image files trigger vision analysis instead of raw file read'); - lines.push('# Provider models: Vision model used for each CLIProxy provider'); - lines.push('# Timeout: Maximum seconds to wait for analysis (10-600)'); - lines.push('#'); - lines.push('# Supported formats: .jpg, .jpeg, .png, .gif, .webp, .heic, .bmp, .tiff, .pdf'); - lines.push('# Configure via: ccs config image-analysis'); - lines.push('# ----------------------------------------------------------------------------'); - lines.push( - yaml - .dump( - { image_analysis: config.image_analysis }, - { indent: 2, lineWidth: -1, quotingType: '"' } - ) - .trim() - ); - lines.push(''); - } - - return lines.join('\n'); -} - -/** - * Sync sleep helper for lock retry loops. - * Uses Atomics.wait when available to avoid CPU-intensive busy-wait. - */ -function sleepSync(ms: number): void { - try { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); - } catch { - const end = Date.now() + ms; - while (Date.now() < end) { - /* busy-wait */ - } - } -} - -/** - * Execute a callback while holding the config lock. - */ -function withConfigWriteLock(callback: () => T): T { - // Acquire lock (retry for up to 1 second) - const maxRetries = 10; - const retryDelayMs = 100; - let lockToken: string | null = null; - for (let i = 0; i < maxRetries; i++) { - const acquiredToken = acquireLock(); - if (acquiredToken) { - lockToken = acquiredToken; - break; - } - sleepSync(retryDelayMs); - } - - if (!lockToken) { - throw new Error('Config file is locked by another process. Wait a moment and try again.'); - } - - try { - return callback(); - } finally { - // Always release lock - releaseLock(lockToken); - } -} - -/** - * Load unified config directly from disk while lock is already held. - * Falls back to empty config when file doesn't exist. - */ -function loadUnifiedConfigWithLockHeld(): UnifiedConfig { - const yamlPath = getConfigYamlPath(); - if (!fs.existsSync(yamlPath)) { - return createEmptyUnifiedConfig(); - } - - const content = fs.readFileSync(yamlPath, 'utf8'); - const parsed = yaml.load(content); - - if (!isUnifiedConfig(parsed)) { - throw new Error(`Invalid config format in ${yamlPath}`); - } - - const merged = mergeWithDefaults(parsed); - validateCompositeVariants(merged); - return merged; -} - -/** - * Write unified config to disk while lock is already held. - */ -function writeUnifiedConfigWithLockHeld(config: UnifiedConfig): void { - const yamlPath = getConfigYamlPath(); - const dir = path.dirname(yamlPath); - - // Ensure directory exists - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - - // Ensure version is set - config.version = UNIFIED_CONFIG_VERSION; - - // Generate YAML with section comments - const yamlContent = generateYamlWithComments(config); - const content = generateYamlHeader() + yamlContent; - - // Atomic write: write to temp file, then rename - const tempPath = `${yamlPath}.tmp.${process.pid}`; - - try { - fs.writeFileSync(tempPath, content, { mode: 0o600 }); - fs.renameSync(tempPath, yamlPath); - } catch (error) { - // Clean up temp file on error - if (fs.existsSync(tempPath)) { - try { - fs.unlinkSync(tempPath); - } catch { - // Ignore cleanup errors - } - } - // Classify filesystem errors - const err = error as NodeJS.ErrnoException; - if (err.code === 'ENOSPC') { - throw new Error('Disk full - cannot save config. Free up space and try again.'); - } else if (err.code === 'EROFS' || err.code === 'EACCES') { - throw new Error(`Cannot write config - check file permissions: ${err.message}`); - } - throw error; - } + return createEmptyUnifiedConfig(); } /** @@ -1178,7 +207,7 @@ function writeUnifiedConfigWithLockHeld(config: UnifiedConfig): void { */ export function saveUnifiedConfig(config: UnifiedConfig): void { withConfigWriteLock(() => { - writeUnifiedConfigWithLockHeld(config); + writeUnifiedConfigWithLockHeld(config, generateYamlHeader, generateYamlWithComments); }); } @@ -1188,7 +217,7 @@ export function saveUnifiedConfig(config: UnifiedConfig): void { */ export function mutateUnifiedConfig(mutator: (config: UnifiedConfig) => void): UnifiedConfig { return withConfigWriteLock(() => { - const current = loadUnifiedConfigWithLockHeld(); + const current = loadUnifiedConfigWithLockHeld(mergeWithDefaults, validateCompositeVariants); const previousBrowser = current.browser ? canonicalizeBrowserConfig(current.browser) : undefined; @@ -1196,7 +225,7 @@ export function mutateUnifiedConfig(mutator: (config: UnifiedConfig) => void): U if (current.browser) { current.browser = canonicalizeBrowserConfig(current.browser, previousBrowser); } - writeUnifiedConfigWithLockHeld(current); + writeUnifiedConfigWithLockHeld(current, generateYamlHeader, generateYamlWithComments); return current; }); } @@ -1211,298 +240,29 @@ export function updateUnifiedConfig(updates: Partial): UnifiedCon }); } +// --------------------------------------------------------------------------- +// Derived helpers +// --------------------------------------------------------------------------- + /** * Check if unified config mode is active. * Returns true if config.yaml exists OR CCS_UNIFIED_CONFIG=1. - * - * Use this centralized function instead of duplicating the logic. */ export function isUnifiedMode(): boolean { return hasUnifiedConfig() || isUnifiedConfigEnabled(); } /** - * Get or set default profile name. + * Get default profile name from config. */ export function getDefaultProfile(): string | undefined { const config = loadUnifiedConfig(); return config?.default; } +/** + * Set default profile name in config. + */ export function setDefaultProfile(name: string): void { updateUnifiedConfig({ default: name }); } - -/** - * Gemini CLI WebSearch configuration - */ -export interface GeminiWebSearchInfo { - enabled: boolean; - model: string; - timeout: number; -} - -/** - * Get websearch configuration. - * Returns defaults if not configured. - * Supports deterministic providers and optional Gemini/OpenCode/Grok CLI fallbacks. - */ -export function getWebSearchConfig(): { - enabled: boolean; - providers?: { - exa?: { enabled?: boolean; max_results?: number }; - tavily?: { enabled?: boolean; max_results?: number }; - brave?: { enabled?: boolean; max_results?: number }; - searxng?: { enabled?: boolean; url?: string; max_results?: number }; - duckduckgo?: { enabled?: boolean; max_results?: number }; - gemini?: GeminiWebSearchInfo; - opencode?: { enabled?: boolean; model?: string; timeout?: number }; - grok?: { enabled?: boolean; timeout?: number }; - }; - // Legacy fields (deprecated) - gemini?: { enabled?: boolean; timeout?: number }; -} { - const config = loadOrCreateUnifiedConfig(); - - // Build provider configs - const exaConfig = { - enabled: config.websearch?.providers?.exa?.enabled ?? false, - max_results: config.websearch?.providers?.exa?.max_results ?? 5, - }; - - const tavilyConfig = { - enabled: config.websearch?.providers?.tavily?.enabled ?? false, - max_results: config.websearch?.providers?.tavily?.max_results ?? 5, - }; - - const duckDuckGoConfig = { - enabled: config.websearch?.providers?.duckduckgo?.enabled ?? true, - max_results: config.websearch?.providers?.duckduckgo?.max_results ?? 5, - }; - - const braveConfig = { - enabled: config.websearch?.providers?.brave?.enabled ?? false, - max_results: config.websearch?.providers?.brave?.max_results ?? 5, - }; - - const searxngConfig = { - enabled: config.websearch?.providers?.searxng?.enabled ?? false, - url: normalizeSearxngBaseUrl(config.websearch?.providers?.searxng?.url) ?? '', - max_results: config.websearch?.providers?.searxng?.max_results ?? 5, - }; - - const geminiConfig: GeminiWebSearchInfo = { - enabled: - config.websearch?.providers?.gemini?.enabled ?? config.websearch?.gemini?.enabled ?? false, - model: config.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash', - timeout: - config.websearch?.providers?.gemini?.timeout ?? config.websearch?.gemini?.timeout ?? 55, - }; - - const opencodeConfig = { - enabled: config.websearch?.providers?.opencode?.enabled ?? false, - model: config.websearch?.providers?.opencode?.model ?? 'opencode/grok-code', - timeout: config.websearch?.providers?.opencode?.timeout ?? 90, - }; - - const grokConfig = { - enabled: config.websearch?.providers?.grok?.enabled ?? false, - timeout: config.websearch?.providers?.grok?.timeout ?? 55, - }; - - // Auto-enable master switch if ANY provider is enabled - const anyProviderEnabled = - exaConfig.enabled || - tavilyConfig.enabled || - braveConfig.enabled || - searxngConfig.enabled || - duckDuckGoConfig.enabled || - geminiConfig.enabled || - opencodeConfig.enabled || - grokConfig.enabled; - const enabled = anyProviderEnabled && (config.websearch?.enabled ?? true); - - return { - enabled, - providers: { - exa: exaConfig, - tavily: tavilyConfig, - brave: braveConfig, - searxng: searxngConfig, - duckduckgo: duckDuckGoConfig, - gemini: geminiConfig, - opencode: opencodeConfig, - grok: grokConfig, - }, - // Legacy field for backwards compatibility - gemini: config.websearch?.gemini, - }; -} - -/** - * Get global_env configuration. - * Returns defaults if not configured. - */ -export function getGlobalEnvConfig(): GlobalEnvConfig { - const config = loadOrCreateUnifiedConfig(); - return { - enabled: config.global_env?.enabled ?? true, - env: config.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV }, - }; -} - -/** - * Get continuity inheritance mapping. - * Returns empty mapping when not configured. - */ -export function getContinuityInheritanceMap(): Record { - const config = loadOrCreateUnifiedConfig(); - return config.continuity?.inherit_from_account ?? {}; -} - -/** - * Get cliproxy safety configuration. - * Returns defaults if not configured. - */ -export function getCliproxySafetyConfig(): CLIProxySafetyConfig { - const config = loadOrCreateUnifiedConfig(); - return { - antigravity_ack_bypass: - config.cliproxy?.safety?.antigravity_ack_bypass ?? - DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass, - }; -} - -/** - * Get thinking configuration. - * Returns defaults if not configured. - */ -export function getThinkingConfig(): ThinkingConfig { - const config = loadOrCreateUnifiedConfig(); - - // W2: Check for invalid thinking config (e.g., thinking: true instead of object) - if (config.thinking !== undefined && typeof config.thinking !== 'object') { - console.warn( - `[!] Invalid thinking config: expected object, got ${typeof config.thinking}. Using defaults.` - ); - console.warn(` Tip: Use 'thinking: { mode: auto }' instead of 'thinking: true'`); - return DEFAULT_THINKING_CONFIG; - } - - return { - mode: config.thinking?.mode ?? DEFAULT_THINKING_CONFIG.mode, - override: config.thinking?.override, - tier_defaults: { - opus: config.thinking?.tier_defaults?.opus ?? DEFAULT_THINKING_CONFIG.tier_defaults.opus, - sonnet: - config.thinking?.tier_defaults?.sonnet ?? DEFAULT_THINKING_CONFIG.tier_defaults.sonnet, - haiku: config.thinking?.tier_defaults?.haiku ?? DEFAULT_THINKING_CONFIG.tier_defaults.haiku, - }, - provider_overrides: config.thinking?.provider_overrides, - show_warnings: config.thinking?.show_warnings ?? DEFAULT_THINKING_CONFIG.show_warnings, - }; -} - -/** - * Get Official Channels configuration. - * Returns defaults if not configured. - */ -export function getOfficialChannelsConfig(): OfficialChannelsConfig { - const config = loadOrCreateUnifiedConfig(); - - return { - selected: - config.channels?.selected && config.channels.selected.length > 0 - ? normalizeOfficialChannelIds(config.channels.selected) - : DEFAULT_OFFICIAL_CHANNELS_CONFIG.selected, - unattended: config.channels?.unattended ?? DEFAULT_OFFICIAL_CHANNELS_CONFIG.unattended, - }; -} - -/** - * Get dashboard_auth configuration with ENV var override. - * Priority: ENV vars > config.yaml > defaults - */ -export function isDashboardAuthEnabled(): boolean { - const envEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; - - if (envEnabled !== undefined) { - return envEnabled === 'true' || envEnabled === '1'; - } - - const config = loadOrCreateUnifiedConfig(); - return config.dashboard_auth?.enabled ?? false; -} - -/** - * Get dashboard_auth configuration with ENV var override. - * Priority: ENV vars > config.yaml > defaults - */ -export function getDashboardAuthConfig(): DashboardAuthConfig { - const config = loadOrCreateUnifiedConfig(); - - // ENV vars take precedence - const envEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; - const envUsername = process.env.CCS_DASHBOARD_USERNAME; - const envPasswordHash = process.env.CCS_DASHBOARD_PASSWORD_HASH; - - return { - enabled: - envEnabled !== undefined - ? envEnabled === 'true' || envEnabled === '1' - : (config.dashboard_auth?.enabled ?? false), - username: envUsername ?? config.dashboard_auth?.username ?? '', - password_hash: envPasswordHash ?? config.dashboard_auth?.password_hash ?? '', - session_timeout_hours: config.dashboard_auth?.session_timeout_hours ?? 24, - }; -} - -/** - * Get browser automation configuration. - * Returns canonicalized defaults if not configured. - */ -export function getBrowserConfig(): BrowserConfig { - const config = loadOrCreateUnifiedConfig(); - return canonicalizeBrowserConfig(config.browser); -} - -/** - * Get image_analysis configuration. - * Returns defaults if not configured. - */ -export function getImageAnalysisConfig(): ImageAnalysisConfig { - const config = loadOrCreateUnifiedConfig(); - - return canonicalizeImageAnalysisConfig({ - enabled: config.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled, - timeout: config.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout, - provider_models: - config.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models, - fallback_backend: - config.image_analysis?.fallback_backend ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend, - profile_backends: - config.image_analysis?.profile_backends ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.profile_backends, - }); -} - -export function getLoggingConfig(): LoggingConfig { - const config = loadOrCreateUnifiedConfig(); - - return { - enabled: config.logging?.enabled ?? DEFAULT_LOGGING_CONFIG.enabled, - level: config.logging?.level ?? DEFAULT_LOGGING_CONFIG.level, - rotate_mb: config.logging?.rotate_mb ?? DEFAULT_LOGGING_CONFIG.rotate_mb, - retain_days: config.logging?.retain_days ?? DEFAULT_LOGGING_CONFIG.retain_days, - redact: config.logging?.redact ?? DEFAULT_LOGGING_CONFIG.redact, - live_buffer_size: config.logging?.live_buffer_size ?? DEFAULT_LOGGING_CONFIG.live_buffer_size, - }; -} - -/** - * Get cursor configuration. - * Returns defaults if not configured. - */ -export function getCursorConfig(): CursorConfig { - const config = loadOrCreateUnifiedConfig(); - return config.cursor ?? { ...DEFAULT_CURSOR_CONFIG }; -} diff --git a/src/copilot/copilot-daemon.ts b/src/copilot/copilot-daemon.ts index 9214f02f..0575d233 100644 --- a/src/copilot/copilot-daemon.ts +++ b/src/copilot/copilot-daemon.ts @@ -11,10 +11,11 @@ import * as path from 'path'; import * as http from 'http'; import { CopilotDaemonStatus } from './types'; import { CopilotConfig, DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types'; -import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; + import { getCopilotDir, getCopilotApiBinPath } from './copilot-package-manager'; import { verifyProcessOwnership } from '../cursor/daemon-process-ownership'; import { createLogger } from '../services/logging'; +import { loadOrCreateUnifiedConfig } from '../config/config-loader-facade'; const logger = createLogger('copilot:daemon'); diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index b7d96962..ea36d9b0 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -7,7 +7,7 @@ import { spawn } from 'child_process'; import { CopilotConfig } from '../config/unified-config-types'; -import { getGlobalEnvConfig } from '../config/unified-config-loader'; + import { ensureCliproxyService } from '../cliproxy'; import { getEffectiveApiKey } from '../cliproxy/auth/auth-token-manager'; import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; @@ -36,6 +36,7 @@ import { } from '../utils/hooks'; import { stripClaudeCodeEnv } from '../utils/shell-executor'; import { createLogger } from '../services/logging'; +import { getGlobalEnvConfig } from '../config/config-loader-facade'; const logger = createLogger('copilot:executor'); diff --git a/src/copilot/copilot-package-manager.ts b/src/copilot/copilot-package-manager.ts index e5c72fba..ebebb6d2 100644 --- a/src/copilot/copilot-package-manager.ts +++ b/src/copilot/copilot-package-manager.ts @@ -15,7 +15,7 @@ import * as path from 'path'; import { spawn, spawnSync } from 'child_process'; import { ProgressIndicator } from '../utils/progress-indicator'; import { ok, info } from '../utils/ui'; -import { getCcsDir } from '../utils/config-manager'; +import { getCcsDir } from '../config/config-loader-facade'; /** Cache duration for version check (1 hour in milliseconds) */ const VERSION_CACHE_DURATION_MS = 60 * 60 * 1000; diff --git a/src/cursor/cursor-auth.ts b/src/cursor/cursor-auth.ts index 313ec2a8..25284876 100644 --- a/src/cursor/cursor-auth.ts +++ b/src/cursor/cursor-auth.ts @@ -19,7 +19,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import type { CursorCredentials, CursorAuthStatus, AutoDetectResult } from './types'; -import { getCcsDir } from '../utils/config-manager'; +import { getCcsDir } from '../config/config-loader-facade'; const ACCESS_TOKEN_KEYS = ['cursorAuth/accessToken', 'cursorAuth/token'] as const; const MACHINE_ID_KEYS = [ diff --git a/src/cursor/cursor-daemon-pid.ts b/src/cursor/cursor-daemon-pid.ts index fceef47a..ceb855d5 100644 --- a/src/cursor/cursor-daemon-pid.ts +++ b/src/cursor/cursor-daemon-pid.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import { getCcsDir } from '../utils/config-manager'; +import { getCcsDir } from '../config/config-loader-facade'; function getCursorDir(): string { return path.join(getCcsDir(), 'cursor'); diff --git a/src/cursor/cursor-profile-executor.ts b/src/cursor/cursor-profile-executor.ts index 1a1b239f..293bce24 100644 --- a/src/cursor/cursor-profile-executor.ts +++ b/src/cursor/cursor-profile-executor.ts @@ -1,7 +1,7 @@ import { spawn } from 'child_process'; import type { CursorConfig } from '../config/unified-config-types'; -import { getGlobalEnvConfig } from '../config/unified-config-loader'; + import { ensureCliproxyService } from '../cliproxy'; import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; import { fail, info, ok } from '../utils/ui'; @@ -15,6 +15,7 @@ import { getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus } from '../u import { stripClaudeCodeEnv } from '../utils/shell-executor'; import { checkAuthStatus } from './cursor-auth'; import { isDaemonRunning, startDaemon } from './cursor-daemon'; +import { getGlobalEnvConfig } from '../config/config-loader-facade'; interface CursorImageAnalysisResolution { env: Record; diff --git a/src/delegation/delegation-handler.ts b/src/delegation/delegation-handler.ts index 79870271..2716bfcb 100644 --- a/src/delegation/delegation-handler.ts +++ b/src/delegation/delegation-handler.ts @@ -6,7 +6,7 @@ import { ResultFormatter } from './result-formatter'; import { DelegationValidator } from '../utils/delegation-validator'; import { SettingsParser } from './settings-parser'; import { fail, warn } from '../utils/ui'; -import { getCcsDir } from '../utils/config-manager'; +import { getCcsDir } from '../config/config-loader-facade'; const PROFILE_FLAGS_WITH_VALUE = new Set(['-p', '--prompt', '--effort']); diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 26e2760c..cb97bb32 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -15,8 +15,8 @@ import { ui, warn, info } from '../utils/ui'; import { type ExecutionOptions, type ExecutionResult, type StreamMessage } from './executor/types'; import { StreamBuffer, formatToolVerbose } from './executor/stream-parser'; import { buildExecutionResult } from './executor/result-aggregator'; -import { getCcsDir, getModelDisplayName, loadSettings } from '../utils/config-manager'; -import { getGlobalEnvConfig } from '../config/unified-config-loader'; +import { getModelDisplayName } from '../utils/config-manager'; + import { getProfileLookupCandidates } from '../utils/profile-compat'; import { getClaudeLaunchEnvOverrides, @@ -58,6 +58,7 @@ import { readWebSearchTraceRecords, syncWebSearchMcpToConfigDir, } from '../utils/websearch-manager'; +import { getCcsDir, getGlobalEnvConfig, loadSettings } from '../config/config-loader-facade'; // Re-export types for consumers export type { ExecutionOptions, ExecutionResult, StreamMessage } from './executor/types'; diff --git a/src/delegation/session-manager.ts b/src/delegation/session-manager.ts index c407daad..79d94438 100644 --- a/src/delegation/session-manager.ts +++ b/src/delegation/session-manager.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { getCcsDir } from '../utils/config-manager'; +import { getCcsDir } from '../config/config-loader-facade'; interface SessionData { sessionId: string; diff --git a/src/dispatcher/__tests__/cli-argument-parser-bootstrap.test.ts b/src/dispatcher/__tests__/cli-argument-parser-bootstrap.test.ts new file mode 100644 index 00000000..7372c1f9 --- /dev/null +++ b/src/dispatcher/__tests__/cli-argument-parser-bootstrap.test.ts @@ -0,0 +1,152 @@ +/** + * Tests for bootstrapAndParseEarlyCli() — Phase A extraction. + * + * Uses a real temp dir for --config-dir validation tests. + * Mocks process.exit to assert error paths without terminating the test runner. + */ + +import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; +import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; +import { bootstrapAndParseEarlyCli } from '../cli-argument-parser'; + +// ========== Helpers ========== + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bootstrap-test-')); +} + +function cleanupDir(dir: string): void { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // best-effort + } +} + +// ========== Tests ========== + +describe('bootstrapAndParseEarlyCli', () => { + let originalArgv: string[]; + let originalStdoutIsTTY: boolean | undefined; + let exitSpy: ReturnType; + + beforeEach(() => { + originalArgv = process.argv.slice(); + originalStdoutIsTTY = process.stdout.isTTY; + // Non-TTY so initUI and update checks don't fire + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + // Suppress CI to ensure TTY branches are testable separately + process.env['CI'] = '1'; + + // Mock process.exit so error paths don't terminate test runner + exitSpy = spyOn(process, 'exit').mockImplementation((_code?: number | string) => { + throw new Error(`process.exit(${_code})`); + }); + }); + + afterEach(() => { + process.argv = originalArgv; + Object.defineProperty(process.stdout, 'isTTY', { + value: originalStdoutIsTTY, + configurable: true, + }); + delete process.env['CI']; + exitSpy.mockRestore(); + }); + + // ---------- completion command ---------- + + it('returns isCompletionCommand=true and exitNow=true for __complete args', async () => { + const result = await bootstrapAndParseEarlyCli(['__complete', 'some', 'arg']); + expect(result.isCompletionCommand).toBe(true); + expect(result.exitNow).toBe(true); + }); + + // ---------- --config-dir flag ---------- + + it('accepts a valid --config-dir and strips it from args', async () => { + const tmpDir = makeTempDir(); + try { + const result = await bootstrapAndParseEarlyCli(['--config-dir', tmpDir, 'gemini']); + expect(result.exitNow).toBe(false); + // --config-dir and its value must be stripped + expect(result.args).not.toContain('--config-dir'); + expect(result.args).not.toContain(tmpDir); + expect(result.args).toContain('gemini'); + } finally { + cleanupDir(tmpDir); + } + }); + + it('accepts --config-dir= (= syntax) and strips it from args', async () => { + const tmpDir = makeTempDir(); + try { + const result = await bootstrapAndParseEarlyCli([`--config-dir=${tmpDir}`, 'gemini']); + expect(result.exitNow).toBe(false); + expect(result.args.some((a) => a.startsWith('--config-dir'))).toBe(false); + expect(result.args).toContain('gemini'); + } finally { + cleanupDir(tmpDir); + } + }); + + it('calls process.exit when --config-dir has no value', async () => { + await expect(bootstrapAndParseEarlyCli(['--config-dir'])).rejects.toThrow('process.exit(1)'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('calls process.exit when --config-dir value is a flag', async () => { + await expect(bootstrapAndParseEarlyCli(['--config-dir', '--other-flag'])).rejects.toThrow( + 'process.exit(1)' + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('calls process.exit when --config-dir points to non-existent path', async () => { + await expect( + bootstrapAndParseEarlyCli(['--config-dir', '/tmp/does-not-exist-ccs-test-xyz']) + ).rejects.toThrow('process.exit(1)'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('calls process.exit when --config-dir points to a file (not dir)', async () => { + const tmpDir = makeTempDir(); + const filePath = path.join(tmpDir, 'notadir.txt'); + fs.writeFileSync(filePath, 'content'); + try { + await expect(bootstrapAndParseEarlyCli(['--config-dir', filePath])).rejects.toThrow( + 'process.exit(1)' + ); + expect(exitSpy).toHaveBeenCalledWith(1); + } finally { + cleanupDir(tmpDir); + } + }); + + // ---------- legacy cursor args ---------- + + it('normalizes legacy cursor args (legacy cursor → legacy-cursor profile)', async () => { + const result = await bootstrapAndParseEarlyCli(['legacy', 'cursor', '--auth']); + expect(result.exitNow).toBe(false); + // normalizeLegacyCursorArgs transforms ['legacy', 'cursor', '--auth'] → ['legacy-cursor', '--auth'] + expect(result.args[0]).toBe('legacy-cursor'); + expect(result.args).toContain('--auth'); + }); + + // ---------- normal passthrough ---------- + + it('returns exitNow=false and preserves args for normal invocation', async () => { + const result = await bootstrapAndParseEarlyCli(['gemini', '-p', 'hello']); + expect(result.exitNow).toBe(false); + expect(result.args).toContain('gemini'); + expect(result.args).toContain('-p'); + expect(result.args).toContain('hello'); + }); + + it('returns browserLaunchOverride=undefined when no browser flags are present', async () => { + const result = await bootstrapAndParseEarlyCli(['gemini']); + expect(result.browserLaunchOverride).toBeUndefined(); + }); +}); diff --git a/src/dispatcher/__tests__/pre-dispatch-handlers.test.ts b/src/dispatcher/__tests__/pre-dispatch-handlers.test.ts new file mode 100644 index 00000000..d67781db --- /dev/null +++ b/src/dispatcher/__tests__/pre-dispatch-handlers.test.ts @@ -0,0 +1,150 @@ +/** + * Tests for runPreDispatchHandlers() — Phase B extraction. + * + * Heavy dynamic-import surface means most paths are tested via the return value + * (consumed=true/false) and spied process.exit, not deep module internals. + */ + +import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; +import { runPreDispatchHandlers } from '../pre-dispatch'; +import type { Logger } from '../../services/logging/logger'; + +// ========== Stub Logger ========== + +function makeStubLogger(): Logger { + return { + info: mock(() => {}), + warn: mock(() => {}), + error: mock(() => {}), + debug: mock(() => {}), + stage: mock(() => {}), + } as unknown as Logger; +} + +// ========== Tests ========== + +describe('runPreDispatchHandlers', () => { + let exitSpy: ReturnType; + + beforeEach(() => { + process.env['CI'] = '1'; + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + // Suppress process.exit — handlers use it for subcommand routing + exitSpy = spyOn(process, 'exit').mockImplementation((_code?: number | string) => { + throw new Error(`process.exit(${_code})`); + }); + }); + + afterEach(() => { + delete process.env['CI']; + Object.defineProperty(process.stdout, 'isTTY', { value: undefined, configurable: true }); + exitSpy.mockRestore(); + }); + + // ---------- update check / migrate / recovery (non-consuming) ---------- + + it('returns false for a normal profile invocation (gemini)', async () => { + const consumed = await runPreDispatchHandlers({ + args: ['gemini', '-p', 'hello'], + cliLogger: makeStubLogger(), + }); + expect(consumed).toBe(false); + }); + + it('returns false for default (no-arg) invocation', async () => { + const consumed = await runPreDispatchHandlers({ + args: [], + cliLogger: makeStubLogger(), + }); + expect(consumed).toBe(false); + }); + + // ---------- root command router ---------- + + it('consumes --version (either returns true or exits via process.exit(0))', async () => { + // --version is handled by tryHandleRootCommand; it either returns true or calls process.exit(0) + let consumed: boolean | undefined; + try { + consumed = await runPreDispatchHandlers({ + args: ['--version'], + cliLogger: makeStubLogger(), + }); + } catch (e) { + // process.exit(0) thrown by our spy — that is also a valid "consumed" signal + expect((e as Error).message).toMatch(/process\.exit/); + return; + } + expect(consumed).toBe(true); + }); + + it('consumes --help (either returns true or exits via process.exit(0))', async () => { + let consumed: boolean | undefined; + try { + consumed = await runPreDispatchHandlers({ + args: ['--help'], + cliLogger: makeStubLogger(), + }); + } catch (e) { + expect((e as Error).message).toMatch(/process\.exit/); + return; + } + expect(consumed).toBe(true); + }); + + // ---------- provider help shortcut ---------- + + it('returns true for provider + --help shortcut (gemini --help)', async () => { + const consumed = await runPreDispatchHandlers({ + args: ['gemini', '--help'], + cliLogger: makeStubLogger(), + }); + expect(consumed).toBe(true); + }); + + it('returns true for provider + -h shortcut (codex -h)', async () => { + const consumed = await runPreDispatchHandlers({ + args: ['codex', '-h'], + cliLogger: makeStubLogger(), + }); + expect(consumed).toBe(true); + }); + + // ---------- copilot subcommand routing ---------- + + it('exits via process.exit for copilot subcommand (copilot --auth)', async () => { + // copilot --auth is a known subcommand token; handler exits with a code + await expect( + runPreDispatchHandlers({ + args: ['copilot', '--auth'], + cliLogger: makeStubLogger(), + }) + ).rejects.toThrow(/process\.exit/); + expect(exitSpy).toHaveBeenCalled(); + }); + + // ---------- cursor subcommand routing ---------- + + it('exits via process.exit for legacy-cursor subcommand (legacy-cursor auth)', async () => { + // 'auth' is a valid CURSOR_SUBCOMMANDS token; handler calls process.exit(exitCode) + await expect( + runPreDispatchHandlers({ + args: ['legacy-cursor', 'auth'], + cliLogger: makeStubLogger(), + }) + ).rejects.toThrow(/process\.exit/); + expect(exitSpy).toHaveBeenCalled(); + }); + + // ---------- recovery error (non-fatal) ---------- + + it('does not throw when recovery manager throws (best-effort)', async () => { + // Recovery errors are caught; handler must continue and return false + // We can't easily force recovery to throw here without mocking the dynamic import, + // but we verify the normal path doesn't surface recovery exceptions. + const consumed = await runPreDispatchHandlers({ + args: ['glm'], + cliLogger: makeStubLogger(), + }); + expect(consumed).toBe(false); + }); +}); diff --git a/src/dispatcher/__tests__/profile-resolver.test.ts b/src/dispatcher/__tests__/profile-resolver.test.ts new file mode 100644 index 00000000..3200f9e3 --- /dev/null +++ b/src/dispatcher/__tests__/profile-resolver.test.ts @@ -0,0 +1,387 @@ +/** + * Tests for resolveProfileAndTarget() — Phase C extraction. + * + * The function has deep dynamic-import dependencies (ProfileDetector, targets, + * browser config, etc.). Strategy: mock the heavy boundary modules and verify + * the control-flow contract and shape of the returned ResolvedProfile object. + * + * Coverage goals: + * - Default 'claude' profile resolves with null targetAdapter + * - CLIProxy profile (gemini) resolves correctly + * - Settings profile (glm) resolves with settings loaded + compatibility check + * - Unknown profile triggers ProfileError (thrown, caught by main's try/catch) + * - --target droid resolves droid adapter + binary + * - Compatibility check runs for non-settings profiles (duplication preserved) + */ + +import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; +import type { Logger } from '../../services/logging/logger'; + +// ========== Stub Logger ========== + +function makeStubLogger(): Logger { + return { + info: mock(() => {}), + warn: mock(() => {}), + error: mock(() => {}), + debug: mock(() => {}), + stage: mock(() => {}), + } as unknown as Logger; +} + +// ========== Module mocks ========== + +// We need to mock heavy dependencies at the module level before importing the SUT. +// Bun's module mock API replaces modules in the registry for the current test file. + +// Mock ProfileDetector — most tests override detectProfileType per-case +const mockDetectProfileType = mock((_profile: string) => ({ + type: 'default' as const, + name: 'default', + target: undefined, +})); +const mockGetAllProfiles = mock(() => ({ + settings: [] as string[], + cliproxy: [], + cliproxyVariants: [], +})); + +mock.module('../../auth/profile-detector', () => ({ + default: class MockProfileDetector { + detectProfileType = mockDetectProfileType; + getAllProfiles = mockGetAllProfiles; + }, +})); + +// Mock management / registry / context modules (dynamically imported, no-op for these tests) +mock.module('../../management/instance-manager', () => ({ default: class {} })); +mock.module('../../auth/profile-registry', () => ({ default: class {} })); +mock.module('../../auth/account-context', () => ({ + resolveAccountContextPolicy: mock(() => 'default'), + isAccountContextMetadata: mock(() => false), +})); +mock.module('../../auth/profile-continuity-inheritance', () => ({ + resolveProfileContinuityInheritance: mock(() => null), +})); + +// Mock target-resolver +const mockResolveTargetType = mock((_args: string[]) => 'claude' as const); +const mockStripTargetFlag = mock((args: string[]) => args); +mock.module('../../targets/target-resolver', () => ({ + resolveTargetType: mockResolveTargetType, + stripTargetFlag: mockStripTargetFlag, +})); + +// Mock targets registry +const mockGetTarget = mock((_name: string) => null); +const mockEvaluateTargetRuntimeCompatibility = mock(() => ({ supported: true })); +const mockPruneOrphanedModels = mock(async () => {}); +mock.module('../../targets', () => ({ + getTarget: mockGetTarget, + evaluateTargetRuntimeCompatibility: mockEvaluateTargetRuntimeCompatibility, + pruneOrphanedModels: mockPruneOrphanedModels, +})); + +// Mock claude-detector +const mockDetectClaudeCli = mock(() => '/usr/local/bin/claude'); +mock.module('../../utils/claude-detector', () => ({ + detectClaudeCli: mockDetectClaudeCli, +})); + +// Mock ErrorManager +mock.module('../../utils/error-manager', () => ({ + ErrorManager: { showClaudeNotFound: mock(async () => {}) }, +})); + +// Mock config-manager +mock.module('../../utils/config-manager', () => ({ + getSettingsPath: mock((name: string) => `/tmp/.ccs/profiles/${name}/settings.json`), + loadSettings: mock(() => ({ env: {} })), +})); + +// Mock helpers +mock.module('../../utils/helpers', () => ({ + expandPath: mock((p: string) => p), +})); + +// Mock browser config +mock.module('../../config/unified-config-loader', () => ({ + getBrowserConfig: mock(() => ({ + claude: { enabled: false, policy: 'never' }, + codex: { enabled: false, policy: 'never' }, + })), +})); + +// Mock browser utilities +mock.module('../../utils/browser', () => ({ + getEffectiveClaudeBrowserAttachConfig: mock(() => undefined), + resolveBrowserExposure: mock(() => ({ enabled: false })), + getBlockedBrowserOverrideWarning: mock(() => undefined), +})); + +// Mock cliproxy bridge +mock.module('../../api/services/cliproxy-profile-bridge', () => ({ + resolveCliproxyBridgeMetadata: mock(() => undefined), +})); + +// Mock environment-builder (only resolveCodexRuntimeConfigOverrides is called) +mock.module('../environment-builder', () => ({ + resolveCodexRuntimeConfigOverrides: mock(() => [] as string[]), + resolveNativeClaudeLaunchArgs: mock((a: string[]) => a), + refreshUpdateCache: mock(() => {}), + showCachedUpdateNotification: mock(async () => {}), +})); + +// Mock cli-argument-parser (detectProfile + normalization helpers) +const mockDetectProfile = mock((args: string[]) => ({ + profile: args[0] || 'default', + remainingArgs: args.slice(1), +})); +mock.module('../cli-argument-parser', () => ({ + detectProfile: mockDetectProfile, + resolveRuntimeReasoningFlags: mock((args: string[]) => ({ + argsWithoutReasoningFlags: args, + reasoningOverride: undefined, + reasoningSource: undefined, + })), + normalizeCodexRuntimeReasoningOverride: mock(() => undefined), + exitWithRuntimeReasoningFlagError: mock(() => {}), + normalizeNativeClaudeEffortArgs: mock((a: string[]) => a), + shouldNormalizeNativeClaudeEffort: mock(() => false), + bootstrapAndParseEarlyCli: mock(async () => ({ + exitNow: false, + args: [], + browserLaunchOverride: undefined, + })), +})); + +// Mock droid command/reasoning modules +mock.module('../../targets/droid-command-router', () => ({ + DroidCommandRouterError: class DroidCommandRouterError extends Error {}, + routeDroidCommandArgs: mock((args: string[]) => ({ + mode: 'interactive', + argsForDroid: args, + duplicateReasoningDisplays: [], + reasoningSourceDisplay: undefined, + autoPrependedExec: false, + })), +})); +mock.module('../../targets/droid-reasoning-runtime', () => ({ + DroidReasoningFlagError: class DroidReasoningFlagError extends Error { + constructor(msg: string, _flag?: string) { + super(msg); + } + }, +})); + +// ========== Import SUT after mocks are registered ========== + +import { resolveProfileAndTarget } from '../profile-resolver'; + +// ========== Tests ========== + +describe('resolveProfileAndTarget', () => { + let exitSpy: ReturnType; + let consoleErrorSpy: ReturnType; + const stubLogger = makeStubLogger(); + + beforeEach(() => { + exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as () => never); + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + process.env['CI'] = '1'; + process.env['CCS_HOME'] = '/tmp/ccs-test-profile-resolver'; + // Reset relevant mocks to defaults + mockDetectProfileType.mockImplementation((_profile: string) => ({ + type: 'default' as const, + name: 'default', + target: undefined, + })); + mockResolveTargetType.mockImplementation((_args: string[]) => 'claude' as const); + mockStripTargetFlag.mockImplementation((args: string[]) => args); + mockGetTarget.mockImplementation((_name: string) => null); + mockDetectClaudeCli.mockImplementation(() => '/usr/local/bin/claude'); + mockEvaluateTargetRuntimeCompatibility.mockImplementation(() => ({ supported: true })); + }); + + afterEach(() => { + exitSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + delete process.env['CI']; + delete process.env['CCS_HOME']; + }); + + it('resolves default claude profile with null targetAdapter', async () => { + const result = await resolveProfileAndTarget({ + args: [], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + expect(result.resolvedTarget).toBe('claude'); + expect(result.targetAdapter).toBeNull(); + expect(result.claudeCli).toBe('/usr/local/bin/claude'); + expect(result.profileInfo.type).toBe('default'); + expect(result.targetBinaryInfo).toBeNull(); + expect(result.targetRemainingArgs).toEqual([]); + expect(result.nativeClaudeRemainingArgs).toEqual([]); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('resolves gemini cliproxy profile', async () => { + mockDetectProfileType.mockImplementation((_profile: string) => ({ + type: 'cliproxy' as const, + name: 'gemini', + provider: 'gemini', + target: undefined, + })); + mockDetectProfile.mockImplementation((args: string[]) => ({ + profile: 'gemini', + remainingArgs: args.slice(1), + })); + + const result = await resolveProfileAndTarget({ + args: ['gemini'], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + expect(result.profile).toBe('gemini'); + expect(result.profileInfo.type).toBe('cliproxy'); + expect(result.resolvedTarget).toBe('claude'); + expect(result.targetAdapter).toBeNull(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('resolves settings profile (glm) with --target droid and loads settings', async () => { + // Settings loading only runs in the non-claude preflight block (resolvedTarget !== 'claude'). + const mockDroidAdapter = { + displayName: 'Factory Droid', + detectBinary: mock(() => ({ path: '/usr/local/bin/droid', version: '1.0.0' })), + supportsProfileType: mock(() => true), + }; + mockGetTarget.mockImplementation((_name: string) => mockDroidAdapter); + mockResolveTargetType.mockImplementation((_args: string[]) => 'droid' as const); + mockDetectProfileType.mockImplementation((_profile: string) => ({ + type: 'settings' as const, + name: 'glm', + settingsPath: undefined, + target: undefined, + })); + mockDetectProfile.mockImplementation((args: string[]) => ({ + profile: 'glm', + remainingArgs: args.slice(1), + })); + + const result = await resolveProfileAndTarget({ + args: ['glm', '--target', 'droid'], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + expect(result.profile).toBe('glm'); + expect(result.profileInfo.type).toBe('settings'); + expect(result.resolvedTarget).toBe('droid'); + // Settings are loaded inside the non-claude preflight block + expect(result.resolvedSettingsPath).toBeDefined(); + expect(result.resolvedSettings).toBeDefined(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('calls process.exit(1) for unknown profile when claude CLI not found', async () => { + // Simulate claude not found for claude target + mockDetectClaudeCli.mockImplementation(() => null); + + await resolveProfileAndTarget({ + args: [], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('resolves --target droid with droid adapter and binary', async () => { + const mockBinaryInfo = { path: '/usr/local/bin/droid', version: '1.0.0' }; + const mockDroidAdapter = { + displayName: 'Factory Droid', + detectBinary: mock(() => mockBinaryInfo), + supportsProfileType: mock(() => true), + }; + mockGetTarget.mockImplementation((_name: string) => mockDroidAdapter); + mockResolveTargetType.mockImplementation((_args: string[]) => 'droid' as const); + mockDetectProfileType.mockImplementation((_profile: string) => ({ + type: 'settings' as const, + name: 'glm', + settingsPath: undefined, + target: undefined, + })); + mockDetectProfile.mockImplementation((args: string[]) => ({ + profile: 'glm', + remainingArgs: args.slice(1), + })); + + const result = await resolveProfileAndTarget({ + args: ['glm', '--target', 'droid'], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + expect(result.resolvedTarget).toBe('droid'); + expect(result.targetAdapter).toBe(mockDroidAdapter); + expect(result.targetBinaryInfo).toBe(mockBinaryInfo); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('calls process.exit(1) when droid binary not found', async () => { + const mockDroidAdapter = { + displayName: 'Factory Droid', + detectBinary: mock(() => null), + supportsProfileType: mock(() => true), + }; + mockGetTarget.mockImplementation((_name: string) => mockDroidAdapter); + mockResolveTargetType.mockImplementation((_args: string[]) => 'droid' as const); + mockDetectProfileType.mockImplementation((_profile: string) => ({ + type: 'default' as const, + name: 'default', + target: undefined, + })); + + await resolveProfileAndTarget({ + args: ['--target', 'droid'], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('runs compatibility check for non-settings profiles (duplication preserved)', async () => { + const mockDroidAdapter = { + displayName: 'Factory Droid', + detectBinary: mock(() => ({ path: '/usr/local/bin/droid', version: '1.0.0' })), + supportsProfileType: mock(() => true), + }; + mockGetTarget.mockImplementation((_name: string) => mockDroidAdapter); + mockResolveTargetType.mockImplementation((_args: string[]) => 'droid' as const); + mockDetectProfileType.mockImplementation((_profile: string) => ({ + type: 'cliproxy' as const, + name: 'gemini', + provider: 'gemini', + target: undefined, + })); + mockDetectProfile.mockImplementation((args: string[]) => ({ + profile: 'gemini', + remainingArgs: args.slice(1), + })); + + await resolveProfileAndTarget({ + args: ['gemini', '--target', 'droid'], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + // Compatibility check must have been called (the duplication from Phase C) + expect(mockEvaluateTargetRuntimeCompatibility).toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/dispatcher/cli-argument-parser.ts b/src/dispatcher/cli-argument-parser.ts new file mode 100644 index 00000000..2a11a199 --- /dev/null +++ b/src/dispatcher/cli-argument-parser.ts @@ -0,0 +1,338 @@ +/** + * CLI argument parsing and normalization utilities. + * + * Extracted from src/ccs.ts (lines 129-244, 246-296, 371-392). + * Pure functions — no side effects except console.error and process.exit. + * + * Also contains bootstrapAndParseEarlyCli() — the Phase A bootstrap extracted + * from main() (lines 128-232 of the original). Handles: adapter registration, + * UI init, --config-dir flag, cloud-sync warnings, completion short-circuit, + * normalizeLegacyCursorArgs, resolveBrowserLaunchFlagResolution, codex passthrough. + */ + +import * as fs from 'fs'; +import { fail, warn, info } from '../utils/ui'; +import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants'; +import { resolveTargetType, stripTargetFlag } from '../targets/target-resolver'; +import { resolveDroidReasoningRuntime } from '../targets/droid-reasoning-runtime'; +import type { ProfileDetectionResult } from '../auth/profile-detector'; +import { setGlobalConfigDir, detectCloudSyncPath } from '../utils/config-manager'; +import { resolveBrowserLaunchFlagResolution } from '../utils/browser'; +import type { BrowserLaunchOverride } from '../utils/browser'; + +// ========== Bootstrap Result ========== + +/** + * Result of the early CLI bootstrap pass. + * All fields are consumed by main() to decide how to continue. + */ +export interface DispatcherBootstrap { + /** Normalized/mutated args after pre-parse (--config-dir stripped, browser flags stripped, legacy cursor normalized) */ + args: string[]; + isCompletionCommand: boolean; + browserLaunchOverride: BrowserLaunchOverride | undefined; + /** true when the caller should return immediately (completion handled, codex passthrough triggered, process.exit called) */ + exitNow: boolean; +} + +/** + * Phase A bootstrap: runs everything that must happen before config loading and profile detection. + * + * Side-effects preserved from original main(): + * - Dynamic import of initUI + * - setGlobalConfigDir (--config-dir flag) + * - Cloud-sync warnings + * - Completion short-circuit via tryHandleRootCommand + * - Codex native passthrough via execNativeCodexFlagCommand + * + * Adapter registration (registerTarget calls) stays in main() because it is + * singleton wiring with no dependency on the parsed args. + */ +export async function bootstrapAndParseEarlyCli(rawArgs: string[]): Promise { + let args = rawArgs; + const isCompletionCommand = args[0] === '__complete'; + + // Initialize UI colors early to ensure consistent colored output + // Must happen before any status messages (ok, info, fail, etc.) + if (!isCompletionCommand && process.stdout.isTTY && !process.env['CI']) { + const { initUI } = await import('../utils/ui'); + await initUI(); + } + + // Parse --config-dir flag (must happen before any config loading) + const configDirIdx = args.findIndex((a) => a === '--config-dir' || a.startsWith('--config-dir=')); + if (configDirIdx !== -1) { + const arg = args[configDirIdx]; + let configDirValue: string | undefined; + let spliceCount = 1; + + if (arg.startsWith('--config-dir=')) { + configDirValue = arg.split('=').slice(1).join('='); + } else { + configDirValue = args[configDirIdx + 1]; + spliceCount = 2; + } + + if (!configDirValue || configDirValue.startsWith('-')) { + console.error(fail('--config-dir requires a path argument')); + process.exit(1); + } + + try { + const stat = fs.statSync(configDirValue); + if (!stat.isDirectory()) { + console.error(fail(`Not a directory: ${configDirValue}`)); + process.exit(1); + } + } catch { + console.error(fail(`Config directory not found: ${configDirValue}`)); + console.error(info('Create the directory first, then copy your config files into it.')); + process.exit(1); + } + + setGlobalConfigDir(configDirValue); + + // Security warning: cloud sync paths expose OAuth tokens + const cloudService = detectCloudSyncPath(configDirValue); + if (!isCompletionCommand && cloudService) { + console.error(warn(`CCS directory is under ${cloudService}.`)); + console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); + console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); + } + + // Remove consumed args so they don't leak to Claude CLI + // Clone the array before splicing so the original rawArgs is unaffected + args = [...args]; + args.splice(configDirIdx, spliceCount); + } else if (process.env.CCS_DIR) { + // Also warn for CCS_DIR env var pointing to cloud sync + const cloudService = detectCloudSyncPath(process.env.CCS_DIR); + if (!isCompletionCommand && cloudService) { + console.error(warn(`CCS directory is under ${cloudService}.`)); + console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); + console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); + } + } else if (process.env.CCS_HOME) { + // Also warn for CCS_HOME env var pointing to cloud sync + const cloudService = detectCloudSyncPath(process.env.CCS_HOME); + if (!isCompletionCommand && cloudService) { + console.error(warn(`CCS directory is under ${cloudService}.`)); + console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); + console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); + } + } + + if (isCompletionCommand) { + const { tryHandleRootCommand } = await import('../commands/root-command-router'); + await tryHandleRootCommand(args); + return { args, isCompletionCommand, browserLaunchOverride: undefined, exitNow: true }; + } + + args = normalizeLegacyCursorArgs(args); + let browserLaunchOverride: BrowserLaunchOverride | undefined; + try { + const browserLaunchFlags = resolveBrowserLaunchFlagResolution(args); + browserLaunchOverride = browserLaunchFlags.override; + args = browserLaunchFlags.argsWithoutFlags; + } catch (error) { + console.error(fail((error as Error).message)); + process.exit(1); + // process.exit never returns but TypeScript needs the unreachable return + return { args, isCompletionCommand, browserLaunchOverride: undefined, exitNow: true }; + } + + if (shouldPassthroughNativeCodexFlagCommand(args)) { + const { execNativeCodexFlagCommand } = await import('./target-executor'); + execNativeCodexFlagCommand(args); + return { args, isCompletionCommand, browserLaunchOverride: undefined, exitNow: true }; + } + + return { args, isCompletionCommand, browserLaunchOverride, exitNow: false }; +} + +// ========== Interfaces ========== + +export interface DetectedProfile { + profile: string; + remainingArgs: string[]; +} + +export interface RuntimeReasoningResolution { + argsWithoutReasoningFlags: string[]; + reasoningOverride: string | number | undefined; + reasoningSource: 'flag' | 'env' | undefined; + sourceDisplay: string | undefined; +} + +// ========== Constants ========== + +export const CODEX_RUNTIME_REASONING_LEVELS = new Set([ + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', +]); + +export const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']); + +export const NATIVE_CLAUDE_EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const; + +export const NATIVE_CLAUDE_EFFORT_LEVEL_SET = new Set(NATIVE_CLAUDE_EFFORT_LEVELS); + +// ========== Profile Detection ========== + +/** + * Smart profile detection — first non-flag arg is the profile name. + */ +export function detectProfile(args: string[]): DetectedProfile { + if (args.length === 0 || args[0].startsWith('-')) { + // No args or first arg is a flag → use default profile + return { profile: 'default', remainingArgs: args }; + } else { + // First arg doesn't start with '-' → treat as profile name + return { profile: args[0], remainingArgs: args.slice(1) }; + } +} + +export function normalizeLegacyCursorArgs(args: string[]): string[] { + if (args[0] === 'legacy' && args[1] === 'cursor') { + return [LEGACY_CURSOR_PROFILE_NAME, ...args.slice(2)]; + } + + return args; +} + +export function printCursorLegacySubcommandDeprecation(subcommand: string): void { + console.error( + warn(`\`ccs cursor ${subcommand}\` is deprecated for the legacy Cursor IDE bridge.`) + ); + console.error( + warn( + `Use \`ccs legacy cursor ${subcommand}\` for the old bridge, or \`ccs cursor --auth|--accounts|--config\` for the CLIProxy provider.` + ) + ); + console.error(''); +} + +// ========== Runtime Reasoning Flags ========== + +export function resolveRuntimeReasoningFlags( + args: string[], + envThinkingValue: string | undefined +): RuntimeReasoningResolution { + const runtime = resolveDroidReasoningRuntime(args, envThinkingValue); + + if (runtime.duplicateDisplays.length > 0) { + console.error( + warn( + `[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || ''}` + ) + ); + } + + return { + argsWithoutReasoningFlags: runtime.argsWithoutReasoningFlags, + reasoningOverride: runtime.reasoningOverride, + reasoningSource: runtime.sourceFlag + ? 'flag' + : runtime.reasoningOverride !== undefined + ? 'env' + : undefined, + sourceDisplay: runtime.sourceDisplay, + }; +} + +export function normalizeCodexRuntimeReasoningOverride( + value: string | number | undefined +): string | undefined { + return typeof value === 'string' && CODEX_RUNTIME_REASONING_LEVELS.has(value) ? value : undefined; +} + +export function exitWithRuntimeReasoningFlagError( + message: string, + options: { + codexAliasLevels: string; + includeDroidExecExample?: boolean; + } +): never { + console.error(fail(message)); + console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); + console.error(` Codex alias: --effort ${options.codexAliasLevels}`); + if (options.includeDroidExecExample) { + console.error(' Droid exec: --reasoning-effort high'); + } + process.exit(1); +} + +// ========== Native Claude Effort Normalization ========== + +export function normalizeNativeClaudeEffortArgs(args: string[]): string[] { + const normalizedArgs: string[] = []; + const allowedValues = NATIVE_CLAUDE_EFFORT_LEVELS.join(', '); + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--effort') { + const rawValue = args[i + 1]; + if (!rawValue || rawValue.startsWith('-') || !rawValue.trim()) { + throw new Error(`--effort requires a value: ${allowedValues}`); + } + const value = rawValue.toLowerCase(); + if (!NATIVE_CLAUDE_EFFORT_LEVEL_SET.has(value)) { + throw new Error(`Invalid --effort value: ${rawValue}. Expected one of: ${allowedValues}.`); + } + normalizedArgs.push(arg, value); + i++; + continue; + } + + if (arg.startsWith('--effort=')) { + const rawValue = arg.slice('--effort='.length); + if (!rawValue.trim()) { + throw new Error(`--effort requires a value: ${allowedValues}`); + } + const value = rawValue.toLowerCase(); + if (!NATIVE_CLAUDE_EFFORT_LEVEL_SET.has(value)) { + throw new Error(`Invalid --effort value: ${rawValue}. Expected one of: ${allowedValues}.`); + } + normalizedArgs.push(`--effort=${value}`); + continue; + } + + normalizedArgs.push(arg); + } + + return normalizedArgs; +} + +export function shouldNormalizeNativeClaudeEffort( + profileType: ProfileDetectionResult['type'] +): boolean { + return profileType === 'default' || profileType === 'account' || profileType === 'settings'; +} + +// ========== Native Codex Passthrough ========== + +export function shouldPassthroughNativeCodexFlagCommand(args: string[]): boolean { + return getNativeCodexPassthroughArgs(args) !== null; +} + +export function getNativeCodexPassthroughArgs(args: string[]): string[] | null { + const targetArgs = stripTargetFlag(args); + if (resolveTargetType(args) !== 'codex' || targetArgs.length === 0) { + return null; + } + + const firstArg = targetArgs[0] || ''; + if (CODEX_NATIVE_PASSTHROUGH_FLAGS.has(firstArg)) { + return targetArgs; + } + + const secondArg = targetArgs[1] || ''; + if (firstArg === 'codex' && CODEX_NATIVE_PASSTHROUGH_FLAGS.has(secondArg)) { + return targetArgs.slice(1); + } + + return null; +} diff --git a/src/dispatcher/dispatcher-context.ts b/src/dispatcher/dispatcher-context.ts new file mode 100644 index 00000000..b65dbe70 --- /dev/null +++ b/src/dispatcher/dispatcher-context.ts @@ -0,0 +1,40 @@ +/** + * Shared context type for Phase E profile dispatch flows. + * + * ProfileDispatchContext extends ResolvedProfile with the dynamic imports + * needed by all flows (InstanceManager, ProfileRegistry, AccountContext, + * ProfileContinuity), and the browser-exposure fields already computed by + * Phase C (profile-resolver.ts). + */ + +import type { ResolvedProfile } from './profile-resolver'; +import type { loadSettings } from '../config/config-loader-facade'; +import type { getEffectiveClaudeBrowserAttachConfig } from '../utils/browser'; +import type { resolveCliproxyBridgeMetadata } from '../api/services/cliproxy-profile-bridge'; +import type { resolveTargetType } from '../targets/target-resolver'; + +// Re-export ResolvedProfile fields as ProfileDispatchContext — +// all browser-exposure fields are already present in ResolvedProfile. +export type ProfileDispatchContext = ResolvedProfile & { + /** Dynamic-imported modules needed by account/settings flows */ + InstanceManager: Awaited['default']; + ProfileRegistry: Awaited['default']; + resolveAccountContextPolicy: Awaited< + typeof import('../auth/account-context') + >['resolveAccountContextPolicy']; + isAccountContextMetadata: Awaited< + typeof import('../auth/account-context') + >['isAccountContextMetadata']; + resolveProfileContinuityInheritance: Awaited< + typeof import('../auth/profile-continuity-inheritance') + >['resolveProfileContinuityInheritance']; +}; + +// Re-export for convenience +export type { ResolvedProfile }; +export type { + loadSettings, + getEffectiveClaudeBrowserAttachConfig, + resolveCliproxyBridgeMetadata, + resolveTargetType, +}; diff --git a/src/dispatcher/environment-builder.ts b/src/dispatcher/environment-builder.ts new file mode 100644 index 00000000..564a7386 --- /dev/null +++ b/src/dispatcher/environment-builder.ts @@ -0,0 +1,125 @@ +/** + * Runtime environment and launch-args builders. + * + * Extracted from src/ccs.ts (lines 146-163, 300-327, 329-369). + * Covers update-cache helpers and launch-arg resolution for native Claude/Codex targets. + */ + +import { warn } from '../utils/ui'; +import { resolveBrowserExposure } from '../utils/browser'; +import { getBrowserConfig, getOfficialChannelsConfig } from '../config/config-loader-facade'; +import { + buildOfficialChannelsArgs, + getOfficialChannelsEnvironmentStatus, + officialChannelRequiresMacOS, + resolveOfficialChannelsLaunchPlan, +} from '../channels/official-channels-runtime'; +import { getOfficialChannelReadiness } from '../channels/official-channels-store'; +import { buildCodexBrowserMcpOverrides } from '../utils/browser-codex-overrides'; +import { getVersion } from '../utils/version'; +import { + checkForUpdates, + showUpdateNotification, + checkCachedUpdate, +} from '../utils/update-checker'; +import { resolveTargetType } from '../targets/target-resolver'; +import type { BrowserLaunchOverride } from '../utils/browser'; + +// ========== Codex Runtime Config ========== + +export function resolveCodexRuntimeConfigOverrides( + target: ReturnType, + browserLaunchOverride: BrowserLaunchOverride | undefined +): string[] { + if (target !== 'codex') { + return []; + } + + const codexBrowserExposure = resolveBrowserExposure( + getBrowserConfig().codex, + browserLaunchOverride + ); + if (!codexBrowserExposure.exposeForLaunch) { + return []; + } + + return buildCodexBrowserMcpOverrides(); +} + +// ========== Update Cache Helpers ========== + +/** + * Perform background update check (refreshes cache, no notification). + */ +export async function refreshUpdateCache(): Promise { + try { + const currentVersion = getVersion(); + // npm is now the only supported installation method + await checkForUpdates(currentVersion, true, 'npm'); + } catch (_e) { + // Silently fail - update check shouldn't crash main CLI + } +} + +/** + * Show update notification if cached result indicates update available. + * Returns true if notification was shown. + */ +export async function showCachedUpdateNotification(): Promise { + try { + const currentVersion = getVersion(); + const updateInfo = checkCachedUpdate(currentVersion); + + if (updateInfo) { + await showUpdateNotification(updateInfo); + return true; + } + } catch (_e) { + // Silently fail + } + return false; +} + +// ========== Native Claude Launch Args ========== + +export function resolveNativeClaudeLaunchArgs( + args: string[], + profileType: 'default' | 'account', + targetConfigDir?: string +): string[] { + const config = getOfficialChannelsConfig(); + const environment = getOfficialChannelsEnvironmentStatus( + targetConfigDir ? { CLAUDE_CONFIG_DIR: targetConfigDir } : undefined + ); + const channelReadiness = { + telegram: getOfficialChannelReadiness('telegram'), + discord: getOfficialChannelReadiness('discord'), + imessage: !officialChannelRequiresMacOS('imessage') || process.platform === 'darwin', + }; + const plan = resolveOfficialChannelsLaunchPlan({ + args, + config, + target: 'claude', + profileType, + environment, + channelReadiness, + }); + + for (const message of plan.skippedMessages) { + console.error(warn(message)); + } + + if ( + config.selected.length > 0 && + environment.auth.state === 'eligible' && + environment.auth.orgRequirementMessage + ) { + console.error(warn(environment.auth.orgRequirementMessage)); + } + + if (!plan.applied) { + return args; + } + + return buildOfficialChannelsArgs(args, plan.appliedChannels, plan.wantsPermissionBypass); +} diff --git a/src/dispatcher/flows/account-flow.ts b/src/dispatcher/flows/account-flow.ts new file mode 100644 index 00000000..6fec0073 --- /dev/null +++ b/src/dispatcher/flows/account-flow.ts @@ -0,0 +1,63 @@ +/** + * Account dispatch flow — account-based profile (work, personal) with instance isolation. + * + * Extracted from src/ccs.ts main() profileInfo.type === 'account' branch. + * Uses CLAUDE_CONFIG_DIR for per-profile instance isolation on all platforms. + */ + +import { execClaude } from '../../utils/shell-executor'; +import { maybeWarnAboutResumeLaneMismatch } from '../../auth/resume-lane-warning'; +import { resolveNativeClaudeLaunchArgs } from '../environment-builder'; +import type { ProfileDispatchContext } from '../dispatcher-context'; + +export async function runAccountFlow(ctx: ProfileDispatchContext): Promise { + const { + profileInfo, + claudeCli, + nativeClaudeRemainingArgs, + InstanceManager, + ProfileRegistry, + resolveAccountContextPolicy, + isAccountContextMetadata, + } = ctx; + + const registry = new ProfileRegistry(); + const instanceMgr = new InstanceManager(); + const accountMetadata = isAccountContextMetadata(profileInfo.profile) + ? profileInfo.profile + : undefined; + const isBareProfile = + typeof profileInfo.profile === 'object' && + profileInfo.profile !== null && + (profileInfo.profile as { bare?: unknown }).bare === true; + const contextPolicy = resolveAccountContextPolicy(accountMetadata); + + // Ensure instance exists (lazy init if needed) + const instancePath = await instanceMgr.ensureInstance(profileInfo.name, contextPolicy, { + bare: isBareProfile, + }); + + // Update last_used timestamp (check unified config first, fallback to legacy) + if (registry.hasAccountUnified(profileInfo.name)) { + registry.touchAccountUnified(profileInfo.name); + } else { + registry.touchProfile(profileInfo.name); + } + + // Execute Claude with instance isolation. + // Skip WebSearch hook — account profiles use native server-side WebSearch. + // Skip Image Analyzer hook — account profiles have native vision support. + const envVars: NodeJS.ProcessEnv = { + CLAUDE_CONFIG_DIR: instancePath, + CCS_PROFILE_TYPE: 'account', + CCS_WEBSEARCH_SKIP: '1', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + await maybeWarnAboutResumeLaneMismatch(profileInfo.name, instancePath, nativeClaudeRemainingArgs); + const launchArgs = resolveNativeClaudeLaunchArgs( + nativeClaudeRemainingArgs, + 'account', + instancePath + ); + execClaude(claudeCli, launchArgs, envVars); +} diff --git a/src/dispatcher/flows/cliproxy-flow.ts b/src/dispatcher/flows/cliproxy-flow.ts new file mode 100644 index 00000000..577fd020 --- /dev/null +++ b/src/dispatcher/flows/cliproxy-flow.ts @@ -0,0 +1,211 @@ +/** + * CLIProxy dispatch flow — OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants. + * + * Extracted from src/ccs.ts main() profileInfo.type === 'cliproxy' branch. + */ + +import { expandPath } from '../../utils/helpers'; +import { fail, info } from '../../utils/ui'; +import { + execClaudeWithCLIProxy, + type CLIProxyProvider, + ensureCliproxyService, + isAuthenticated, +} from '../../cliproxy'; +import { getEffectiveEnvVars, getCompositeEnvVars } from '../../cliproxy/config/env-builder'; +import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager'; +import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; +import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; +import { + ensureProfileHooks as ensureImageAnalyzerHooks, + removeImageAnalysisProfileHook, +} from '../../utils/hooks/image-analyzer-profile-hook-injector'; +import { prepareImageAnalysisFallbackHook } from '../../utils/hooks'; +import { resolveDroidProvider, type TargetCredentials } from '../../targets'; +import type { ProfileDispatchContext } from '../dispatcher-context'; + +export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise { + const { + profileInfo, + resolvedTarget, + claudeCli, + targetAdapter, + targetRemainingArgs, + runtimeReasoningOverride, + codexRuntimeConfigOverrides, + remainingArgs, + } = ctx; + + const imageAnalysisMcpReady = + resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true; + if (resolvedTarget === 'claude') { + ensureWebSearchMcpOrThrow(); + } + const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider); + const expandedCliproxySettingsPath = profileInfo.settingsPath + ? expandPath(profileInfo.settingsPath) + : undefined; + if (resolvedTarget === 'claude') { + if (imageAnalysisMcpReady) { + removeImageAnalysisProfileHook(profileInfo.name, expandedCliproxySettingsPath); + } else { + const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); + ensureImageAnalyzerHooks({ + profileName: profileInfo.name, + profileType: profileInfo.type, + cliproxyProvider: provider, + isComposite: profileInfo.isComposite, + settingsPath: expandedCliproxySettingsPath, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + } + } + const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles + const variantPort = profileInfo.port; // variant-specific port for isolation + const cliproxyPort = variantPort || CLIPROXY_DEFAULT_PORT; + + if (resolvedTarget !== 'claude') { + const adapter = targetAdapter; + if (!adapter) { + console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); + process.exitCode = 1; + return; + } + if (!adapter.supportsProfileType('cliproxy')) { + console.error(fail(`${adapter.displayName} does not support CLIProxy profiles`)); + process.exitCode = 1; + return; + } + + // Keep CLIProxy management/auth flags on Claude flow only. + const unsupportedCliproxyFlags = [ + '--auth', + '--logout', + '--accounts', + '--add', + '--use', + '--config', + '--headless', + '--paste-callback', + '--port-forward', + '--nickname', + '--kiro-auth-method', + '--kiro-idc-start-url', + '--kiro-idc-region', + '--kiro-idc-flow', + '--backend', + '--proxy-host', + '--proxy-port', + '--proxy-protocol', + '--proxy-auth-token', + '--proxy-timeout', + '--local-proxy', + '--remote-only', + '--no-fallback', + '--allow-self-signed', + '--1m', + '--no-1m', + ]; + const providedUnsupportedFlag = unsupportedCliproxyFlags.find( + (flag) => + targetRemainingArgs.includes(flag) || + targetRemainingArgs.some((arg) => arg.startsWith(`${flag}=`)) + ); + if (providedUnsupportedFlag) { + console.error( + fail( + `${providedUnsupportedFlag} is only supported when running CLIProxy profiles on Claude target` + ) + ); + console.error(info(`Run with Claude target: ccs ${profileInfo.name} --target claude ...`)); + process.exitCode = 1; + return; + } + + // For Droid execution path, require existing OAuth auth and running local proxy. + if (profileInfo.isComposite && profileInfo.compositeTiers) { + const compositeProviders = [ + ...new Set(Object.values(profileInfo.compositeTiers).map((tier) => tier.provider)), + ] as CLIProxyProvider[]; + const missingProvider = compositeProviders.find((p) => !isAuthenticated(p)); + if (missingProvider) { + console.error(fail(`Missing OAuth auth for composite tier provider: ${missingProvider}`)); + console.error(info(`Authenticate first: ccs ${missingProvider} --auth`)); + process.exitCode = 1; + return; + } + } else if (!isAuthenticated(provider)) { + console.error(fail(`No OAuth authentication found for provider: ${provider}`)); + console.error(info(`Authenticate first: ccs ${provider} --auth`)); + process.exitCode = 1; + return; + } + + const ensureServiceResult = await ensureCliproxyService( + cliproxyPort, + targetRemainingArgs.includes('--verbose') || targetRemainingArgs.includes('-v') + ); + if (!ensureServiceResult.started) { + console.error(fail(ensureServiceResult.error || 'Failed to start local CLIProxy service')); + process.exitCode = 1; + return; + } + + const envVars = + profileInfo.isComposite && profileInfo.compositeTiers && profileInfo.compositeDefaultTier + ? getCompositeEnvVars( + profileInfo.compositeTiers, + profileInfo.compositeDefaultTier, + cliproxyPort, + customSettingsPath + ) + : getEffectiveEnvVars(provider, cliproxyPort, customSettingsPath); + + const creds: TargetCredentials = { + profile: profileInfo.name, + baseUrl: envVars['ANTHROPIC_BASE_URL'] || '', + apiKey: envVars['ANTHROPIC_AUTH_TOKEN'] || '', + model: envVars['ANTHROPIC_MODEL'] || undefined, + provider: resolveDroidProvider({ + provider: envVars['CCS_DROID_PROVIDER'] || envVars['DROID_PROVIDER'], + baseUrl: envVars['ANTHROPIC_BASE_URL'], + model: envVars['ANTHROPIC_MODEL'], + }), + reasoningOverride: runtimeReasoningOverride, + runtimeConfigOverrides: codexRuntimeConfigOverrides, + envVars, + }; + + if (!creds.baseUrl || !creds.apiKey) { + console.error( + fail( + `Missing CLIProxy runtime credentials for ${profileInfo.name} (ANTHROPIC_BASE_URL/AUTH_TOKEN)` + ) + ); + console.error( + info('Reconfigure with: ccs config > CLIProxy, or run ccs --config') + ); + process.exitCode = 1; + return; + } + + await adapter.prepareCredentials(creds); + const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs, { + creds, + profileType: profileInfo.type, + binaryInfo: ctx.targetBinaryInfo || undefined, + }); + const targetEnv = adapter.buildEnv(creds, profileInfo.type); + adapter.exec(targetArgs, targetEnv, { binaryInfo: ctx.targetBinaryInfo || undefined }); + return; + } + + await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, { + customSettingsPath, + port: cliproxyPort, + isComposite: profileInfo.isComposite, + compositeTiers: profileInfo.compositeTiers, + compositeDefaultTier: profileInfo.compositeDefaultTier, + profileName: profileInfo.name, + }); +} diff --git a/src/dispatcher/flows/copilot-flow.ts b/src/dispatcher/flows/copilot-flow.ts new file mode 100644 index 00000000..a294c5a7 --- /dev/null +++ b/src/dispatcher/flows/copilot-flow.ts @@ -0,0 +1,66 @@ +/** + * Copilot dispatch flow — GitHub Copilot subscription via copilot-api proxy. + * + * Extracted from src/ccs.ts main() profileInfo.type === 'copilot' branch. + */ + +import { fail, info } from '../../utils/ui'; +import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; +import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; +import { + ensureProfileHooks as ensureImageAnalyzerHooks, + removeImageAnalysisProfileHook, +} from '../../utils/hooks/image-analyzer-profile-hook-injector'; +import { prepareImageAnalysisFallbackHook } from '../../utils/hooks'; +import type { ProfileDispatchContext } from '../dispatcher-context'; + +export async function runCopilotFlow(ctx: ProfileDispatchContext): Promise { + const { + profileInfo, + resolvedTarget, + claudeCli, + remainingArgs, + resolveProfileContinuityInheritance, + } = ctx; + + ensureWebSearchMcpOrThrow(); + const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); + if (resolvedTarget === 'claude') { + if (imageAnalysisMcpReady) { + removeImageAnalysisProfileHook(profileInfo.name); + } else { + const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); + ensureImageAnalyzerHooks({ + profileName: profileInfo.name, + profileType: profileInfo.type, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + } + } + + const { executeCopilotProfile } = await import('../../copilot'); + const copilotConfig = profileInfo.copilotConfig; + if (!copilotConfig) { + console.error(fail('Copilot configuration not found')); + process.exit(1); + } + const continuityInheritance = await resolveProfileContinuityInheritance({ + profileName: profileInfo.name, + profileType: profileInfo.type, + target: resolvedTarget, + }); + if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { + console.error( + info( + `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` + ) + ); + } + const exitCode = await executeCopilotProfile( + copilotConfig, + remainingArgs, + continuityInheritance.claudeConfigDir, + claudeCli + ); + process.exit(exitCode); +} diff --git a/src/dispatcher/flows/cursor-flow.ts b/src/dispatcher/flows/cursor-flow.ts new file mode 100644 index 00000000..8a98c0b8 --- /dev/null +++ b/src/dispatcher/flows/cursor-flow.ts @@ -0,0 +1,54 @@ +/** + * Cursor dispatch flow — local Cursor daemon profile. + * + * Extracted from src/ccs.ts main() profileInfo.type === 'cursor' branch. + */ + +import { fail, info } from '../../utils/ui'; +import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; +import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector'; +import { installImageAnalyzerHook } from '../../utils/hooks'; +import type { ProfileDispatchContext } from '../dispatcher-context'; + +export async function runCursorFlow(ctx: ProfileDispatchContext): Promise { + const { + profileInfo, + resolvedTarget, + claudeCli, + remainingArgs, + resolveProfileContinuityInheritance, + } = ctx; + + ensureWebSearchMcpOrThrow(); + installImageAnalyzerHook(); + ensureImageAnalyzerHooks({ + profileName: profileInfo.name, + profileType: profileInfo.type, + }); + + const { executeCursorProfile } = await import('../../cursor'); + const cursorConfig = profileInfo.cursorConfig; + if (!cursorConfig) { + console.error(fail('Cursor configuration not found')); + process.exit(1); + } + const continuityInheritance = await resolveProfileContinuityInheritance({ + profileName: profileInfo.name, + profileType: profileInfo.type, + target: resolvedTarget, + }); + if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { + console.error( + info( + `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` + ) + ); + } + const exitCode = await executeCursorProfile( + cursorConfig, + remainingArgs, + continuityInheritance.claudeConfigDir, + claudeCli + ); + process.exit(exitCode); +} diff --git a/src/dispatcher/flows/default-flow.ts b/src/dispatcher/flows/default-flow.ts new file mode 100644 index 00000000..b782bfb7 --- /dev/null +++ b/src/dispatcher/flows/default-flow.ts @@ -0,0 +1,136 @@ +/** + * Default dispatch flow — no profile configured, use Claude's own defaults. + * + * Extracted from src/ccs.ts main() else branch (profileInfo.type === 'default'). + * Skip WebSearch hook — native Claude has server-side WebSearch. + * Skip Image Analyzer hook — native Claude has native vision support. + */ + +import { fail, info, warn } from '../../utils/ui'; +import { + appendBrowserToolArgs, + ensureBrowserMcpOrThrow, + resolveOptionalBrowserAttachRuntime, + syncBrowserMcpToConfigDir, +} from '../../utils/browser'; +import { execClaude } from '../../utils/shell-executor'; +import { resolveDroidProvider, type TargetCredentials } from '../../targets'; +import { resolveNativeClaudeLaunchArgs } from '../environment-builder'; +import type { ProfileDispatchContext } from '../dispatcher-context'; + +export async function runDefaultFlow(ctx: ProfileDispatchContext): Promise { + const { + profileInfo, + resolvedTarget, + claudeCli, + targetAdapter, + targetBinaryInfo, + targetRemainingArgs, + nativeClaudeRemainingArgs, + runtimeReasoningOverride, + codexRuntimeConfigOverrides, + claudeBrowserExposure, + claudeAttachConfig, + resolveProfileContinuityInheritance, + } = ctx; + + const envVars: NodeJS.ProcessEnv = { + CCS_PROFILE_TYPE: 'default', + CCS_WEBSEARCH_SKIP: '1', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + const browserAttachRuntime = + resolvedTarget === 'claude' && + claudeBrowserExposure?.exposeForLaunch && + claudeAttachConfig?.enabled + ? await resolveOptionalBrowserAttachRuntime(claudeAttachConfig) + : undefined; + const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; + if (browserAttachRuntime?.warning) { + process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`); + } + + if (resolvedTarget === 'claude') { + if (browserRuntimeEnv) { + ensureBrowserMcpOrThrow(); + Object.assign(envVars, browserRuntimeEnv); + } + const defaultContinuityInheritance = await resolveProfileContinuityInheritance({ + profileName: profileInfo.name, + profileType: profileInfo.type, + target: resolvedTarget, + }); + if (defaultContinuityInheritance.sourceAccount && process.env.CCS_DEBUG) { + console.error( + info( + `Continuity inheritance active: profile "${profileInfo.name}" -> account "${defaultContinuityInheritance.sourceAccount}"` + ) + ); + } + if (defaultContinuityInheritance.claudeConfigDir) { + envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir; + if ( + browserRuntimeEnv && + !syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir) + ) { + throw new Error( + 'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.' + ); + } + } + } + + // Dispatch through target adapter for non-claude targets + if (resolvedTarget !== 'claude') { + const adapter = targetAdapter; + if (!adapter) { + console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); + process.exit(1); + } + if (!adapter.supportsProfileType('default')) { + console.error(fail(`${adapter.displayName} does not support default profile mode`)); + process.exit(1); + } + const creds: TargetCredentials = { + profile: 'default', + baseUrl: process.env['ANTHROPIC_BASE_URL'] || '', + apiKey: process.env['ANTHROPIC_AUTH_TOKEN'] || '', + model: process.env['ANTHROPIC_MODEL'], + provider: resolveDroidProvider({ + provider: process.env['CCS_DROID_PROVIDER'] || process.env['DROID_PROVIDER'], + baseUrl: process.env['ANTHROPIC_BASE_URL'], + model: process.env['ANTHROPIC_MODEL'], + }), + reasoningOverride: runtimeReasoningOverride, + runtimeConfigOverrides: codexRuntimeConfigOverrides, + browserRuntimeEnv, + }; + if (resolvedTarget === 'droid' && (!creds.baseUrl || !creds.apiKey)) { + console.error( + fail( + `${adapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN` + ) + ); + console.error(info('Use a settings-based profile instead: ccs glm --target droid')); + process.exit(1); + } + await adapter.prepareCredentials(creds); + const targetArgs = adapter.buildArgs('default', targetRemainingArgs, { + creds, + profileType: 'default', + binaryInfo: targetBinaryInfo || undefined, + }); + const targetEnv = adapter.buildEnv(creds, 'default'); + adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); + return; + } + + const launchArgs = resolveNativeClaudeLaunchArgs( + browserRuntimeEnv + ? appendBrowserToolArgs(nativeClaudeRemainingArgs) + : nativeClaudeRemainingArgs, + 'default', + envVars.CLAUDE_CONFIG_DIR + ); + execClaude(claudeCli, launchArgs, envVars); +} diff --git a/src/dispatcher/flows/settings-flow.ts b/src/dispatcher/flows/settings-flow.ts new file mode 100644 index 00000000..f54bbb41 --- /dev/null +++ b/src/dispatcher/flows/settings-flow.ts @@ -0,0 +1,401 @@ +/** + * Settings dispatch flow — settings-based profiles (glm, glmt, kimi, etc.). + * + * Extracted from src/ccs.ts main() profileInfo.type === 'settings' branch. + * Image-analysis prep is large enough to split; see settings-image-analysis-prep.ts. + */ + +import { getSettingsPath } from '../../utils/config-manager'; +import { loadSettings } from '../../config/config-loader-facade'; +import { expandPath } from '../../utils/helpers'; +import { + validateGlmKey, + validateMiniMaxKey, + validateAnthropicKey, +} from '../../utils/api-key-validator'; +import { + ensureWebSearchMcpOrThrow, + displayWebSearchStatus, + getWebSearchHookEnv, + syncWebSearchMcpToConfigDir, + appendThirdPartyWebSearchToolArgs, + createWebSearchTraceContext, +} from '../../utils/websearch-manager'; +import { + ensureImageAnalysisMcpOrThrow, + syncImageAnalysisMcpToConfigDir, + appendThirdPartyImageAnalysisToolArgs, +} from '../../utils/image-analysis'; +import { + appendBrowserToolArgs, + ensureBrowserMcpOrThrow, + resolveOptionalBrowserAttachRuntime, + syncBrowserMcpToConfigDir, +} from '../../utils/browser'; +import { getGlobalEnvConfig } from '../../config/config-loader-facade'; +import { + ensureProfileHooks as ensureImageAnalyzerHooks, + removeImageAnalysisProfileHook, +} from '../../utils/hooks/image-analyzer-profile-hook-injector'; +import { fail, info, warn } from '../../utils/ui'; +import { execClaude, stripAnthropicRoutingEnv, stripBrowserEnv } from '../../utils/shell-executor'; +import { + isDeprecatedGlmtProfileName, + normalizeDeprecatedGlmtEnv, +} from '../../utils/glmt-deprecation'; +import { createOpenAICompatLaunchSettings } from '../../utils/openai-compat-launch-settings'; +import { + resolveDroidProvider, + evaluateTargetRuntimeCompatibility, + type TargetCredentials, +} from '../../targets'; +import { resolveCliproxyBridgeMetadata } from '../../api/services/cliproxy-profile-bridge'; +import { + buildOpenAICompatProxyEnv, + resolveOpenAICompatProfileConfig, + startOpenAICompatProxy, +} from '../../proxy'; +import { resolveSettingsImageAnalysisEnv } from './settings-image-analysis-prep'; +import type { ProfileDispatchContext } from '../dispatcher-context'; + +export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise { + const { + profileInfo, + resolvedTarget, + claudeCli, + targetAdapter, + targetBinaryInfo, + targetRemainingArgs, + nativeClaudeRemainingArgs, + runtimeReasoningOverride, + codexRuntimeConfigOverrides, + claudeBrowserExposure, + claudeAttachConfig, + resolvedSettingsPath: preResolvedSettingsPath, + resolvedSettings: preResolvedSettings, + resolvedCliproxyBridge: preResolvedCliproxyBridge, + resolveProfileContinuityInheritance, + remainingArgs, + } = ctx; + + const imageAnalysisMcpReady = + resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true; + const browserAttachRuntime = + resolvedTarget === 'claude' && + claudeBrowserExposure?.exposeForLaunch && + claudeAttachConfig?.enabled + ? await resolveOptionalBrowserAttachRuntime(claudeAttachConfig) + : undefined; + const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv; + if (browserAttachRuntime?.warning) { + process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`); + } + if (resolvedTarget === 'claude') { + ensureWebSearchMcpOrThrow(); + if (browserRuntimeEnv) { + ensureBrowserMcpOrThrow(); + } + } + + // Display WebSearch status (single line, equilibrium UX) + displayWebSearchStatus(); + + const continuityInheritance = + resolvedTarget === 'claude' + ? await resolveProfileContinuityInheritance({ + profileName: profileInfo.name, + profileType: profileInfo.type, + target: resolvedTarget, + }) + : {}; + if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { + console.error( + info( + `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` + ) + ); + } + const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir; + syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); + syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); + if ( + browserRuntimeEnv && + inheritedClaudeConfigDir && + !syncBrowserMcpToConfigDir(inheritedClaudeConfigDir) + ) { + throw new Error( + 'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.' + ); + } + + const expandedSettingsPath = + preResolvedSettingsPath ?? + (profileInfo.settingsPath + ? expandPath(profileInfo.settingsPath) + : getSettingsPath(profileInfo.name)); + const settings = preResolvedSettings ?? loadSettings(expandedSettingsPath); + const cliproxyBridge = preResolvedCliproxyBridge ?? resolveCliproxyBridgeMetadata(settings); + + let imageAnalysisFallbackHookReady: boolean | undefined; + if (resolvedTarget === 'claude') { + if (imageAnalysisMcpReady) { + removeImageAnalysisProfileHook(profileInfo.name, expandedSettingsPath); + } else { + imageAnalysisFallbackHookReady = ( + await import('../../utils/hooks') + ).prepareImageAnalysisFallbackHook(); + ensureImageAnalyzerHooks({ + profileName: profileInfo.name, + profileType: profileInfo.type, + settingsPath: expandedSettingsPath, + settings, + cliproxyBridge, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + } + } + if (resolvedTarget !== 'claude') { + const compatibility = evaluateTargetRuntimeCompatibility({ + target: resolvedTarget, + profileType: profileInfo.type, + cliproxyBridgeProvider: cliproxyBridge?.provider ?? null, + }); + if (!compatibility.supported) { + console.error( + fail( + compatibility.reason || + `${targetAdapter?.displayName || resolvedTarget} does not support this profile.` + ) + ); + if (compatibility.suggestion) { + console.error(info(compatibility.suggestion)); + } + process.exit(1); + } + } + + const rawSettingsEnv = profileInfo.env ?? settings.env ?? {}; + const isDeprecatedGlmtProfile = isDeprecatedGlmtProfileName(profileInfo.name); + const glmtNormalization = isDeprecatedGlmtProfile + ? normalizeDeprecatedGlmtEnv(rawSettingsEnv) + : null; + const settingsEnv = glmtNormalization?.env ?? rawSettingsEnv; + + if (glmtNormalization) { + for (const message of glmtNormalization.warnings) { + console.error(warn(message)); + } + } + + // Pre-flight validation for Z.AI-compatible profiles. + if (profileInfo.name === 'glm' || isDeprecatedGlmtProfile) { + const apiKey = settingsEnv['ANTHROPIC_AUTH_TOKEN']; + if (apiKey) { + const validation = await validateGlmKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']); + if (!validation.valid) { + console.error(''); + console.error(fail(validation.error || 'API key validation failed')); + if (validation.suggestion) { + console.error(''); + console.error(validation.suggestion); + } + console.error(''); + console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs glm "prompt"')); + process.exit(1); + } + } + } + + if (profileInfo.name === 'mm') { + const apiKey = settingsEnv['ANTHROPIC_AUTH_TOKEN']; + if (apiKey) { + const validation = await validateMiniMaxKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']); + if (!validation.valid) { + console.error(''); + console.error(fail(validation.error || 'API key validation failed')); + if (validation.suggestion) { + console.error(''); + console.error(validation.suggestion); + } + console.error(''); + console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs mm "prompt"')); + process.exit(1); + } + } + } + + // Pre-flight validation for Anthropic direct profiles (ANTHROPIC_API_KEY + no BASE_URL) + { + const anthropicApiKey = settingsEnv['ANTHROPIC_API_KEY']; + const hasBaseUrl = !!settingsEnv['ANTHROPIC_BASE_URL']; + if (anthropicApiKey && !hasBaseUrl) { + const validation = await validateAnthropicKey(anthropicApiKey); + if (!validation.valid) { + console.error(''); + console.error(fail(validation.error || 'API key validation failed')); + if (validation.suggestion) { + console.error(''); + console.error(validation.suggestion); + } + console.error(''); + console.error( + info(`To skip validation: CCS_SKIP_PREFLIGHT=1 ccs ${profileInfo.name} "prompt"`) + ); + process.exit(1); + } + } + } + + // Image analysis env resolution (split into sibling helper to stay under LOC budget) + const imageAnalysisEnv = await resolveSettingsImageAnalysisEnv({ + profileInfo, + resolvedTarget, + settings, + cliproxyBridge, + imageAnalysisMcpReady, + imageAnalysisFallbackHookReady, + remainingArgs, + targetRemainingArgs, + }); + + const webSearchEnv = getWebSearchHookEnv(); + + // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles + const globalEnvConfig = getGlobalEnvConfig(); + const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; + + // Log global env injection for visibility (debug mode only) + if (globalEnvConfig.enabled && Object.keys(globalEnv).length > 0 && process.env.CCS_DEBUG) { + const envNames = Object.keys(globalEnv).join(', '); + console.error(info(`Global env: ${envNames}`)); + } + + // For Claude target launches that already pass `--settings`, keep runtime env free of + // ANTHROPIC routing/auth while preserving non-routing profile env so nested Team/subagent + // sessions can still inherit model intent and other profile-scoped runtime flags. + const settingsRuntimeEnv = stripBrowserEnv({ ...globalEnv, ...settingsEnv }); + const claudeRuntimeEnvVars: NodeJS.ProcessEnv = { + ...stripAnthropicRoutingEnv(settingsRuntimeEnv), + ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), + ...webSearchEnv, + ...imageAnalysisEnv, + ...(browserRuntimeEnv || {}), + CCS_PROFILE_TYPE: 'settings', + CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1', + }; + + // Non-Claude targets still need effective credentials injected directly. + const envVars: NodeJS.ProcessEnv = { + ...settingsRuntimeEnv, + ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), + ...webSearchEnv, + ...imageAnalysisEnv, + ...(browserRuntimeEnv || {}), + CCS_PROFILE_TYPE: 'settings', + }; + + // Dispatch through target adapter for non-claude targets + if (resolvedTarget !== 'claude') { + const adapter = targetAdapter; + if (!adapter) { + console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); + process.exit(1); + } + const directAnthropicBaseUrl = + settingsEnv['ANTHROPIC_BASE_URL'] || + (settingsEnv['ANTHROPIC_API_KEY'] ? 'https://api.anthropic.com' : ''); + const creds: TargetCredentials = { + profile: profileInfo.name, + baseUrl: directAnthropicBaseUrl, + apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || settingsEnv['ANTHROPIC_API_KEY'] || '', + model: settingsEnv['ANTHROPIC_MODEL'], + provider: resolveDroidProvider({ + provider: settingsEnv['CCS_DROID_PROVIDER'] || settingsEnv['DROID_PROVIDER'], + baseUrl: directAnthropicBaseUrl, + model: settingsEnv['ANTHROPIC_MODEL'], + }), + reasoningOverride: runtimeReasoningOverride, + runtimeConfigOverrides: codexRuntimeConfigOverrides, + envVars, + }; + await adapter.prepareCredentials(creds); + const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs, { + creds, + profileType: profileInfo.type, + binaryInfo: targetBinaryInfo || undefined, + }); + const targetEnv = adapter.buildEnv(creds, profileInfo.type); + adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); + return; + } + + const imageAnalysisArgs = imageAnalysisMcpReady + ? appendThirdPartyImageAnalysisToolArgs(nativeClaudeRemainingArgs) + : nativeClaudeRemainingArgs; + const browserArgs = browserRuntimeEnv + ? appendBrowserToolArgs(imageAnalysisArgs) + : imageAnalysisArgs; + const openAICompatProfile = resolveOpenAICompatProfileConfig( + profileInfo.name, + expandedSettingsPath, + settingsEnv + ); + if (openAICompatProfile) { + const proxyStart = await startOpenAICompatProxy(openAICompatProfile, { + insecure: openAICompatProfile.insecure, + }); + if (!proxyStart.success) { + console.error(fail(proxyStart.error || 'Failed to start local OpenAI-compatible proxy')); + process.exit(1); + } + + console.error( + info( + `Using local OpenAI-compatible proxy for "${profileInfo.name}" on port ${proxyStart.port}` + ) + ); + + const proxyEnv = { + ...envVars, + ...buildOpenAICompatProxyEnv( + openAICompatProfile, + proxyStart.port, + proxyStart.authToken || '', + inheritedClaudeConfigDir + ), + }; + delete proxyEnv.ANTHROPIC_API_KEY; + const launchSettings = createOpenAICompatLaunchSettings(expandedSettingsPath, settings); + + const launchArgs = [ + '--settings', + launchSettings.settingsPath, + ...appendThirdPartyWebSearchToolArgs(browserArgs), + ]; + const traceEnv = createWebSearchTraceContext({ + launcher: 'ccs.settings-profile.proxy', + args: launchArgs, + profile: profileInfo.name, + profileType: profileInfo.type, + settingsPath: expandedSettingsPath, + }); + + execClaude(claudeCli, launchArgs, { ...proxyEnv, ...traceEnv }, launchSettings.cleanup); + return; + } + + const launchArgs = [ + '--settings', + expandedSettingsPath, + ...appendThirdPartyWebSearchToolArgs(browserArgs), + ]; + const traceEnv = createWebSearchTraceContext({ + launcher: 'ccs.settings-profile', + args: launchArgs, + profile: profileInfo.name, + profileType: profileInfo.type, + settingsPath: expandedSettingsPath, + }); + + execClaude(claudeCli, launchArgs, { ...claudeRuntimeEnvVars, ...traceEnv }); +} diff --git a/src/dispatcher/flows/settings-image-analysis-prep.ts b/src/dispatcher/flows/settings-image-analysis-prep.ts new file mode 100644 index 00000000..3a20fc19 --- /dev/null +++ b/src/dispatcher/flows/settings-image-analysis-prep.ts @@ -0,0 +1,126 @@ +/** + * Image analysis environment preparation for settings-based profiles. + * + * Split from settings-flow.ts to keep that file under the 300 LOC budget. + * Resolves runtime status, connection, and env overrides for image analysis MCP. + */ + +import { warn, info } from '../../utils/ui'; +import { + applyImageAnalysisRuntimeOverrides, + getImageAnalysisHookEnv, + resolveImageAnalysisRuntimeConnection, + resolveImageAnalysisRuntimeStatus, +} from '../../utils/hooks'; +import { ensureCliproxyService } from '../../cliproxy'; +import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager'; +import type { ProfileDetectionResult } from '../../auth/profile-detector'; +import type { loadSettings } from '../../config/config-loader-facade'; +import type { resolveCliproxyBridgeMetadata } from '../../api/services/cliproxy-profile-bridge'; +import type { resolveTargetType } from '../../targets/target-resolver'; + +export interface SettingsImageAnalysisPrepContext { + profileInfo: ProfileDetectionResult; + resolvedTarget: ReturnType; + settings: ReturnType; + cliproxyBridge: ReturnType | undefined; + imageAnalysisMcpReady: boolean; + imageAnalysisFallbackHookReady: boolean | undefined; + remainingArgs: string[]; + targetRemainingArgs: string[]; +} + +/** + * Resolve the complete image analysis env vars for a settings-based profile launch. + * Handles native-read fallback and proxy-stopped recovery. + */ +export async function resolveSettingsImageAnalysisEnv( + ctx: SettingsImageAnalysisPrepContext +): Promise { + const { + profileInfo, + resolvedTarget, + settings, + cliproxyBridge, + imageAnalysisMcpReady, + imageAnalysisFallbackHookReady, + remainingArgs, + targetRemainingArgs, + } = ctx; + + const imageAnalysisStatus = await resolveImageAnalysisRuntimeStatus({ + profileName: profileInfo.name, + profileType: profileInfo.type, + settings, + cliproxyBridge, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + const runtimeConnection = resolveImageAnalysisRuntimeConnection(); + let imageAnalysisEnv = getImageAnalysisHookEnv({ + profileName: profileInfo.name, + profileType: profileInfo.type, + settings, + cliproxyBridge, + }); + imageAnalysisEnv = applyImageAnalysisRuntimeOverrides(imageAnalysisEnv, { + backendId: imageAnalysisStatus.backendId, + model: imageAnalysisStatus.model, + runtimePath: imageAnalysisStatus.runtimePath, + baseUrl: runtimeConnection.baseUrl, + apiKey: runtimeConnection.apiKey, + allowSelfSigned: runtimeConnection.allowSelfSigned, + }); + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_IMAGE_ANALYSIS_SKIP_HOOK: resolvedTarget === 'claude' && imageAnalysisMcpReady ? '1' : '0', + }; + + const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER']; + if ( + resolvedTarget === 'claude' && + imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' && + imageAnalysisProvider + ) { + const verboseProxyLaunch = + remainingArgs.includes('--verbose') || + remainingArgs.includes('-v') || + targetRemainingArgs.includes('--verbose') || + targetRemainingArgs.includes('-v'); + + if (imageAnalysisStatus.effectiveRuntimeMode === 'native-read') { + console.error( + info( + `${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This session will use native Read.` + ) + ); + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '', + CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: '', + CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: '', + CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED: '0', + }; + } else if (imageAnalysisStatus.proxyReadiness === 'stopped') { + const ensureServiceResult = await ensureCliproxyService( + CLIPROXY_DEFAULT_PORT, + verboseProxyLaunch + ); + if (!ensureServiceResult.started) { + console.error( + warn( + `Image analysis via ${imageAnalysisProvider} is unavailable because CCS could not start the local CLIProxy service. This session will use native Read.` + ) + ); + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + } + } + } + + return imageAnalysisEnv; +} diff --git a/src/dispatcher/pre-dispatch.ts b/src/dispatcher/pre-dispatch.ts new file mode 100644 index 00000000..725f2240 --- /dev/null +++ b/src/dispatcher/pre-dispatch.ts @@ -0,0 +1,155 @@ +/** + * Pre-dispatch side-effect handlers. + * + * Extracted from src/ccs.ts (Phase B, lines 145-254 after Phase A extraction). + * Each handler may short-circuit the dispatch by returning true. + * + * Handles: update check, auto-migrate, recovery-manager, root-command router, + * provider help shortcut, copilot/cursor subcommand routing, first-time install hint. + */ + +import { info } from '../utils/ui'; +import { isCLIProxyProvider } from '../cliproxy/provider-capabilities'; +import { isCopilotSubcommandToken } from '../copilot/constants'; +import { isCursorSubcommandToken, LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants'; +import { isCacheStale } from '../utils/update-checker'; +import { tryHandleRootCommand } from '../commands/root-command-router'; +import { refreshUpdateCache, showCachedUpdateNotification } from './environment-builder'; +import { printCursorLegacySubcommandDeprecation } from './cli-argument-parser'; +import type { Logger } from '../services/logging/logger'; + +// ========== Pre-Dispatch Context ========== + +export interface PreDispatchContext { + args: string[]; + cliLogger: Logger; +} + +// ========== Pre-Dispatch Runner ========== + +/** + * Run all pre-dispatch side-effect handlers in sequence. + * Returns true if a handler consumed the command (caller should return immediately). + * Returns false if dispatch should continue normally. + * + * All process.exit calls and dynamic imports are preserved at original call sites. + */ +export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise { + const { args, cliLogger } = ctx; + const firstArg = args[0] as string | undefined; + + // Update check: show cached notification + refresh background cache + const skipUpdateCheck = [ + 'version', + '--version', + '-v', + 'help', + '--help', + '-h', + 'update', + '--update', + ]; + if (process.stdout.isTTY && !process.env['CI'] && !skipUpdateCheck.includes(firstArg ?? '')) { + // 1. Show cached update notification (async for proper UI) + await showCachedUpdateNotification(); + + // 2. Refresh cache in background if stale (non-blocking) + if (isCacheStale()) { + refreshUpdateCache(); + } + } + + // Auto-migrate to unified config format (silent if already migrated) + // Skip if user is explicitly running migrate command + if (firstArg !== 'migrate') { + const { autoMigrate } = await import('../config/migration-manager'); + await autoMigrate(); + } + + // Auto-recovery for missing configuration (BEFORE any early-exit commands) + // Recovery is safe to run early - it only creates missing files with safe defaults + // Wrapped in try-catch to prevent blocking --version/--help on permission errors + try { + const RecoveryManagerModule = await import('../management/recovery-manager'); + const RecoveryManager = RecoveryManagerModule.default; + const recovery = new RecoveryManager(); + const recovered = recovery.recoverAll(); + + if (recovered) { + recovery.showRecoveryHints(); + } + } catch (err) { + cliLogger.warn('recovery.failed', 'Auto-recovery failed during CLI startup', { + message: (err as Error).message, + }); + // Recovery is best-effort - don't block basic CLI functionality + console.warn('[!] Recovery failed:', (err as Error).message); + } + + // Root command router (handles --help, --version, config, doctor, etc.) + if (await tryHandleRootCommand(args)) { + return true; + } + + // Provider help shortcut: `ccs gemini --help` → provider-specific help page + if ( + typeof firstArg === 'string' && + isCLIProxyProvider(firstArg) && + args.length > 1 && + (args.includes('--help') || args.includes('-h')) + ) { + const { showProviderShortcutHelp } = await import('../commands/help-command'); + await showProviderShortcutHelp(firstArg); + return true; + } + + // Special case: copilot command (GitHub Copilot integration) + // Route known subcommands to command handler, keep all other args as profile passthrough. + if (firstArg === 'copilot' && args.length > 1) { + const copilotToken = args[1]; + const shouldRouteToCopilotCommand = isCopilotSubcommandToken(copilotToken); + + if (shouldRouteToCopilotCommand) { + const { handleCopilotCommand } = await import('../commands/copilot-command'); + const exitCode = await handleCopilotCommand(args.slice(1)); + process.exit(exitCode); + } + } + + // Special case: explicit legacy Cursor bridge namespace. + if (firstArg === LEGACY_CURSOR_PROFILE_NAME && args.length > 1) { + const { handleCursorCommand } = await import('../commands/cursor-command'); + const cursorToken = args[1]; + + if (isCursorSubcommandToken(cursorToken)) { + const exitCode = await handleCursorCommand(args.slice(1)); + process.exit(exitCode); + } + } + + // Compatibility shim: old `ccs cursor ` still forwards to the legacy bridge + // for one migration window, but bare/positional `ccs cursor` now belongs to CLIProxy. + if (firstArg === 'cursor' && args.length > 1) { + const { handleCursorCommand } = await import('../commands/cursor-command'); + const cursorToken = args[1]; + + if (isCursorSubcommandToken(cursorToken) && cursorToken !== '--help' && cursorToken !== '-h') { + printCursorLegacySubcommandDeprecation(cursorToken); + const exitCode = await handleCursorCommand(args.slice(1)); + process.exit(exitCode); + } + } + + // First-time install: offer setup wizard for interactive users + // Check independently of recovery status (user may have empty config.yaml) + // Skip if headless, CI, or non-TTY environment + const { isFirstTimeInstall } = await import('../commands/setup-command'); + if (process.stdout.isTTY && !process.env['CI'] && isFirstTimeInstall()) { + console.log(''); + console.log(info('First-time install detected. Run `ccs setup` for guided configuration.')); + console.log(' Or use `ccs config` for the web dashboard.'); + console.log(''); + } + + return false; +} diff --git a/src/dispatcher/profile-resolver.ts b/src/dispatcher/profile-resolver.ts new file mode 100644 index 00000000..bff9cbd0 --- /dev/null +++ b/src/dispatcher/profile-resolver.ts @@ -0,0 +1,359 @@ +/** + * Profile and target resolution — Phase C extraction from src/ccs.ts. + * + * Extracted from main() lines ~136–313 (after Phase B pre-dispatch). + * Responsible for: dynamic imports of auth modules, profile detection, target type + * resolution, Claude CLI detection, target adapter resolution, compatibility preflight + * checks (preserved with duplication per plan risk note 5), binary detection, and + * per-target argument normalization (droid routing + reasoning flag stripping, + * codex reasoning normalization, native Claude effort normalization). + * + * Outputs a ResolvedProfile context object consumed by Phase D (environment-builder + * argument normalization) and Phase E (per-profile dispatch flows). + */ + +import { detectClaudeCli } from '../utils/claude-detector'; +import { getSettingsPath } from '../utils/config-manager'; +import { loadSettings } from '../config/config-loader-facade'; +import { expandPath } from '../utils/helpers'; +import { fail, info, warn } from '../utils/ui'; +import { ErrorManager } from '../utils/error-manager'; +import { getBrowserConfig } from '../config/config-loader-facade'; +import { + getEffectiveClaudeBrowserAttachConfig, + resolveBrowserExposure, + getBlockedBrowserOverrideWarning, +} from '../utils/browser'; +import { getTarget, evaluateTargetRuntimeCompatibility, pruneOrphanedModels } from '../targets'; +import { resolveTargetType, stripTargetFlag } from '../targets/target-resolver'; +import { DroidReasoningFlagError } from '../targets/droid-reasoning-runtime'; +import { DroidCommandRouterError, routeDroidCommandArgs } from '../targets/droid-command-router'; +import { resolveCliproxyBridgeMetadata } from '../api/services/cliproxy-profile-bridge'; +import { resolveCodexRuntimeConfigOverrides } from './environment-builder'; +import { + detectProfile, + resolveRuntimeReasoningFlags, + normalizeCodexRuntimeReasoningOverride, + exitWithRuntimeReasoningFlagError, + normalizeNativeClaudeEffortArgs, + shouldNormalizeNativeClaudeEffort, +} from './cli-argument-parser'; +import type ProfileDetector from '../auth/profile-detector'; +import type { ProfileDetectionResult } from '../auth/profile-detector'; +import type { BrowserLaunchOverride } from '../utils/browser'; +import type { ResolvedBrowserExposure } from '../utils/browser/browser-policy'; +import type { Logger } from '../services/logging/logger'; +import type { TargetAdapter, TargetBinaryInfo } from '../targets'; + +// ========== Interfaces ========== + +export interface ProfileResolutionContext { + args: string[]; + browserLaunchOverride: BrowserLaunchOverride | undefined; + cliLogger: Logger; +} + +export interface ResolvedProfile { + /** Detected profile name (first positional arg or 'default') */ + profile: string; + /** Args remaining after stripping profile token and --target flags */ + remainingArgs: string[]; + profileInfo: ProfileDetectionResult; + resolvedTarget: ReturnType; + claudeCli: string; + targetAdapter: TargetAdapter | null; + targetBinaryInfo: TargetBinaryInfo | null; + resolvedSettingsPath: string | undefined; + resolvedSettings: ReturnType | undefined; + resolvedCliproxyBridge: ReturnType | undefined; + /** Per-target normalized args (droid/codex routing applied) */ + targetRemainingArgs: string[]; + /** Claude-specific args with effort normalization applied */ + nativeClaudeRemainingArgs: string[]; + runtimeReasoningOverride: string | number | undefined; + codexRuntimeConfigOverrides: string[]; + claudeBrowserExposure: ResolvedBrowserExposure | undefined; + codexBrowserExposure: ResolvedBrowserExposure | undefined; + claudeAttachConfig: ReturnType | undefined; + /** ProfileDetector instance — Phase E account/settings flows need getAllProfiles() */ + detector: InstanceType; +} + +// ========== Profile and Target Resolver ========== + +/** + * Resolve profile detection, target type, adapter, binary, and per-target arg normalization. + * + * Throws ProfileError (caught by main() try/catch) for unknown profiles. + * Calls process.exit(1) for hard-stop conditions (adapter not found, binary not found, etc.). + * + * NOTE: The compatibility preflight block for non-settings profiles (else branch) is + * INTENTIONALLY duplicated between here (Phase C) and the settings flow (Phase E). + * Per plan risk note 5, do NOT dedupe in this PR. + */ +export async function resolveProfileAndTarget( + ctx: ProfileResolutionContext +): Promise { + const { args, browserLaunchOverride } = ctx; + + // Dynamic imports — preserved at original call sites for latency/circular-dep avoidance. + // InstanceManager, ProfileRegistry, AccountContext, ProfileContinuity are loaded here + // to match original ordering; their resolved values flow into Phase E via the detector + // instance or are re-imported inside flow handlers. + const ProfileDetectorModule = await import('../auth/profile-detector'); + const ProfileDetectorClass = ProfileDetectorModule.default; + await import('../management/instance-manager'); + await import('../auth/profile-registry'); + await import('../auth/account-context'); + await import('../auth/profile-continuity-inheritance'); + + const detector = new ProfileDetectorClass(); + + // Detect profile (strip --target flags before profile detection) + const cleanArgs = stripTargetFlag(args); + const { profile, remainingArgs } = detectProfile(cleanArgs); + const profileInfo: ProfileDetectionResult = detector.detectProfileType(profile); + + let resolvedTarget: ReturnType; + try { + resolvedTarget = resolveTargetType( + args, + profileInfo.target ? { target: profileInfo.target } : undefined + ); + } catch (error) { + console.error(fail((error as Error).message)); + process.exit(1); + // Unreachable; needed so TS knows resolvedTarget is always assigned below + throw error; + } + + // Detect Claude CLI (needed for claude target and all CLIProxy-derived flows) + const claudeCliRaw = detectClaudeCli(); + if (resolvedTarget === 'claude' && !claudeCliRaw) { + await ErrorManager.showClaudeNotFound(); + process.exit(1); + } + const claudeCli = claudeCliRaw || ''; + + // Resolve non-claude target adapter once. + const targetAdapter: TargetAdapter | null = + resolvedTarget !== 'claude' ? (getTarget(resolvedTarget) ?? null) : null; + let resolvedSettingsPath: string | undefined; + let resolvedSettings: ReturnType | undefined; + let resolvedCliproxyBridge: ReturnType | undefined; + + // Preflight unsupported profile/target combinations BEFORE binary detection, + // so users get the most actionable error even when the target CLI is not installed. + if (resolvedTarget !== 'claude') { + if (!targetAdapter) { + console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); + process.exit(1); + } + + if (profileInfo.type === 'settings') { + resolvedSettingsPath = profileInfo.settingsPath + ? expandPath(profileInfo.settingsPath) + : getSettingsPath(profileInfo.name); + resolvedSettings = loadSettings(resolvedSettingsPath); + resolvedCliproxyBridge = resolveCliproxyBridgeMetadata(resolvedSettings); + const compatibility = evaluateTargetRuntimeCompatibility({ + target: resolvedTarget, + profileType: profileInfo.type, + cliproxyBridgeProvider: resolvedCliproxyBridge?.provider ?? null, + }); + if (!compatibility.supported) { + console.error( + fail( + compatibility.reason || `${targetAdapter.displayName} does not support this profile.` + ) + ); + if (compatibility.suggestion) { + console.error(info(compatibility.suggestion)); + } + process.exit(1); + } + } else { + // NOTE: Intentional duplication — Phase E settings flow re-runs this check post-load. + // Per plan risk note 5, preserve verbatim; do NOT dedupe. + const compatibility = evaluateTargetRuntimeCompatibility({ + target: resolvedTarget, + profileType: profileInfo.type, + cliproxyProvider: profileInfo.type === 'cliproxy' ? profileInfo.provider : undefined, + isComposite: profileInfo.type === 'cliproxy' ? Boolean(profileInfo.isComposite) : undefined, + }); + if (!compatibility.supported) { + console.error( + fail( + compatibility.reason || `${targetAdapter.displayName} does not support this profile.` + ) + ); + if (compatibility.suggestion) { + console.error(info(compatibility.suggestion)); + } + process.exit(1); + } + } + + if (profileInfo.type === 'default') { + if (!targetAdapter.supportsProfileType('default')) { + console.error(fail(`${targetAdapter.displayName} does not support default profile mode`)); + process.exit(1); + } + + // For default mode, Droid requires explicit credentials from environment. + if (resolvedTarget === 'droid') { + const baseUrl = process.env['ANTHROPIC_BASE_URL'] || ''; + const apiKey = process.env['ANTHROPIC_AUTH_TOKEN'] || ''; + if (!baseUrl.trim() || !apiKey.trim()) { + console.error( + fail( + `${targetAdapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN` + ) + ); + console.error(info('Use a settings-based profile instead: ccs glm --target droid')); + process.exit(1); + } + } + } + } + + // For non-claude targets, verify target binary exists once and pass it through. + const targetBinaryInfo: TargetBinaryInfo | null = targetAdapter?.detectBinary() ?? null; + const browserConfig = getBrowserConfig(); + const claudeAttachConfig = + resolvedTarget === 'claude' ? getEffectiveClaudeBrowserAttachConfig(browserConfig) : undefined; + const codexRuntimeConfigOverrides = resolveCodexRuntimeConfigOverrides( + resolvedTarget, + browserLaunchOverride + ); + const claudeBrowserExposure: ResolvedBrowserExposure | undefined = + resolvedTarget === 'claude' + ? resolveBrowserExposure( + { + enabled: claudeAttachConfig?.enabled ?? browserConfig.claude.enabled, + policy: browserConfig.claude.policy, + }, + browserLaunchOverride + ) + : undefined; + const codexBrowserExposure: ResolvedBrowserExposure | undefined = + resolvedTarget === 'codex' + ? resolveBrowserExposure(browserConfig.codex, browserLaunchOverride) + : undefined; + const blockedBrowserOverrideWarning = + resolvedTarget === 'claude' && claudeBrowserExposure + ? getBlockedBrowserOverrideWarning('Claude Browser Attach', claudeBrowserExposure) + : resolvedTarget === 'codex' && codexBrowserExposure + ? getBlockedBrowserOverrideWarning('Codex Browser Tools', codexBrowserExposure) + : undefined; + if (blockedBrowserOverrideWarning) { + console.error(warn(blockedBrowserOverrideWarning)); + } + if (resolvedTarget !== 'claude' && !targetBinaryInfo) { + const displayName = targetAdapter?.displayName || resolvedTarget; + console.error(fail(`${displayName} CLI not found.`)); + if (resolvedTarget === 'droid') { + console.error(info('Install: npm i -g @factory/cli')); + } else if (resolvedTarget === 'codex') { + console.error(info('Install a recent @openai/codex build, then retry.')); + } + process.exit(1); + } + + // Best-effort: prune stale Droid model entries at runtime so settings.json stays clean. + if (resolvedTarget === 'droid') { + try { + const allProfiles = detector.getAllProfiles(); + const activeProfiles = allProfiles.settings.filter((name) => /^[a-zA-Z0-9._-]+$/.test(name)); + await pruneOrphanedModels(activeProfiles); + } catch (error) { + console.error(warn(`[!] Droid prune skipped: ${(error as Error).message}`)); + } + } + + // Per-target argument normalization + let targetRemainingArgs = remainingArgs; + let runtimeReasoningOverride: string | number | undefined; + let nativeClaudeRemainingArgs = remainingArgs; + + if (resolvedTarget === 'droid') { + try { + const droidRoute = routeDroidCommandArgs(remainingArgs); + targetRemainingArgs = droidRoute.argsForDroid; + + if (droidRoute.mode === 'interactive') { + const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env['CCS_THINKING']); + targetRemainingArgs = runtime.argsWithoutReasoningFlags; + runtimeReasoningOverride = runtime.reasoningOverride; + } else { + if (droidRoute.duplicateReasoningDisplays.length > 0) { + console.error( + warn( + `[!] Multiple reasoning flags detected. Using first occurrence: ${droidRoute.reasoningSourceDisplay || ''}` + ) + ); + } + if (droidRoute.autoPrependedExec && process.stdout.isTTY) { + console.error( + info('Detected Droid exec-only flags. Routing as: droid exec [prompt]') + ); + } + } + } catch (error) { + if (error instanceof DroidReasoningFlagError || error instanceof DroidCommandRouterError) { + exitWithRuntimeReasoningFlagError(error.message, { + codexAliasLevels: 'minimal|low|medium|high|xhigh', + includeDroidExecExample: true, + }); + } + throw error; + } + } else if (resolvedTarget === 'codex') { + try { + const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env['CCS_THINKING']); + targetRemainingArgs = runtime.argsWithoutReasoningFlags; + const normalizedReasoning = normalizeCodexRuntimeReasoningOverride(runtime.reasoningOverride); + if (runtime.reasoningOverride !== undefined && !normalizedReasoning) { + if (runtime.reasoningSource === 'flag') { + throw new DroidReasoningFlagError( + 'Codex target supports reasoning levels only: minimal, low, medium, high, xhigh.', + '--effort' + ); + } + runtimeReasoningOverride = undefined; + } else { + runtimeReasoningOverride = normalizedReasoning; + } + } catch (error) { + if (error instanceof DroidReasoningFlagError) { + exitWithRuntimeReasoningFlagError(error.message, { + codexAliasLevels: 'minimal|low|medium|high|xhigh', + }); + } + throw error; + } + } else if (resolvedTarget === 'claude' && shouldNormalizeNativeClaudeEffort(profileInfo.type)) { + nativeClaudeRemainingArgs = normalizeNativeClaudeEffortArgs(remainingArgs); + } + + return { + profile, + remainingArgs, + profileInfo, + resolvedTarget, + claudeCli, + targetAdapter, + targetBinaryInfo, + resolvedSettingsPath, + resolvedSettings, + resolvedCliproxyBridge, + targetRemainingArgs, + nativeClaudeRemainingArgs, + runtimeReasoningOverride, + codexRuntimeConfigOverrides, + claudeBrowserExposure, + codexBrowserExposure, + claudeAttachConfig, + detector, + }; +} diff --git a/src/dispatcher/target-executor.ts b/src/dispatcher/target-executor.ts new file mode 100644 index 00000000..7016caf2 --- /dev/null +++ b/src/dispatcher/target-executor.ts @@ -0,0 +1,94 @@ +/** + * Native target execution — short-circuit dispatch for passthrough flag commands, + * and top-level profile dispatcher (Phase E switch). + * + * Extracted from src/ccs.ts (lines 291-295, 394-426). + * Handles direct execution of native Codex flag passthrough (--help, --version, etc.) + * before the main profile dispatch loop runs. + * + * dispatchProfile() collapses the 6-branch switch in main() to a single call. + */ + +import { fail, info } from '../utils/ui'; +import { getTarget } from '../targets'; +import { getNativeCodexPassthroughArgs } from './cli-argument-parser'; +import { runCliproxyFlow } from './flows/cliproxy-flow'; +import { runCopilotFlow } from './flows/copilot-flow'; +import { runCursorFlow } from './flows/cursor-flow'; +import { runSettingsFlow } from './flows/settings-flow'; +import { runAccountFlow } from './flows/account-flow'; +import { runDefaultFlow } from './flows/default-flow'; +import type { TargetCredentials } from '../targets'; +import type { ProfileDispatchContext } from './dispatcher-context'; + +// ========== Interfaces ========== + +export interface ProfileError extends Error { + profileName?: string; + availableProfiles?: string; + suggestions?: string[]; +} + +// ========== Native Codex Flag Command Executor ========== + +export function execNativeCodexFlagCommand(args: string[]): void { + const adapter = getTarget('codex'); + if (!adapter) { + console.error(fail('Target adapter not found for "codex"')); + process.exit(1); + } + + const binaryInfo = adapter.detectBinary(); + if (!binaryInfo) { + console.error(fail('Codex CLI not found.')); + console.error(info('Install a recent @openai/codex build, then retry.')); + process.exit(1); + } + + const targetArgs = getNativeCodexPassthroughArgs(args); + if (!targetArgs) { + console.error(fail('Native Codex passthrough args could not be resolved.')); + process.exit(1); + } + const creds: TargetCredentials = { + profile: 'default', + baseUrl: '', + apiKey: '', + }; + + const builtArgs = adapter.buildArgs('default', targetArgs, { + creds, + profileType: 'default', + binaryInfo, + }); + const targetEnv = adapter.buildEnv(creds, 'default'); + adapter.exec(builtArgs, targetEnv, { binaryInfo }); +} + +// ========== Profile Dispatcher ========== + +/** + * Dispatch to the correct per-profile-type flow. + * + * Collapses the 6-branch if/else-if switch that previously lived in main() into a + * single call site. The headless -p delegation short-circuit is handled in main() + * before this function is called. + */ +export async function dispatchProfile(ctx: ProfileDispatchContext): Promise { + const { profileInfo } = ctx; + + switch (profileInfo.type) { + case 'cliproxy': + return runCliproxyFlow(ctx); + case 'copilot': + return runCopilotFlow(ctx); + case 'cursor': + return runCursorFlow(ctx); + case 'settings': + return runSettingsFlow(ctx); + case 'account': + return runAccountFlow(ctx); + default: + return runDefaultFlow(ctx); + } +} diff --git a/src/docker/docker-assets.ts b/src/docker/docker-assets.ts index 1ebf58aa..b40c6808 100644 --- a/src/docker/docker-assets.ts +++ b/src/docker/docker-assets.ts @@ -1,6 +1,7 @@ import * as path from 'path'; -import { getCcsDir } from '../utils/config-manager'; + import type { DockerConfigSummary } from './docker-types'; +import { getCcsDir } from '../config/config-loader-facade'; export const DOCKER_REMOTE_DIR = '~/.ccs/docker'; export const DOCKER_COMPOSE_SERVICE = 'ccs-cliproxy'; diff --git a/src/glmt/glmt-transformer.ts b/src/glmt/glmt-transformer.ts index 72c4c63e..68a18b6f 100644 --- a/src/glmt/glmt-transformer.ts +++ b/src/glmt/glmt-transformer.ts @@ -11,7 +11,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { DeltaAccumulator } from './delta-accumulator'; -import { getCcsDir } from '../utils/config-manager'; + import { createLogger } from '../services/logging'; import { RequestTransformer, @@ -32,6 +32,7 @@ import { type ThinkingSignature, type ValidationResult, } from './pipeline'; +import { getCcsDir } from '../config/config-loader-facade'; export class GlmtTransformer { private verbose: boolean; diff --git a/src/management/checks/config-check.ts b/src/management/checks/config-check.ts index 8c1af017..b706852b 100644 --- a/src/management/checks/config-check.ts +++ b/src/management/checks/config-check.ts @@ -6,8 +6,9 @@ import * as fs from 'fs'; import * as path from 'path'; import { ok, fail, warn, info } from '../../utils/ui'; import { HealthCheck, IHealthChecker, createSpinner } from './types'; -import { getCcsDir } from '../../utils/config-manager'; + import { getClaudeConfigDir } from '../../utils/claude-config-path'; +import { getCcsDir } from '../../config/config-loader-facade'; const ora = createSpinner(); diff --git a/src/management/checks/env-check.ts b/src/management/checks/env-check.ts index 95a34a61..0dab2952 100644 --- a/src/management/checks/env-check.ts +++ b/src/management/checks/env-check.ts @@ -4,8 +4,9 @@ import { getEnvironmentDiagnostics } from '../environment-diagnostics'; import { ok, warn } from '../../utils/ui'; -import { getCcsDir, getCcsDirSource } from '../../utils/config-manager'; +import { getCcsDirSource } from '../../utils/config-manager'; import { HealthCheck, IHealthChecker, createSpinner } from './types'; +import { getCcsDir } from '../../config/config-loader-facade'; const ora = createSpinner(); diff --git a/src/management/checks/image-analysis-check.ts b/src/management/checks/image-analysis-check.ts index 85fe7e3a..50e9ba99 100644 --- a/src/management/checks/image-analysis-check.ts +++ b/src/management/checks/image-analysis-check.ts @@ -5,7 +5,6 @@ * Checks: enabled status, provider_models, timeout, CLIProxy availability. */ -import { getImageAnalysisConfig } from '../../config/unified-config-loader'; import { DEFAULT_IMAGE_ANALYSIS_CONFIG } from '../../config/unified-config-types'; import { countManagedImageAnalysisHookFiles, @@ -17,6 +16,7 @@ import { isCliproxyRunning } from '../../cliproxy/services/stats-fetcher'; import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/config-generator'; import type { HealthCheck } from './types'; import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer'; +import { getImageAnalysisConfig } from '../../config/config-loader-facade'; /** * Run image analysis configuration check @@ -112,8 +112,8 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise * Fix image analysis configuration issues */ export async function fixImageAnalysisConfig(): Promise { - const { updateUnifiedConfig, loadOrCreateUnifiedConfig } = await import( - '../../config/unified-config-loader' + const { updateConfig, loadOrCreateUnifiedConfig } = await import( + '../../config/config-loader-facade' ); const config = loadOrCreateUnifiedConfig(); @@ -145,7 +145,7 @@ export async function fixImageAnalysisConfig(): Promise { } if (fixed) { - updateUnifiedConfig({ image_analysis: config.image_analysis }); + updateConfig({ image_analysis: config.image_analysis }); } const repairStats = repairImageAnalysisRuntimeState(); diff --git a/src/management/checks/profile-check.ts b/src/management/checks/profile-check.ts index d1cd555b..5c969a83 100644 --- a/src/management/checks/profile-check.ts +++ b/src/management/checks/profile-check.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { ok, fail, warn, info } from '../../utils/ui'; import { HealthCheck, IHealthChecker, createSpinner } from './types'; -import { getCcsDir } from '../../utils/config-manager'; +import { getCcsDir } from '../../config/config-loader-facade'; const ora = createSpinner(); diff --git a/src/management/checks/symlink-check.ts b/src/management/checks/symlink-check.ts index 0d4a6fa7..56f7d0e4 100644 --- a/src/management/checks/symlink-check.ts +++ b/src/management/checks/symlink-check.ts @@ -7,7 +7,7 @@ import * as path from 'path'; import * as os from 'os'; import { ok, fail, warn } from '../../utils/ui'; import { HealthCheck, IHealthChecker, createSpinner } from './types'; -import { getCcsDir } from '../../utils/config-manager'; +import { getCcsDir } from '../../config/config-loader-facade'; const ora = createSpinner(); diff --git a/src/management/checks/system-check.ts b/src/management/checks/system-check.ts index aaa8d458..875c0976 100644 --- a/src/management/checks/system-check.ts +++ b/src/management/checks/system-check.ts @@ -8,7 +8,7 @@ import { getClaudeCliInfo } from '../../utils/claude-detector'; import { escapeShellArg, stripClaudeCodeEnv } from '../../utils/shell-executor'; import { ok, fail } from '../../utils/ui'; import { HealthCheck, IHealthChecker, createSpinner } from './types'; -import { getCcsDir } from '../../utils/config-manager'; +import { getCcsDir } from '../../config/config-loader-facade'; const ora = createSpinner(); diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index ba39c65c..e6d73717 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -12,8 +12,9 @@ import SharedManager from './shared-manager'; import ProfileContextSyncLock from './profile-context-sync-lock'; import { DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context'; import type { AccountContextPolicy } from '../auth/account-context'; -import { getCcsDir, getCcsHome } from '../utils/config-manager'; +import { getCcsHome } from '../utils/config-manager'; import { createLogger } from '../services/logging'; +import { getCcsDir } from '../config/config-loader-facade'; const logger = createLogger('management:instance-manager'); diff --git a/src/management/recovery-manager.ts b/src/management/recovery-manager.ts index e7f6decf..425f76d7 100644 --- a/src/management/recovery-manager.ts +++ b/src/management/recovery-manager.ts @@ -7,13 +7,14 @@ import * as fs from 'fs'; import * as path from 'path'; import { info } from '../utils/ui'; -import { getCcsHome, getCcsDir } from '../utils/config-manager'; +import { getCcsHome } from '../utils/config-manager'; import { createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION } from '../config/unified-config-types'; import { - saveUnifiedConfig, + getCcsDir, hasUnifiedConfig, loadUnifiedConfig, -} from '../config/unified-config-loader'; + saveConfig, +} from '../config/config-loader-facade'; /** * Recovery Manager Class @@ -137,7 +138,7 @@ class RecoveryManager { config.version = UNIFIED_CONFIG_VERSION; try { - saveUnifiedConfig(config); + saveConfig(config); this.recovered.push(`Created ${this.ccsDir}/config.yaml`); return true; } catch (_saveErr) { diff --git a/src/management/repair/auto-repair.ts b/src/management/repair/auto-repair.ts index 861a30b6..d0db80b3 100644 --- a/src/management/repair/auto-repair.ts +++ b/src/management/repair/auto-repair.ts @@ -5,7 +5,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { ok, warn, fail, info, header, color } from '../../utils/ui'; -import { getCcsDir } from '../../utils/config-manager'; + import { CLIPROXY_DEFAULT_PORT, configNeedsRegeneration, @@ -16,6 +16,7 @@ import { getPortProcess, isCLIProxyProcess } from '../../utils/port-utils'; import { killProcessOnPort, getPlatformName } from '../../utils/platform-commands'; import { createSpinner } from '../checks/types'; import { fixImageAnalysisConfig } from '../checks/image-analysis-check'; +import { getCcsDir } from '../../config/config-loader-facade'; const ora = createSpinner(); diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index c2f79d2c..d9963679 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -13,11 +13,12 @@ import ProfileContextSyncLock from './profile-context-sync-lock'; import { ok, info, warn } from '../utils/ui'; import { DEFAULT_ACCOUNT_CONTEXT_GROUP } from '../auth/account-context'; import type { AccountContextPolicy } from '../auth/account-context'; -import { getCcsDir } from '../utils/config-manager'; + import { normalizePluginMetadataContent, normalizePluginMetadataValue, } from './plugin-path-normalizer'; +import { getCcsDir } from '../config/config-loader-facade'; export { normalizePluginMetadataContent, normalizePluginMetadataPathString, diff --git a/src/proxy/proxy-daemon-entry.ts b/src/proxy/proxy-daemon-entry.ts index d66b1f34..8f3b7e24 100644 --- a/src/proxy/proxy-daemon-entry.ts +++ b/src/proxy/proxy-daemon-entry.ts @@ -1,7 +1,7 @@ -import { loadSettings } from '../utils/config-manager'; import { resolveOpenAICompatProfileConfig } from './profile-router'; import { OPENAI_COMPAT_PROXY_DEFAULT_PORT } from './proxy-daemon-paths'; import { startOpenAICompatProxyServer } from './server/proxy-server'; +import { loadSettings } from '../config/config-loader-facade'; interface RuntimeOptions { port: number; diff --git a/src/proxy/proxy-daemon-paths.ts b/src/proxy/proxy-daemon-paths.ts index 843e1286..c321d121 100644 --- a/src/proxy/proxy-daemon-paths.ts +++ b/src/proxy/proxy-daemon-paths.ts @@ -1,5 +1,5 @@ import * as path from 'path'; -import { getCcsDir } from '../utils/config-manager'; +import { getCcsDir } from '../config/config-loader-facade'; export const OPENAI_COMPAT_PROXY_LEGACY_DEFAULT_PORT = 3456; export const OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START = 43_456; diff --git a/src/proxy/proxy-port-resolver.ts b/src/proxy/proxy-port-resolver.ts index cd7cd9a3..95f15268 100644 --- a/src/proxy/proxy-port-resolver.ts +++ b/src/proxy/proxy-port-resolver.ts @@ -1,9 +1,9 @@ -import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; import { OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_END, OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START, OPENAI_COMPAT_PROXY_LEGACY_DEFAULT_PORT, } from './proxy-daemon-paths'; +import { loadOrCreateUnifiedConfig } from '../config/config-loader-facade'; export interface OpenAICompatProxyPortPreference { port: number; diff --git a/src/proxy/request-router.ts b/src/proxy/request-router.ts index aa758280..2025cbea 100644 --- a/src/proxy/request-router.ts +++ b/src/proxy/request-router.ts @@ -1,4 +1,3 @@ -import { loadConfigSafe, loadSettings } from '../utils/config-manager'; import { expandPath } from '../utils/helpers'; import type { ProxyOpenAIRequest } from './transformers/request-transformer'; import { @@ -6,6 +5,7 @@ import { type OpenAICompatProxyRoutingConfig, } from './routing-config'; import { resolveOpenAICompatProfileConfig, type OpenAICompatProfileConfig } from './profile-router'; +import { loadConfigSafe, loadSettings } from '../config/config-loader-facade'; export type ProxyRoutingScenario = 'default' | 'background' | 'think' | 'longContext' | 'webSearch'; diff --git a/src/services/logging/log-config.ts b/src/services/logging/log-config.ts index 3985d25e..0c8b9def 100644 --- a/src/services/logging/log-config.ts +++ b/src/services/logging/log-config.ts @@ -1,10 +1,11 @@ import * as fs from 'fs'; import { DEFAULT_LOGGING_CONFIG } from '../../config/unified-config-types'; + +import type { LoggingConfig } from './log-types'; import { getConfigYamlPath, getLoggingConfig as getUnifiedLoggingConfig, -} from '../../config/unified-config-loader'; -import type { LoggingConfig } from './log-types'; +} from '../../config/config-loader-facade'; const CACHE_RECHECK_MS = 1000; let cachedConfig: LoggingConfig = { ...DEFAULT_LOGGING_CONFIG }; diff --git a/src/services/logging/log-paths.ts b/src/services/logging/log-paths.ts index 258e256d..1fe2fb21 100644 --- a/src/services/logging/log-paths.ts +++ b/src/services/logging/log-paths.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import { getCcsDir } from '../../utils/config-manager'; +import { getCcsDir } from '../../config/config-loader-facade'; const LOGS_DIR = 'logs'; const ARCHIVE_DIR = 'archive'; diff --git a/src/utils/browser/browser-setup.ts b/src/utils/browser/browser-setup.ts index 1d23e738..b95345fc 100644 --- a/src/utils/browser/browser-setup.ts +++ b/src/utils/browser/browser-setup.ts @@ -1,4 +1,3 @@ -import { getBrowserConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; import type { BrowserConfig } from '../../config/unified-config-types'; import { getNodePlatformKey } from './platform'; import { type BrowserStatusPayload, getBrowserStatus } from './browser-status'; @@ -9,6 +8,7 @@ import { getRecommendedBrowserUserDataDir, isManagedClaudeBrowserAttachConfig, } from './browser-settings'; +import { getBrowserConfig, mutateConfig } from '../../config/config-loader-facade'; export interface BrowserSetupResult { configUpdated: boolean; @@ -23,14 +23,14 @@ export interface BrowserSetupResult { export interface BrowserSetupDeps { getBrowserConfig: typeof getBrowserConfig; - mutateUnifiedConfig: typeof mutateUnifiedConfig; + mutateConfig: typeof mutateConfig; ensureBrowserMcp: typeof ensureBrowserMcp; getBrowserStatus: typeof getBrowserStatus; } const defaultBrowserSetupDeps: BrowserSetupDeps = { getBrowserConfig, - mutateUnifiedConfig, + mutateConfig, ensureBrowserMcp, getBrowserStatus, }; @@ -81,7 +81,7 @@ export async function runBrowserSetup( function persistBrowserSetupConfig(deps: BrowserSetupDeps, currentConfig: BrowserConfig): boolean { const before = JSON.stringify(currentConfig); - deps.mutateUnifiedConfig((config) => { + deps.mutateConfig((config) => { const existingBrowser = config.browser ?? currentConfig; const currentUserDataDir = existingBrowser.claude.user_data_dir?.trim(); diff --git a/src/utils/browser/browser-status.ts b/src/utils/browser/browser-status.ts index 2fbe14ef..48732454 100644 --- a/src/utils/browser/browser-status.ts +++ b/src/utils/browser/browser-status.ts @@ -4,7 +4,7 @@ import type { BrowserEvalMode, BrowserToolPolicy, } from '../../config/unified-config-types'; -import { getBrowserConfig, loadUnifiedConfig } from '../../config/unified-config-loader'; + import { getCcsPathDisplay } from '../config-manager'; import { getCodexBinaryInfo } from '../../targets/codex-detector'; import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse'; @@ -19,6 +19,7 @@ import { getEffectiveClaudeBrowserAttachConfig, getRecommendedBrowserUserDataDir, } from './browser-settings'; +import { getBrowserConfig, loadUnifiedConfig } from '../../config/config-loader-facade'; export interface ClaudeBrowserStatus { enabled: boolean; diff --git a/src/utils/hooks/get-image-analysis-hook-env.ts b/src/utils/hooks/get-image-analysis-hook-env.ts index 8c1777a6..86e947fe 100644 --- a/src/utils/hooks/get-image-analysis-hook-env.ts +++ b/src/utils/hooks/get-image-analysis-hook-env.ts @@ -7,7 +7,6 @@ * @module utils/hooks/image-analysis-hook-env */ -import { getImageAnalysisConfig } from '../../config/unified-config-loader'; import { resolveCliproxyBridgeProfile } from '../../api/services/cliproxy-profile-bridge'; import { getEffectiveApiKey } from '../../cliproxy/auth/auth-token-manager'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; @@ -21,6 +20,7 @@ import { resolveImageAnalysisStatus, type ImageAnalysisResolutionContext, } from './image-analysis-backend-resolver'; +import { getImageAnalysisConfig } from '../../config/config-loader-facade'; /** * Serialize provider_models map to env var format: provider:model,provider:model diff --git a/src/utils/hooks/image-analyzer-hook-configuration.ts b/src/utils/hooks/image-analyzer-hook-configuration.ts index 45de9481..057dd375 100644 --- a/src/utils/hooks/image-analyzer-hook-configuration.ts +++ b/src/utils/hooks/image-analyzer-hook-configuration.ts @@ -7,8 +7,9 @@ */ import * as path from 'path'; -import { getImageAnalysisConfig } from '../../config/unified-config-loader'; + import { getCcsHooksDir } from '../config-manager'; +import { getImageAnalysisConfig } from '../../config/config-loader-facade'; // Hook file name const IMAGE_ANALYZER_HOOK = 'image-analyzer-transformer.cjs'; diff --git a/src/utils/hooks/image-analyzer-hook-installer.ts b/src/utils/hooks/image-analyzer-hook-installer.ts index e124c74a..9bb5bf7e 100644 --- a/src/utils/hooks/image-analyzer-hook-installer.ts +++ b/src/utils/hooks/image-analyzer-hook-installer.ts @@ -12,9 +12,10 @@ import * as path from 'path'; import { info, warn } from '../ui'; import { getImageAnalyzerHookPath } from './image-analyzer-hook-configuration'; import { getCcsHooksDir } from '../config-manager'; -import { getImageAnalysisConfig } from '../../config/unified-config-loader'; + import { removeMigrationMarker } from './image-analyzer-profile-hook-injector'; import { installImageAnalysisPrompts } from '../image-analysis/hook-installer'; +import { getImageAnalysisConfig } from '../../config/config-loader-facade'; // Re-export from hook-configuration for backward compatibility export { diff --git a/src/utils/hooks/image-analyzer-profile-hook-injector.ts b/src/utils/hooks/image-analyzer-profile-hook-injector.ts index 73a0517e..76b907cf 100644 --- a/src/utils/hooks/image-analyzer-profile-hook-injector.ts +++ b/src/utils/hooks/image-analyzer-profile-hook-injector.ts @@ -21,12 +21,13 @@ import { isCcsImageAnalyzerHook, removeCcsImageAnalyzerHooks, } from './image-analyzer-hook-utils'; -import { getImageAnalysisConfig } from '../../config/unified-config-loader'; + import { getCcsDir } from '../config-manager'; import { resolveImageAnalysisStatus, type ImageAnalysisResolutionContext, } from './image-analysis-backend-resolver'; +import { getImageAnalysisConfig } from '../../config/config-loader-facade'; // Valid profile name pattern (alphanumeric, dot, dash, underscore only) const VALID_PROFILE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; diff --git a/src/utils/image-analysis/mcp-installer.ts b/src/utils/image-analysis/mcp-installer.ts index 76b8c3d2..64bd9e35 100644 --- a/src/utils/image-analysis/mcp-installer.ts +++ b/src/utils/image-analysis/mcp-installer.ts @@ -5,12 +5,13 @@ import * as fs from 'fs'; import * as path from 'path'; import * as lockfile from 'proper-lockfile'; -import { getImageAnalysisConfig } from '../../config/unified-config-loader'; + import { getCcsDir } from '../config-manager'; import { getClaudeUserConfigPath } from '../claude-config-path'; import { info, warn } from '../ui'; import { InstanceManager } from '../../management/instance-manager'; import { installImageAnalysisPrompts } from './hook-installer'; +import { getImageAnalysisConfig } from '../../config/config-loader-facade'; const IMAGE_ANALYSIS_MCP_SERVER = 'ccs-image-analysis-server.cjs'; const IMAGE_ANALYSIS_MCP_RUNTIME = 'image-analysis-runtime.cjs'; diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 35a7505d..4b236a90 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -8,8 +8,9 @@ import { spawn, spawnSync, ChildProcess, type SpawnOptions } from 'child_process import { ErrorManager } from './error-manager'; import { getWebSearchHookEnv } from './websearch-manager'; import { wireChildProcessSignals } from './signal-forwarder'; -import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; + import SharedManager from '../management/shared-manager'; +import { loadOrCreateUnifiedConfig } from '../config/config-loader-facade'; /** * Strip ANTHROPIC_* env vars from an environment object. diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts index b7d6c092..83b605ab 100644 --- a/src/utils/websearch/hook-config.ts +++ b/src/utils/websearch/hook-config.ts @@ -9,10 +9,11 @@ import * as fs from 'fs'; import * as path from 'path'; import { info, warn } from '../ui'; -import { getWebSearchConfig } from '../../config/unified-config-loader'; + import { getCcsHooksDir } from '../config-manager'; import { getClaudeSettingsPath } from '../claude-config-path'; import { isCcsWebSearchHook, deduplicateCcsHooks } from './hook-utils'; +import { getWebSearchConfig } from '../../config/config-loader-facade'; // Hook file name const WEBSEARCH_HOOK = 'websearch-transformer.cjs'; diff --git a/src/utils/websearch/hook-env.ts b/src/utils/websearch/hook-env.ts index 2e79e649..f3b8887f 100644 --- a/src/utils/websearch/hook-env.ts +++ b/src/utils/websearch/hook-env.ts @@ -6,9 +6,9 @@ * @module utils/websearch/hook-env */ -import { getWebSearchConfig } from '../../config/unified-config-loader'; import { normalizeSearxngBaseUrl } from './types'; import { resolveAllowedWebSearchTraceFile } from './trace'; +import { getWebSearchConfig } from '../../config/config-loader-facade'; /** * Get environment variables for WebSearch hook configuration. diff --git a/src/utils/websearch/hook-installer.ts b/src/utils/websearch/hook-installer.ts index dc5c1f70..64a18797 100644 --- a/src/utils/websearch/hook-installer.ts +++ b/src/utils/websearch/hook-installer.ts @@ -9,9 +9,10 @@ import * as fs from 'fs'; import * as path from 'path'; import { info, warn } from '../ui'; -import { getWebSearchConfig } from '../../config/unified-config-loader'; + import { getCcsDir, getCcsHooksDir } from '../config-manager'; import { getHookPath } from './hook-config'; +import { getWebSearchConfig } from '../../config/config-loader-facade'; // Re-export from hook-config for backward compatibility export { getHookPath, getWebSearchHookConfig } from './hook-config'; diff --git a/src/utils/websearch/mcp-installer.ts b/src/utils/websearch/mcp-installer.ts index 5f7260e9..967860ba 100644 --- a/src/utils/websearch/mcp-installer.ts +++ b/src/utils/websearch/mcp-installer.ts @@ -4,13 +4,14 @@ import * as fs from 'fs'; import * as path from 'path'; -import { getWebSearchConfig } from '../../config/unified-config-loader'; + import { getCcsDir } from '../config-manager'; import { getClaudeUserConfigPath } from '../claude-config-path'; import { info, warn } from '../ui'; import { InstanceManager } from '../../management/instance-manager'; import { installWebSearchHook } from './hook-installer'; import { appendWebSearchTrace } from './trace'; +import { getWebSearchConfig } from '../../config/config-loader-facade'; const WEBSEARCH_MCP_SERVER = 'ccs-websearch-server.cjs'; const WEBSEARCH_MCP_SERVER_NAME = 'ccs-websearch'; diff --git a/src/utils/websearch/profile-hook-injector.ts b/src/utils/websearch/profile-hook-injector.ts index 62bbed64..f1e909d6 100644 --- a/src/utils/websearch/profile-hook-injector.ts +++ b/src/utils/websearch/profile-hook-injector.ts @@ -12,11 +12,12 @@ import * as fs from 'fs'; import * as path from 'path'; import { info, warn } from '../ui'; import { getWebSearchHookConfig, getHookPath } from './hook-config'; -import { getWebSearchConfig } from '../../config/unified-config-loader'; + import { removeHookConfig } from './hook-config'; import { getCcsDir } from '../config-manager'; import { isCcsWebSearchHook, deduplicateCcsHooks } from './hook-utils'; import { getMigrationMarkerPath, installWebSearchHook } from './hook-installer'; +import { getWebSearchConfig } from '../../config/config-loader-facade'; // Valid profile name pattern (alphanumeric, dash, underscore only) const VALID_PROFILE_NAME = /^[a-zA-Z0-9_-]+$/; diff --git a/src/utils/websearch/provider-secrets.ts b/src/utils/websearch/provider-secrets.ts index 26ab1a77..8cb55b94 100644 --- a/src/utils/websearch/provider-secrets.ts +++ b/src/utils/websearch/provider-secrets.ts @@ -1,5 +1,5 @@ -import { getGlobalEnvConfig } from '../../config/unified-config-loader'; import { maskSensitiveValue } from '../sensitive-keys'; +import { getGlobalEnvConfig } from '../../config/config-loader-facade'; export type WebSearchApiKeyProviderId = 'exa' | 'tavily' | 'brave'; export type WebSearchApiKeySource = 'global_env' | 'process_env' | 'both' | 'none'; diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts index 5f496624..1cf9e57b 100644 --- a/src/utils/websearch/status.ts +++ b/src/utils/websearch/status.ts @@ -9,13 +9,14 @@ import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; import { ok, warn, fail, info } from '../ui'; -import { getWebSearchConfig } from '../../config/unified-config-loader'; + import { getCcsDir } from '../config-manager'; import { getGeminiCliStatus, isGeminiAuthenticated } from './gemini-cli'; import { getGrokCliStatus } from './grok-cli'; import { getOpenCodeCliStatus } from './opencode-cli'; import { getWebSearchApiKeyStates } from './provider-secrets'; import { normalizeSearxngBaseUrl, type WebSearchCliInfo, type WebSearchStatus } from './types'; +import { getWebSearchConfig } from '../../config/config-loader-facade'; const PROVIDER_STATE_FILE = 'websearch-provider-state.json'; diff --git a/src/web-server/file-watcher.ts b/src/web-server/file-watcher.ts index 90de9027..5a9b4303 100644 --- a/src/web-server/file-watcher.ts +++ b/src/web-server/file-watcher.ts @@ -7,7 +7,7 @@ import chokidar, { FSWatcher } from 'chokidar'; import * as path from 'path'; -import { getCcsDir } from '../utils/config-manager'; +import { getCcsDir } from '../config/config-loader-facade'; export interface FileChangeEvent { type: 'config-changed' | 'settings-changed' | 'profiles-changed' | 'proxy-status-changed'; diff --git a/src/web-server/health-service.ts b/src/web-server/health-service.ts index c76ae57f..27559cd9 100644 --- a/src/web-server/health-service.ts +++ b/src/web-server/health-service.ts @@ -8,7 +8,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { getCcsDir, getConfigPath } from '../utils/config-manager'; +import { getConfigPath } from '../utils/config-manager'; import packageJson from '../../package.json'; // Import all check functions from modular components @@ -35,6 +35,7 @@ import { checkOAuthPortsForDashboard, checkWebSearchClis, } from './health'; +import { getCcsDir } from '../config/config-loader-facade'; // Re-export types for external consumers export type { HealthCheck, HealthGroup, HealthReport }; @@ -143,10 +144,10 @@ export function fixHealthIssue(checkId: string): { success: boolean; message: st case 'config-file': { // Use appropriate config based on unified mode - const { isUnifiedMode } = require('../config/unified-config-loader'); + const { isUnifiedMode } = require('../config/config-loader-facade'); if (isUnifiedMode()) { - const { mutateUnifiedConfig } = require('../config/unified-config-loader'); - mutateUnifiedConfig(() => {}); + const { mutateConfig } = require('../config/config-loader-facade'); + mutateConfig(() => {}); return { success: true, message: 'Created/updated config.yaml' }; } const configPath = getConfigPath(); @@ -157,7 +158,7 @@ export function fixHealthIssue(checkId: string): { success: boolean; message: st case 'profiles-file': { // Use appropriate storage based on unified mode - const { isUnifiedMode: isUnified } = require('../config/unified-config-loader'); + const { isUnifiedMode: isUnified } = require('../config/config-loader-facade'); if (isUnified()) { // In unified mode, accounts are stored in config.yaml return { success: true, message: 'Accounts stored in config.yaml (unified mode)' }; diff --git a/src/web-server/health/config-checks.ts b/src/web-server/health/config-checks.ts index f8ff910b..eb467034 100644 --- a/src/web-server/health/config-checks.ts +++ b/src/web-server/health/config-checks.ts @@ -7,9 +7,10 @@ import * as fs from 'fs'; import * as path from 'path'; -import { getConfigPath, getCcsDir } from '../../utils/config-manager'; -import { isUnifiedMode, hasUnifiedConfig } from '../../config/unified-config-loader'; +import { getConfigPath } from '../../utils/config-manager'; + import type { HealthCheck } from './types'; +import { getCcsDir, hasUnifiedConfig, isUnifiedMode } from '../../config/config-loader-facade'; /** * Check config file (config.json or config.yaml based on mode) diff --git a/src/web-server/health/profile-checks.ts b/src/web-server/health/profile-checks.ts index 83955611..5e5e6561 100644 --- a/src/web-server/health/profile-checks.ts +++ b/src/web-server/health/profile-checks.ts @@ -7,8 +7,9 @@ import * as fs from 'fs'; import * as path from 'path'; -import { isUnifiedMode, loadUnifiedConfig } from '../../config/unified-config-loader'; + import type { HealthCheck } from './types'; +import { isUnifiedMode, loadUnifiedConfig } from '../../config/config-loader-facade'; /** * Check profiles configuration (API profiles from config.json or config.yaml) diff --git a/src/web-server/middleware/auth-middleware.ts b/src/web-server/middleware/auth-middleware.ts index c3f04f04..4afc4703 100644 --- a/src/web-server/middleware/auth-middleware.ts +++ b/src/web-server/middleware/auth-middleware.ts @@ -6,11 +6,15 @@ import type { NextFunction, Request, Response } from 'express'; import session from 'express-session'; import rateLimit from 'express-rate-limit'; -import { getDashboardAuthConfig, isDashboardAuthEnabled } from '../../config/unified-config-loader'; + import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; -import { getCcsDir } from '../../utils/config-manager'; +import { + getCcsDir, + getDashboardAuthConfig, + isDashboardAuthEnabled, +} from '../../config/config-loader-facade'; // Extend Express Request with session declare module 'express-session' { diff --git a/src/web-server/models-dev/registry-cache.ts b/src/web-server/models-dev/registry-cache.ts index 9b2a0802..650f41b4 100644 --- a/src/web-server/models-dev/registry-cache.ts +++ b/src/web-server/models-dev/registry-cache.ts @@ -1,7 +1,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import { getCcsDir } from '../../utils/config-manager'; + import type { ModelsDevCacheData, ModelsDevProvider, ModelsDevRegistry } from './types'; +import { getCcsDir } from '../../config/config-loader-facade'; export const MODELS_DEV_API_URL = 'https://models.dev/api.json'; diff --git a/src/web-server/overview-routes.ts b/src/web-server/overview-routes.ts index d0c0586f..384f5de7 100644 --- a/src/web-server/overview-routes.ts +++ b/src/web-server/overview-routes.ts @@ -5,11 +5,12 @@ */ import { Router, Request, Response } from 'express'; -import { loadConfigSafe } from '../utils/config-manager'; + import { runHealthChecks } from './health-service'; import { getAllAuthStatus, initializeAccounts } from '../cliproxy/auth/auth-handler'; import { getVersion } from '../utils/version'; import ProfileRegistry from '../auth/profile-registry'; +import { loadConfigSafe } from '../config/config-loader-facade'; export const overviewRoutes = Router(); diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index 0ae56c1d..df520087 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -8,7 +8,7 @@ import { Router, Request, Response } from 'express'; import ProfileRegistry from '../../auth/profile-registry'; import InstanceManager from '../../management/instance-manager'; -import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; + import { getAllAccountsSummary, setDefaultAccount as setCliproxyDefault, @@ -33,6 +33,7 @@ import { } from './account-route-helpers'; import type { AccountConfig } from '../../config/unified-config-types'; import { resolveConfiguredPlainCcsResumeLane } from '../../auth/resume-lane-diagnostics'; +import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../../config/config-loader-facade'; const router = Router(); diff --git a/src/web-server/routes/auth-routes.ts b/src/web-server/routes/auth-routes.ts index 6fcd3e22..2ec20d1e 100644 --- a/src/web-server/routes/auth-routes.ts +++ b/src/web-server/routes/auth-routes.ts @@ -6,9 +6,10 @@ import { Router, type Request, type Response } from 'express'; import bcrypt from 'bcrypt'; import crypto from 'crypto'; -import { getDashboardAuthConfig } from '../../config/unified-config-loader'; + import type { DashboardAuthConfig } from '../../config/unified-config-types'; import { isLoopbackRemoteAddress, loginRateLimiter } from '../middleware/auth-middleware'; +import { getDashboardAuthConfig } from '../../config/config-loader-facade'; /** * Timing-safe string comparison to prevent timing attacks. diff --git a/src/web-server/routes/browser-routes.ts b/src/web-server/routes/browser-routes.ts index f25dcc2f..2e789df8 100644 --- a/src/web-server/routes/browser-routes.ts +++ b/src/web-server/routes/browser-routes.ts @@ -1,9 +1,10 @@ import { Router, type Request, type Response } from 'express'; -import { mutateUnifiedConfig } from '../../config/unified-config-loader'; + import type { BrowserConfig, BrowserEvalMode } from '../../config/unified-config-types'; import { getBrowserStatus } from '../../utils/browser'; import { getUserFacingBrowserConfig } from '../../utils/browser/browser-status'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; +import { mutateConfig } from '../../config/config-loader-facade'; const router = Router(); const BROWSER_LOCAL_ACCESS_ERROR = @@ -173,7 +174,7 @@ router.put('/', async (req: Request, res: Response): Promise => { const current = getUserFacingBrowserConfig(); const nextClaudeUserDataDir = claude?.userDataDir === undefined ? current.claude.user_data_dir : claude.userDataDir.trim(); - mutateUnifiedConfig((config) => { + mutateConfig((config) => { config.browser = { claude: { enabled: claude?.enabled ?? current.claude.enabled, diff --git a/src/web-server/routes/channels-routes.ts b/src/web-server/routes/channels-routes.ts index 89a67cef..dc0913ab 100644 --- a/src/web-server/routes/channels-routes.ts +++ b/src/web-server/routes/channels-routes.ts @@ -1,5 +1,5 @@ import { Router, type Request, type Response } from 'express'; -import { getOfficialChannelsConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; + import { clearConfiguredOfficialChannelTokensEverywhere, getOfficialChannelTokenStatus, @@ -24,6 +24,7 @@ import { isOfficialChannelId, } from '../../channels/official-channels-runtime'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; +import { getOfficialChannelsConfig, mutateConfig } from '../../config/config-loader-facade'; const router = Router(); @@ -146,7 +147,7 @@ router.put('/', (req: Request, res: Response): void => { } try { - const updated = mutateUnifiedConfig((config) => { + const updated = mutateConfig((config) => { config.channels = { selected: selected !== undefined ? [...new Set(selected)] : (config.channels?.selected ?? []), diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 009ac005..5faae932 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -34,7 +34,7 @@ import { import { fetchRemoteAuthStatus } from '../../cliproxy/services/remote-auth-fetcher'; import { ensureManagedModelPrefixes } from '../../cliproxy/ai-providers/managed-model-prefixes'; import { invalidateQuotaCache } from '../../cliproxy/quota/quota-response-cache'; -import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; + import { tryKiroImport } from '../../cliproxy/auth/kiro-import'; import { type ProviderTokenSnapshot, @@ -65,6 +65,7 @@ import { } from '../../cliproxy/auth/antigravity-responsibility'; import { createRouteErrorHelpers } from './route-helpers'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; +import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade'; const router = Router(); const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 1000; diff --git a/src/web-server/routes/cliproxy-local-proxy.ts b/src/web-server/routes/cliproxy-local-proxy.ts index dc55e0fb..80d065d6 100644 --- a/src/web-server/routes/cliproxy-local-proxy.ts +++ b/src/web-server/routes/cliproxy-local-proxy.ts @@ -10,8 +10,9 @@ import http from 'http'; import { Request, Response, Router } from 'express'; import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager'; -import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; + import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; +import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade'; export interface CliproxyLocalProxyDeps { enforceAccess?: (req: Request, res: Response) => boolean; diff --git a/src/web-server/routes/cliproxy-sync-routes.ts b/src/web-server/routes/cliproxy-sync-routes.ts index d414bbda..37eab52c 100644 --- a/src/web-server/routes/cliproxy-sync-routes.ts +++ b/src/web-server/routes/cliproxy-sync-routes.ts @@ -3,7 +3,7 @@ */ import { Router, Request, Response } from 'express'; -import { mutateUnifiedConfig } from '../../config/unified-config-loader'; + import { generateSyncPayload, generateSyncPreview, @@ -12,6 +12,7 @@ import { syncToLocalConfig, getLocalSyncStatus, } from '../../cliproxy/sync'; +import { mutateConfig } from '../../config/config-loader-facade'; const router = Router(); @@ -130,7 +131,7 @@ router.put('/auto-sync', async (req: Request, res: Response): Promise => { } try { - mutateUnifiedConfig((config) => { + mutateConfig((config) => { if (!config.cliproxy) { throw new Error('CLIProxy config not initialized'); } diff --git a/src/web-server/routes/config-routes.ts b/src/web-server/routes/config-routes.ts index 3cfa87e6..6a48d96f 100644 --- a/src/web-server/routes/config-routes.ts +++ b/src/web-server/routes/config-routes.ts @@ -4,13 +4,7 @@ import { Router, Request, Response } from 'express'; import * as fs from 'fs'; -import { - hasUnifiedConfig, - loadUnifiedConfig, - mutateUnifiedConfig, - getConfigFormat, - getConfigYamlPath, -} from '../../config/unified-config-loader'; + import { needsMigration, migrate, @@ -28,6 +22,13 @@ import { import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../../config/unified-config-types'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; import { isSensitiveKey } from '../../utils/sensitive-keys'; +import { + getConfigFormat, + getConfigYamlPath, + hasUnifiedConfig, + loadUnifiedConfig, + mutateConfig, +} from '../../config/config-loader-facade'; const router = Router(); const LOCAL_CONFIG_ERROR = @@ -652,7 +653,7 @@ router.put('/', (req: Request, res: Response): void => { } try { - mutateUnifiedConfig((currentConfig) => { + mutateConfig((currentConfig) => { if ('setup_completed' in config) { currentConfig.setup_completed = config.setup_completed; } diff --git a/src/web-server/routes/copilot-routes.ts b/src/web-server/routes/copilot-routes.ts index 4c85d284..919e0748 100644 --- a/src/web-server/routes/copilot-routes.ts +++ b/src/web-server/routes/copilot-routes.ts @@ -23,8 +23,9 @@ import { type CopilotNormalizationWarning, } from '../../copilot/copilot-model-normalizer'; import { DEFAULT_COPILOT_CONFIG, type CopilotConfig } from '../../config/unified-config-types'; -import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; + import copilotSettingsRoutes from './copilot-settings-routes'; +import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade'; const router = Router(); @@ -211,7 +212,7 @@ router.put('/config', (req: Request, res: Response): void => { let nextCopilotConfig: CopilotConfig = { ...DEFAULT_COPILOT_CONFIG }; let warnings: CopilotNormalizationWarning[] = []; - mutateUnifiedConfig((config) => { + mutateConfig((config) => { const currentConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; const result = normalizeCopilotConfigWithWarnings({ enabled: diff --git a/src/web-server/routes/copilot-settings-routes.ts b/src/web-server/routes/copilot-settings-routes.ts index 015da32b..54a748e0 100644 --- a/src/web-server/routes/copilot-settings-routes.ts +++ b/src/web-server/routes/copilot-settings-routes.ts @@ -10,8 +10,11 @@ import { normalizeCopilotSettingsWithWarnings, } from '../../copilot/copilot-model-normalizer'; import { DEFAULT_COPILOT_CONFIG, type CopilotConfig } from '../../config/unified-config-types'; -import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; -import { getCcsDir } from '../../utils/config-manager'; +import { + getCcsDir, + loadOrCreateUnifiedConfig, + mutateConfig, +} from '../../config/config-loader-facade'; const router = Router(); @@ -150,7 +153,7 @@ router.put('/raw', (req: Request, res: Response): void => { ); try { - mutateUnifiedConfig((config) => { + mutateConfig((config) => { config.copilot = settingsResult.effectiveConfig; }); } catch (error) { diff --git a/src/web-server/routes/cursor-routes.ts b/src/web-server/routes/cursor-routes.ts index defcc96e..220dae9a 100644 --- a/src/web-server/routes/cursor-routes.ts +++ b/src/web-server/routes/cursor-routes.ts @@ -15,8 +15,9 @@ import { saveCredentials, validateToken, } from '../../cursor'; -import { getCursorConfig } from '../../config/unified-config-loader'; + import cursorSettingsRoutes from './cursor-settings-routes'; +import { getCursorConfig } from '../../config/config-loader-facade'; const router = Router(); diff --git a/src/web-server/routes/cursor-settings-routes.ts b/src/web-server/routes/cursor-settings-routes.ts index 56c9dcc1..42c7105a 100644 --- a/src/web-server/routes/cursor-settings-routes.ts +++ b/src/web-server/routes/cursor-settings-routes.ts @@ -7,10 +7,11 @@ import { Router } from 'express'; import * as fs from 'fs'; import { promises as fsp } from 'fs'; import * as path from 'path'; -import { getCcsDir } from '../../utils/config-manager'; + import { DEFAULT_CURSOR_CONFIG } from '../../config/unified-config-types'; -import { mutateUnifiedConfig, getCursorConfig } from '../../config/unified-config-loader'; + import type { CursorConfig } from '../../config/unified-config-types'; +import { getCcsDir, getCursorConfig, mutateConfig } from '../../config/config-loader-facade'; const router = Router(); @@ -181,7 +182,7 @@ router.put('/', async (req: Request, res: Response): Promise => { } const normalizedModel = parseRequiredModel(updates.model); - const config = mutateUnifiedConfig((currentConfig) => { + const config = mutateConfig((currentConfig) => { currentConfig.cursor = { enabled: updates.enabled ?? currentConfig.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled, port: updates.port ?? currentConfig.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port, @@ -294,7 +295,7 @@ router.put('/raw', (req: Request, res: Response): void => { // Keep unified config aligned with raw settings edits (parity with Copilot raw editor). const parsedPort = parseLocalCursorPort(settings); const env = (settings as { env?: Record }).env ?? {}; - mutateUnifiedConfig((config) => { + mutateConfig((config) => { const model = parseRequiredModel(env.ANTHROPIC_MODEL) ?? config.cursor?.model; config.cursor = { diff --git a/src/web-server/routes/image-analysis-routes.ts b/src/web-server/routes/image-analysis-routes.ts index 22f21c47..25ec7fc9 100644 --- a/src/web-server/routes/image-analysis-routes.ts +++ b/src/web-server/routes/image-analysis-routes.ts @@ -1,6 +1,6 @@ import { Router, type Request, type Response } from 'express'; import * as fs from 'fs'; -import { getImageAnalysisConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; + import { CLIPROXY_PROVIDER_IDS, getProviderDisplayName, @@ -10,7 +10,7 @@ import type { CLIProxyProvider } from '../../cliproxy/types'; import { listApiProfiles, resolveCliproxyBridgeMetadata } from '../../api/services'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; import { expandPath } from '../../utils/helpers'; -import { loadSettings } from '../../utils/config-manager'; + import type { Settings } from '../../types/config'; import { extractProviderFromPathname } from '../../cliproxy/ai-providers/model-id-normalizer'; import { @@ -23,6 +23,11 @@ import { hasImageAnalysisMcpReady, repairImageAnalysisRuntimeState, } from '../../utils/image-analysis'; +import { + getImageAnalysisConfig, + loadSettings, + mutateConfig, +} from '../../config/config-loader-facade'; const router = Router(); const IMAGE_ANALYSIS_LOCAL_ACCESS_ERROR = @@ -448,7 +453,7 @@ router.put('/', async (req: Request, res: Response): Promise => { nextProfileBackends[trimmedProfileName] = normalizedBackend; } - mutateUnifiedConfig((config) => { + mutateConfig((config) => { config.image_analysis = { enabled: body.enabled ?? currentConfig.enabled, timeout: body.timeout ?? currentConfig.timeout, diff --git a/src/web-server/routes/misc-routes.ts b/src/web-server/routes/misc-routes.ts index ce95c457..39a96a94 100644 --- a/src/web-server/routes/misc-routes.ts +++ b/src/web-server/routes/misc-routes.ts @@ -5,14 +5,9 @@ import { Router, Request, Response } from 'express'; import * as fs from 'fs'; import * as path from 'path'; -import { getCcsDir } from '../../utils/config-manager'; + import { expandPath } from '../../utils/helpers'; -import { - mutateUnifiedConfig, - getGlobalEnvConfig, - getThinkingConfig, - getConfigYamlPath, -} from '../../config/unified-config-loader'; + import type { ThinkingConfig } from '../../config/unified-config-types'; import { THINKING_BUDGET_MIN, @@ -23,6 +18,13 @@ import { } from '../../cliproxy'; import { validateFilePath } from './route-helpers'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; +import { + getCcsDir, + getConfigYamlPath, + getGlobalEnvConfig, + getThinkingConfig, + mutateConfig, +} from '../../config/config-loader-facade'; const router = Router(); @@ -244,7 +246,7 @@ router.put('/global-env', (req: Request, res: Response): void => { try { // Atomic read-modify-write — avoids race between load and save - const updated = mutateUnifiedConfig((config) => { + const updated = mutateConfig((config) => { config.global_env = { enabled: enabled ?? config.global_env?.enabled ?? true, env: env ?? config.global_env?.env ?? {}, @@ -445,7 +447,7 @@ router.put('/thinking', (req: Request, res: Response): void => { Object.keys(sanitizedOverrides).length > 0 ? sanitizedOverrides : undefined; } - const config = mutateUnifiedConfig((currentConfig) => { + const config = mutateConfig((currentConfig) => { currentConfig.thinking = { mode: updates.mode ?? currentConfig.thinking?.mode ?? 'auto', override: shouldClearOverride diff --git a/src/web-server/routes/proxy-routes.ts b/src/web-server/routes/proxy-routes.ts index 22100e42..e25b440b 100644 --- a/src/web-server/routes/proxy-routes.ts +++ b/src/web-server/routes/proxy-routes.ts @@ -8,7 +8,7 @@ */ import { Router, Request, Response } from 'express'; -import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; + import { testConnection } from '../../cliproxy/services/remote-proxy-client'; import { isProxyRunning } from '../../cliproxy/services/proxy-lifecycle-service'; import { DEFAULT_BACKEND } from '../../cliproxy/binary/platform-detector'; @@ -18,6 +18,7 @@ import { } from '../../config/unified-config-types'; import { CLIPROXY_PROVIDER_IDS } from '../../cliproxy/provider-capabilities'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; +import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade'; const router = Router(); @@ -54,7 +55,7 @@ router.put('/', (req: Request, res: Response) => { const updates = req.body as Partial; // Atomic read-modify-write — avoids race between load and save - const updated = mutateUnifiedConfig((config) => { + const updated = mutateConfig((config) => { config.cliproxy_server = { remote: { ...DEFAULT_CLIPROXY_SERVER_CONFIG.remote, @@ -125,7 +126,7 @@ router.put('/backend', (req: Request, res: Response) => { } // Atomic write — avoids race between load and save - mutateUnifiedConfig((config) => { + mutateConfig((config) => { if (!config.cliproxy) { config.cliproxy = { backend, diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index 3251a064..827fffee 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -5,7 +5,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { Response } from 'express'; -import { getCcsDir, getConfigPath, loadConfigSafe, loadSettings } from '../../utils/config-manager'; +import { getConfigPath } from '../../utils/config-manager'; import { expandPath } from '../../utils/helpers'; import { getClaudeSettingsPath } from '../../utils/claude-config-path'; import { resolveDroidProvider } from '../../targets/droid-provider'; @@ -20,6 +20,7 @@ import type { Config, Settings } from '../../types/config'; import type { TargetType } from '../../targets/target-adapter'; import { isPersistedTargetType } from '../../targets/target-metadata'; import { ValidationError } from '../../errors/error-types'; +import { getCcsDir, loadConfigSafe, loadSettings } from '../../config/config-loader-facade'; /** Model mapping for API profiles */ export interface ModelMapping { diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 0bed44be..2faa3f4c 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -6,7 +6,7 @@ import { Router, Request, Response } from 'express'; import * as fs from 'fs'; import * as path from 'path'; import * as lockfile from 'proper-lockfile'; -import { getCcsDir, loadConfigSafe, loadSettings } from '../../utils/config-manager'; + import { isSensitiveKey, maskSensitiveValue } from '../../utils/sensitive-keys'; import { listVariants } from '../../cliproxy/services/variant-service'; import { @@ -21,11 +21,7 @@ import { regenerateConfig } from '../../cliproxy/config/config-generator'; import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils'; import { removeCcsImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-hook-utils'; import { resolveCliproxyBridgeMetadata } from '../../api/services'; -import { - getImageAnalysisConfig, - loadOrCreateUnifiedConfig, - mutateUnifiedConfig, -} from '../../config/unified-config-loader'; + import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; import type { Settings } from '../../types/config'; import type { CLIProxyProvider } from '../../cliproxy/types'; @@ -44,6 +40,14 @@ import { } from '../../utils/hooks/image-analyzer-profile-hook-injector'; import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer'; import { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks'; +import { + getCcsDir, + getImageAnalysisConfig, + loadConfigSafe, + loadOrCreateUnifiedConfig, + loadSettings, + mutateConfig, +} from '../../config/config-loader-facade'; const router = Router(); const MODEL_ENV_KEYS = [ @@ -765,7 +769,7 @@ router.put('/auth/antigravity-risk', (req: Request, res: Response): void => { return; } - const updatedConfig = mutateUnifiedConfig((config) => { + const updatedConfig = mutateConfig((config) => { config.cliproxy.safety = { ...(config.cliproxy.safety ?? {}), antigravity_ack_bypass: antigravityAckBypass, diff --git a/src/web-server/routes/websearch-routes.ts b/src/web-server/routes/websearch-routes.ts index 22f88100..4b2220ca 100644 --- a/src/web-server/routes/websearch-routes.ts +++ b/src/web-server/routes/websearch-routes.ts @@ -3,7 +3,7 @@ */ import { Router, Request, Response } from 'express'; -import { mutateUnifiedConfig, getWebSearchConfig } from '../../config/unified-config-loader'; + import type { WebSearchConfig } from '../../config/unified-config-types'; import { getWebSearchReadiness, getWebSearchCliProviders } from '../../utils/websearch-manager'; import { @@ -14,6 +14,7 @@ import { } from '../../utils/websearch/provider-secrets'; import { normalizeSearxngBaseUrl } from '../../utils/websearch/types'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; +import { getWebSearchConfig, mutateConfig } from '../../config/config-loader-facade'; const router = Router(); const WEBSEARCH_LOCAL_ACCESS_ERROR = @@ -143,7 +144,7 @@ router.put('/', (req: Request, res: Response): void => { } try { - mutateUnifiedConfig((config) => { + mutateConfig((config) => { const existingSearxngUrl = normalizeSearxngBaseUrl(config.websearch?.providers?.searxng?.url) ?? ''; diff --git a/src/web-server/services/claude-extension-binding-service.ts b/src/web-server/services/claude-extension-binding-service.ts index ded0d77d..5d969b2e 100644 --- a/src/web-server/services/claude-extension-binding-service.ts +++ b/src/web-server/services/claude-extension-binding-service.ts @@ -4,7 +4,7 @@ import * as path from 'path'; import ProfileDetector from '../../auth/profile-detector'; import type { ClaudeExtensionHost } from '../../shared/claude-extension-hosts'; import { expandPath } from '../../utils/helpers'; -import { getCcsDir } from '../../utils/config-manager'; +import { getCcsDir } from '../../config/config-loader-facade'; export interface ClaudeExtensionBinding { id: string; diff --git a/src/web-server/services/logs-dashboard-service.ts b/src/web-server/services/logs-dashboard-service.ts index 58c6a84c..c50c87c7 100644 --- a/src/web-server/services/logs-dashboard-service.ts +++ b/src/web-server/services/logs-dashboard-service.ts @@ -1,4 +1,3 @@ -import { mutateUnifiedConfig } from '../../config/unified-config-loader'; import { getResolvedLoggingConfig, invalidateLoggingConfigCache, @@ -7,13 +6,14 @@ import { } from '../../services/logging'; import type { LoggingConfig } from '../../config/unified-config-types'; import type { ReadLogEntriesOptions } from '../../services/logging'; +import { mutateConfig } from '../../config/config-loader-facade'; export function getDashboardLoggingConfig(): LoggingConfig { return getResolvedLoggingConfig(); } export function updateDashboardLoggingConfig(updates: Partial): LoggingConfig { - const updated = mutateUnifiedConfig((config) => { + const updated = mutateConfig((config) => { config.logging = { ...getResolvedLoggingConfig(), ...config.logging, diff --git a/src/web-server/shared-routes.ts b/src/web-server/shared-routes.ts index ade2e0c3..05830d71 100644 --- a/src/web-server/shared-routes.ts +++ b/src/web-server/shared-routes.ts @@ -8,9 +8,10 @@ import { Router, Request, Response } from 'express'; import * as fs from 'fs'; import * as path from 'path'; import * as yaml from 'js-yaml'; -import { getCcsDir } from '../utils/config-manager'; + import { getClaudeConfigDir } from '../utils/claude-config-path'; import { requireLocalAccessWhenAuthDisabled } from './middleware/auth-middleware'; +import { getCcsDir } from '../config/config-loader-facade'; export const sharedRoutes = Router(); diff --git a/src/web-server/usage/aggregator.ts b/src/web-server/usage/aggregator.ts index 129f2d73..537da727 100644 --- a/src/web-server/usage/aggregator.ts +++ b/src/web-server/usage/aggregator.ts @@ -24,7 +24,7 @@ import { getCacheAge, } from './disk-cache'; import { ok, info, fail } from '../../utils/ui'; -import { getCcsDir } from '../../utils/config-manager'; + import { getClaudeConfigDir, getDefaultClaudeConfigDir } from '../../utils/claude-config-path'; import { loadCachedCliproxyData, @@ -40,6 +40,7 @@ import { getModelsUsed, getProviderModelKey, } from './model-identity'; +import { getCcsDir } from '../../config/config-loader-facade'; // ============================================================================ // Multi-Instance Support - Aggregate usage from CCS profiles diff --git a/src/web-server/usage/cliproxy-usage-syncer.ts b/src/web-server/usage/cliproxy-usage-syncer.ts index 6ab48cc7..83d15a69 100644 --- a/src/web-server/usage/cliproxy-usage-syncer.ts +++ b/src/web-server/usage/cliproxy-usage-syncer.ts @@ -19,8 +19,9 @@ import { type CliproxyUsageHistoryDetail, } from './cliproxy-usage-transformer'; import type { DailyUsage, HourlyUsage, MonthlyUsage } from './types'; -import { getCcsDir } from '../../utils/config-manager'; + import { ok, info, warn } from '../../utils/ui'; +import { getCcsDir } from '../../config/config-loader-facade'; interface CliproxyUsageSnapshot { version: number; diff --git a/src/web-server/usage/disk-cache.ts b/src/web-server/usage/disk-cache.ts index a2bc1e86..5edfdf7d 100644 --- a/src/web-server/usage/disk-cache.ts +++ b/src/web-server/usage/disk-cache.ts @@ -12,7 +12,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { DailyUsage, HourlyUsage, MonthlyUsage, SessionUsage } from './types'; import { ok, info, warn } from '../../utils/ui'; -import { getCcsDir } from '../../utils/config-manager'; +import { getCcsDir } from '../../config/config-loader-facade'; // Cache configuration function getCacheDir() { diff --git a/tests/unit/utils/browser/browser-setup.test.ts b/tests/unit/utils/browser/browser-setup.test.ts index fd180579..755dc8d7 100644 --- a/tests/unit/utils/browser/browser-setup.test.ts +++ b/tests/unit/utils/browser/browser-setup.test.ts @@ -111,7 +111,7 @@ describe('browser setup', () => { const deps: BrowserSetupDeps = { getBrowserConfig: () => config.browser, - mutateUnifiedConfig: (mutator) => { + mutateConfig: (mutator) => { mutator(config); return config; }, @@ -166,7 +166,7 @@ describe('browser setup', () => { const deps: BrowserSetupDeps = { getBrowserConfig: () => config.browser, - mutateUnifiedConfig: (mutator) => { + mutateConfig: (mutator) => { mutator(config); return config; },