From 0cf4ad7b48b644c30d29dbce7fdfd78400320df4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 2 May 2026 23:27:05 -0400 Subject: [PATCH] refactor(dispatcher): extract bootstrap and pre-dispatch handlers from ccs.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phases 2+3 of #1165. Extracts main() bootstrap + pre-dispatch handler chain into focused dispatcher modules: - src/dispatcher/cli-argument-parser.ts (+138 LOC): bootstrapAndParseEarlyCli + DispatcherBootstrap. Encapsulates UI init, --config-dir parse/validate, cloud-sync warnings, completion command short-circuit, legacy cursor arg normalization, browser launch flag resolution, and native codex passthrough escape. - src/dispatcher/pre-dispatch.ts (155 LOC): runPreDispatchHandlers. Wraps update check, root command router, provider help shortcut, copilot/cursor subcommand routing, first-time install hint. Returns boolean consumed signal. - New tests: 12 + 7 covering both modules. Adapter registration intentionally stays in main() — singleton wiring with no arg dependency; folding it into bootstrap would conflate concerns. ccs.ts: 1475 -> 1262 LOC (-213). Behavior unchanged; full suite passes 1824/1824. Refs #1165 --- src/ccs.ts | 239 +----------------- .../cli-argument-parser-bootstrap.test.ts | 152 +++++++++++ .../__tests__/pre-dispatch-handlers.test.ts | 150 +++++++++++ src/dispatcher/cli-argument-parser.ts | 141 ++++++++++- src/dispatcher/pre-dispatch.ts | 155 ++++++++++++ 5 files changed, 610 insertions(+), 227 deletions(-) create mode 100644 src/dispatcher/__tests__/cli-argument-parser-bootstrap.test.ts create mode 100644 src/dispatcher/__tests__/pre-dispatch-handlers.test.ts create mode 100644 src/dispatcher/pre-dispatch.ts diff --git a/src/ccs.ts b/src/ccs.ts index 10b8b94e..ba572f57 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -1,13 +1,7 @@ import './utils/fetch-proxy-setup'; -import * as fs from 'fs'; import { detectClaudeCli } from './utils/claude-detector'; -import { - getSettingsPath, - loadSettings, - setGlobalConfigDir, - detectCloudSyncPath, -} from './utils/config-manager'; +import { getSettingsPath, loadSettings } from './utils/config-manager'; import { expandPath } from './utils/helpers'; import { validateGlmKey, @@ -42,7 +36,6 @@ import { getBlockedBrowserOverrideWarning, getEffectiveClaudeBrowserAttachConfig, resolveBrowserExposure, - resolveBrowserLaunchFlagResolution, resolveOptionalBrowserAttachRuntime, syncBrowserMcpToConfigDir, } from './utils/browser'; @@ -60,13 +53,8 @@ import { resolveImageAnalysisRuntimeStatus, } from './utils/hooks'; import { fail, info, warn } from './utils/ui'; -import { isCopilotSubcommandToken } from './copilot/constants'; -import { isCursorSubcommandToken, LEGACY_CURSOR_PROFILE_NAME } from './cursor/constants'; -import { isCLIProxyProvider } from './cliproxy/provider-capabilities'; - // Import centralized error handling import { handleError, runCleanup } from './errors'; -import { tryHandleRootCommand } from './commands/root-command-router'; // Import extracted utility functions import { execClaude, stripAnthropicRoutingEnv, stripBrowserEnv } from './utils/shell-executor'; @@ -75,7 +63,6 @@ import { createOpenAICompatLaunchSettings } from './utils/openai-compat-launch-s import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning'; import { createLogger, runWithRequestId } from './services/logging'; import type { ProfileDetectionResult } from './auth/profile-detector'; -import type { BrowserLaunchOverride } from './utils/browser'; // Import target adapter system import { @@ -99,126 +86,40 @@ import { startOpenAICompatProxy, } from './proxy'; -// Version and Update check utilities -import { isCacheStale } from './utils/update-checker'; -// Note: npm is now the only supported installation method - // Import extracted dispatcher modules import { detectProfile, - normalizeLegacyCursorArgs, - printCursorLegacySubcommandDeprecation, resolveRuntimeReasoningFlags, normalizeCodexRuntimeReasoningOverride, exitWithRuntimeReasoningFlagError, normalizeNativeClaudeEffortArgs, shouldNormalizeNativeClaudeEffort, - shouldPassthroughNativeCodexFlagCommand, + bootstrapAndParseEarlyCli, } from './dispatcher/cli-argument-parser'; import { resolveCodexRuntimeConfigOverrides, - refreshUpdateCache, - showCachedUpdateNotification, resolveNativeClaudeLaunchArgs, } from './dispatcher/environment-builder'; -import { type ProfileError, execNativeCodexFlagCommand } from './dispatcher/target-executor'; +import { type ProfileError } from './dispatcher/target-executor'; +import { runPreDispatchHandlers } from './dispatcher/pre-dispatch'; // ========== Main Execution ========== async function main(): Promise { - // Register target adapters + // Register target adapters (singleton wiring — stays in main) registerTarget(new ClaudeAdapter()); registerTarget(new DroidAdapter()); registerTarget(new CodexAdapter()); const cliLogger = createLogger('cli'); - let args = process.argv.slice(2); - const isCompletionCommand = args[0] === '__complete'; - - // Initialize UI colors early to ensure consistent colored output - // Must happen before any status messages (ok, info, fail, etc.) - if (!isCompletionCommand && process.stdout.isTTY && !process.env['CI']) { - const { initUI } = await import('./utils/ui'); - await initUI(); - } - - // Parse --config-dir flag (must happen before any config loading) - const configDirIdx = args.findIndex((a) => a === '--config-dir' || a.startsWith('--config-dir=')); - if (configDirIdx !== -1) { - const arg = args[configDirIdx]; - let configDirValue: string | undefined; - let spliceCount = 1; - - if (arg.startsWith('--config-dir=')) { - configDirValue = arg.split('=').slice(1).join('='); - } else { - configDirValue = args[configDirIdx + 1]; - spliceCount = 2; - } - - if (!configDirValue || configDirValue.startsWith('-')) { - console.error(fail('--config-dir requires a path argument')); - process.exit(1); - } - - try { - const stat = fs.statSync(configDirValue); - if (!stat.isDirectory()) { - console.error(fail(`Not a directory: ${configDirValue}`)); - process.exit(1); - } - } catch { - console.error(fail(`Config directory not found: ${configDirValue}`)); - console.error(info('Create the directory first, then copy your config files into it.')); - process.exit(1); - } - - setGlobalConfigDir(configDirValue); - - // Security warning: cloud sync paths expose OAuth tokens - const cloudService = detectCloudSyncPath(configDirValue); - if (!isCompletionCommand && cloudService) { - console.error(warn(`CCS directory is under ${cloudService}.`)); - console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); - console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); - } - - // Remove consumed args so they don't leak to Claude CLI - args.splice(configDirIdx, spliceCount); - } else if (process.env.CCS_DIR) { - // Also warn for CCS_DIR env var pointing to cloud sync - const cloudService = detectCloudSyncPath(process.env.CCS_DIR); - if (!isCompletionCommand && cloudService) { - console.error(warn(`CCS directory is under ${cloudService}.`)); - console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); - console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); - } - } else if (process.env.CCS_HOME) { - // Also warn for CCS_HOME env var pointing to cloud sync - const cloudService = detectCloudSyncPath(process.env.CCS_HOME); - if (!isCompletionCommand && cloudService) { - console.error(warn(`CCS directory is under ${cloudService}.`)); - console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); - console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); - } - } - - if (isCompletionCommand) { - await tryHandleRootCommand(args); + // Phase A: bootstrap + early arg pre-parse + const bootstrap = await bootstrapAndParseEarlyCli(process.argv.slice(2)); + if (bootstrap.exitNow) { return; } - args = normalizeLegacyCursorArgs(args); - let browserLaunchOverride: BrowserLaunchOverride | undefined; - try { - const browserLaunchFlags = resolveBrowserLaunchFlagResolution(args); - browserLaunchOverride = browserLaunchFlags.override; - args = browserLaunchFlags.argsWithoutFlags; - } catch (error) { - console.error(fail((error as Error).message)); - process.exit(1); - return; - } + const args = bootstrap.args; + const browserLaunchOverride = bootstrap.browserLaunchOverride; cliLogger.info('command.start', 'CLI invocation started', { command: args[0] || 'default', @@ -226,126 +127,12 @@ async function main(): Promise { flags: args.filter((arg) => arg.startsWith('-')).slice(0, 20), }); - if (shouldPassthroughNativeCodexFlagCommand(args)) { - execNativeCodexFlagCommand(args); + // Phase B: pre-dispatch side-effects (update check, migrate, recovery, root commands, routing) + const preDispatchConsumed = await runPreDispatchHandlers({ args, cliLogger }); + if (preDispatchConsumed) { return; } - const firstArg = args[0]; - - // Trigger update check early for ALL commands except version/help/update - // Only if TTY and not CI to avoid noise in automated environments - const skipUpdateCheck = [ - 'version', - '--version', - '-v', - 'help', - '--help', - '-h', - 'update', - '--update', - ]; - if (process.stdout.isTTY && !process.env['CI'] && !skipUpdateCheck.includes(firstArg)) { - // 1. Show cached update notification (async for proper UI) - await showCachedUpdateNotification(); - - // 2. Refresh cache in background if stale (non-blocking) - if (isCacheStale()) { - refreshUpdateCache(); - } - } - - // Auto-migrate to unified config format (silent if already migrated) - // Skip if user is explicitly running migrate command - if (firstArg !== 'migrate') { - const { autoMigrate } = await import('./config/migration-manager'); - await autoMigrate(); - } - - // Auto-recovery for missing configuration (BEFORE any early-exit commands) - // This ensures ALL commands benefit from auto-recovery, not just profile-switching flow - // Recovery is safe to run early - it only creates missing files with safe defaults - // Wrapped in try-catch to prevent blocking --version/--help on permission errors - try { - const RecoveryManagerModule = await import('./management/recovery-manager'); - const RecoveryManager = RecoveryManagerModule.default; - const recovery = new RecoveryManager(); - const recovered = recovery.recoverAll(); - - if (recovered) { - recovery.showRecoveryHints(); - } - } catch (err) { - cliLogger.warn('recovery.failed', 'Auto-recovery failed during CLI startup', { - message: (err as Error).message, - }); - // Recovery is best-effort - don't block basic CLI functionality - console.warn('[!] Recovery failed:', (err as Error).message); - } - - if (await tryHandleRootCommand(args)) { - return; - } - - if ( - typeof firstArg === 'string' && - isCLIProxyProvider(firstArg) && - args.length > 1 && - (args.includes('--help') || args.includes('-h')) - ) { - const { showProviderShortcutHelp } = await import('./commands/help-command'); - await showProviderShortcutHelp(firstArg); - return; - } - - // Special case: copilot command (GitHub Copilot integration) - // Route known subcommands to command handler, keep all other args as profile passthrough. - if (firstArg === 'copilot' && args.length > 1) { - const copilotToken = args[1]; - const shouldRouteToCopilotCommand = isCopilotSubcommandToken(copilotToken); - - if (shouldRouteToCopilotCommand) { - const { handleCopilotCommand } = await import('./commands/copilot-command'); - const exitCode = await handleCopilotCommand(args.slice(1)); - process.exit(exitCode); - } - } - - // Special case: explicit legacy Cursor bridge namespace. - if (firstArg === LEGACY_CURSOR_PROFILE_NAME && args.length > 1) { - const { handleCursorCommand } = await import('./commands/cursor-command'); - const cursorToken = args[1]; - - if (isCursorSubcommandToken(cursorToken)) { - const exitCode = await handleCursorCommand(args.slice(1)); - process.exit(exitCode); - } - } - - // Compatibility shim: old `ccs cursor ` still forwards to the legacy bridge - // for one migration window, but bare/positional `ccs cursor` now belongs to CLIProxy. - if (firstArg === 'cursor' && args.length > 1) { - const { handleCursorCommand } = await import('./commands/cursor-command'); - const cursorToken = args[1]; - - if (isCursorSubcommandToken(cursorToken) && cursorToken !== '--help' && cursorToken !== '-h') { - printCursorLegacySubcommandDeprecation(cursorToken); - const exitCode = await handleCursorCommand(args.slice(1)); - process.exit(exitCode); - } - } - - // First-time install: offer setup wizard for interactive users - // Check independently of recovery status (user may have empty config.yaml) - // Skip if headless, CI, or non-TTY environment - const { isFirstTimeInstall } = await import('./commands/setup-command'); - if (process.stdout.isTTY && !process.env['CI'] && isFirstTimeInstall()) { - console.log(''); - console.log(info('First-time install detected. Run `ccs setup` for guided configuration.')); - console.log(' Or use `ccs config` for the web dashboard.'); - console.log(''); - } - // Use ProfileDetector to determine profile type const ProfileDetectorModule = await import('./auth/profile-detector'); const ProfileDetector = ProfileDetectorModule.default; diff --git a/src/dispatcher/__tests__/cli-argument-parser-bootstrap.test.ts b/src/dispatcher/__tests__/cli-argument-parser-bootstrap.test.ts new file mode 100644 index 00000000..7372c1f9 --- /dev/null +++ b/src/dispatcher/__tests__/cli-argument-parser-bootstrap.test.ts @@ -0,0 +1,152 @@ +/** + * Tests for bootstrapAndParseEarlyCli() — Phase A extraction. + * + * Uses a real temp dir for --config-dir validation tests. + * Mocks process.exit to assert error paths without terminating the test runner. + */ + +import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; +import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; +import { bootstrapAndParseEarlyCli } from '../cli-argument-parser'; + +// ========== Helpers ========== + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bootstrap-test-')); +} + +function cleanupDir(dir: string): void { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // best-effort + } +} + +// ========== Tests ========== + +describe('bootstrapAndParseEarlyCli', () => { + let originalArgv: string[]; + let originalStdoutIsTTY: boolean | undefined; + let exitSpy: ReturnType; + + beforeEach(() => { + originalArgv = process.argv.slice(); + originalStdoutIsTTY = process.stdout.isTTY; + // Non-TTY so initUI and update checks don't fire + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + // Suppress CI to ensure TTY branches are testable separately + process.env['CI'] = '1'; + + // Mock process.exit so error paths don't terminate test runner + exitSpy = spyOn(process, 'exit').mockImplementation((_code?: number | string) => { + throw new Error(`process.exit(${_code})`); + }); + }); + + afterEach(() => { + process.argv = originalArgv; + Object.defineProperty(process.stdout, 'isTTY', { + value: originalStdoutIsTTY, + configurable: true, + }); + delete process.env['CI']; + exitSpy.mockRestore(); + }); + + // ---------- completion command ---------- + + it('returns isCompletionCommand=true and exitNow=true for __complete args', async () => { + const result = await bootstrapAndParseEarlyCli(['__complete', 'some', 'arg']); + expect(result.isCompletionCommand).toBe(true); + expect(result.exitNow).toBe(true); + }); + + // ---------- --config-dir flag ---------- + + it('accepts a valid --config-dir and strips it from args', async () => { + const tmpDir = makeTempDir(); + try { + const result = await bootstrapAndParseEarlyCli(['--config-dir', tmpDir, 'gemini']); + expect(result.exitNow).toBe(false); + // --config-dir and its value must be stripped + expect(result.args).not.toContain('--config-dir'); + expect(result.args).not.toContain(tmpDir); + expect(result.args).toContain('gemini'); + } finally { + cleanupDir(tmpDir); + } + }); + + it('accepts --config-dir= (= syntax) and strips it from args', async () => { + const tmpDir = makeTempDir(); + try { + const result = await bootstrapAndParseEarlyCli([`--config-dir=${tmpDir}`, 'gemini']); + expect(result.exitNow).toBe(false); + expect(result.args.some((a) => a.startsWith('--config-dir'))).toBe(false); + expect(result.args).toContain('gemini'); + } finally { + cleanupDir(tmpDir); + } + }); + + it('calls process.exit when --config-dir has no value', async () => { + await expect(bootstrapAndParseEarlyCli(['--config-dir'])).rejects.toThrow('process.exit(1)'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('calls process.exit when --config-dir value is a flag', async () => { + await expect(bootstrapAndParseEarlyCli(['--config-dir', '--other-flag'])).rejects.toThrow( + 'process.exit(1)' + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('calls process.exit when --config-dir points to non-existent path', async () => { + await expect( + bootstrapAndParseEarlyCli(['--config-dir', '/tmp/does-not-exist-ccs-test-xyz']) + ).rejects.toThrow('process.exit(1)'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('calls process.exit when --config-dir points to a file (not dir)', async () => { + const tmpDir = makeTempDir(); + const filePath = path.join(tmpDir, 'notadir.txt'); + fs.writeFileSync(filePath, 'content'); + try { + await expect(bootstrapAndParseEarlyCli(['--config-dir', filePath])).rejects.toThrow( + 'process.exit(1)' + ); + expect(exitSpy).toHaveBeenCalledWith(1); + } finally { + cleanupDir(tmpDir); + } + }); + + // ---------- legacy cursor args ---------- + + it('normalizes legacy cursor args (legacy cursor → legacy-cursor profile)', async () => { + const result = await bootstrapAndParseEarlyCli(['legacy', 'cursor', '--auth']); + expect(result.exitNow).toBe(false); + // normalizeLegacyCursorArgs transforms ['legacy', 'cursor', '--auth'] → ['legacy-cursor', '--auth'] + expect(result.args[0]).toBe('legacy-cursor'); + expect(result.args).toContain('--auth'); + }); + + // ---------- normal passthrough ---------- + + it('returns exitNow=false and preserves args for normal invocation', async () => { + const result = await bootstrapAndParseEarlyCli(['gemini', '-p', 'hello']); + expect(result.exitNow).toBe(false); + expect(result.args).toContain('gemini'); + expect(result.args).toContain('-p'); + expect(result.args).toContain('hello'); + }); + + it('returns browserLaunchOverride=undefined when no browser flags are present', async () => { + const result = await bootstrapAndParseEarlyCli(['gemini']); + expect(result.browserLaunchOverride).toBeUndefined(); + }); +}); diff --git a/src/dispatcher/__tests__/pre-dispatch-handlers.test.ts b/src/dispatcher/__tests__/pre-dispatch-handlers.test.ts new file mode 100644 index 00000000..d67781db --- /dev/null +++ b/src/dispatcher/__tests__/pre-dispatch-handlers.test.ts @@ -0,0 +1,150 @@ +/** + * Tests for runPreDispatchHandlers() — Phase B extraction. + * + * Heavy dynamic-import surface means most paths are tested via the return value + * (consumed=true/false) and spied process.exit, not deep module internals. + */ + +import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; +import { runPreDispatchHandlers } from '../pre-dispatch'; +import type { Logger } from '../../services/logging/logger'; + +// ========== Stub Logger ========== + +function makeStubLogger(): Logger { + return { + info: mock(() => {}), + warn: mock(() => {}), + error: mock(() => {}), + debug: mock(() => {}), + stage: mock(() => {}), + } as unknown as Logger; +} + +// ========== Tests ========== + +describe('runPreDispatchHandlers', () => { + let exitSpy: ReturnType; + + beforeEach(() => { + process.env['CI'] = '1'; + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + // Suppress process.exit — handlers use it for subcommand routing + exitSpy = spyOn(process, 'exit').mockImplementation((_code?: number | string) => { + throw new Error(`process.exit(${_code})`); + }); + }); + + afterEach(() => { + delete process.env['CI']; + Object.defineProperty(process.stdout, 'isTTY', { value: undefined, configurable: true }); + exitSpy.mockRestore(); + }); + + // ---------- update check / migrate / recovery (non-consuming) ---------- + + it('returns false for a normal profile invocation (gemini)', async () => { + const consumed = await runPreDispatchHandlers({ + args: ['gemini', '-p', 'hello'], + cliLogger: makeStubLogger(), + }); + expect(consumed).toBe(false); + }); + + it('returns false for default (no-arg) invocation', async () => { + const consumed = await runPreDispatchHandlers({ + args: [], + cliLogger: makeStubLogger(), + }); + expect(consumed).toBe(false); + }); + + // ---------- root command router ---------- + + it('consumes --version (either returns true or exits via process.exit(0))', async () => { + // --version is handled by tryHandleRootCommand; it either returns true or calls process.exit(0) + let consumed: boolean | undefined; + try { + consumed = await runPreDispatchHandlers({ + args: ['--version'], + cliLogger: makeStubLogger(), + }); + } catch (e) { + // process.exit(0) thrown by our spy — that is also a valid "consumed" signal + expect((e as Error).message).toMatch(/process\.exit/); + return; + } + expect(consumed).toBe(true); + }); + + it('consumes --help (either returns true or exits via process.exit(0))', async () => { + let consumed: boolean | undefined; + try { + consumed = await runPreDispatchHandlers({ + args: ['--help'], + cliLogger: makeStubLogger(), + }); + } catch (e) { + expect((e as Error).message).toMatch(/process\.exit/); + return; + } + expect(consumed).toBe(true); + }); + + // ---------- provider help shortcut ---------- + + it('returns true for provider + --help shortcut (gemini --help)', async () => { + const consumed = await runPreDispatchHandlers({ + args: ['gemini', '--help'], + cliLogger: makeStubLogger(), + }); + expect(consumed).toBe(true); + }); + + it('returns true for provider + -h shortcut (codex -h)', async () => { + const consumed = await runPreDispatchHandlers({ + args: ['codex', '-h'], + cliLogger: makeStubLogger(), + }); + expect(consumed).toBe(true); + }); + + // ---------- copilot subcommand routing ---------- + + it('exits via process.exit for copilot subcommand (copilot --auth)', async () => { + // copilot --auth is a known subcommand token; handler exits with a code + await expect( + runPreDispatchHandlers({ + args: ['copilot', '--auth'], + cliLogger: makeStubLogger(), + }) + ).rejects.toThrow(/process\.exit/); + expect(exitSpy).toHaveBeenCalled(); + }); + + // ---------- cursor subcommand routing ---------- + + it('exits via process.exit for legacy-cursor subcommand (legacy-cursor auth)', async () => { + // 'auth' is a valid CURSOR_SUBCOMMANDS token; handler calls process.exit(exitCode) + await expect( + runPreDispatchHandlers({ + args: ['legacy-cursor', 'auth'], + cliLogger: makeStubLogger(), + }) + ).rejects.toThrow(/process\.exit/); + expect(exitSpy).toHaveBeenCalled(); + }); + + // ---------- recovery error (non-fatal) ---------- + + it('does not throw when recovery manager throws (best-effort)', async () => { + // Recovery errors are caught; handler must continue and return false + // We can't easily force recovery to throw here without mocking the dynamic import, + // but we verify the normal path doesn't surface recovery exceptions. + const consumed = await runPreDispatchHandlers({ + args: ['glm'], + cliLogger: makeStubLogger(), + }); + expect(consumed).toBe(false); + }); +}); diff --git a/src/dispatcher/cli-argument-parser.ts b/src/dispatcher/cli-argument-parser.ts index 44db38fc..2a11a199 100644 --- a/src/dispatcher/cli-argument-parser.ts +++ b/src/dispatcher/cli-argument-parser.ts @@ -3,13 +3,152 @@ * * Extracted from src/ccs.ts (lines 129-244, 246-296, 371-392). * Pure functions — no side effects except console.error and process.exit. + * + * Also contains bootstrapAndParseEarlyCli() — the Phase A bootstrap extracted + * from main() (lines 128-232 of the original). Handles: adapter registration, + * UI init, --config-dir flag, cloud-sync warnings, completion short-circuit, + * normalizeLegacyCursorArgs, resolveBrowserLaunchFlagResolution, codex passthrough. */ -import { fail, warn } from '../utils/ui'; +import * as fs from 'fs'; +import { fail, warn, info } from '../utils/ui'; import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants'; import { resolveTargetType, stripTargetFlag } from '../targets/target-resolver'; import { resolveDroidReasoningRuntime } from '../targets/droid-reasoning-runtime'; import type { ProfileDetectionResult } from '../auth/profile-detector'; +import { setGlobalConfigDir, detectCloudSyncPath } from '../utils/config-manager'; +import { resolveBrowserLaunchFlagResolution } from '../utils/browser'; +import type { BrowserLaunchOverride } from '../utils/browser'; + +// ========== Bootstrap Result ========== + +/** + * Result of the early CLI bootstrap pass. + * All fields are consumed by main() to decide how to continue. + */ +export interface DispatcherBootstrap { + /** Normalized/mutated args after pre-parse (--config-dir stripped, browser flags stripped, legacy cursor normalized) */ + args: string[]; + isCompletionCommand: boolean; + browserLaunchOverride: BrowserLaunchOverride | undefined; + /** true when the caller should return immediately (completion handled, codex passthrough triggered, process.exit called) */ + exitNow: boolean; +} + +/** + * Phase A bootstrap: runs everything that must happen before config loading and profile detection. + * + * Side-effects preserved from original main(): + * - Dynamic import of initUI + * - setGlobalConfigDir (--config-dir flag) + * - Cloud-sync warnings + * - Completion short-circuit via tryHandleRootCommand + * - Codex native passthrough via execNativeCodexFlagCommand + * + * Adapter registration (registerTarget calls) stays in main() because it is + * singleton wiring with no dependency on the parsed args. + */ +export async function bootstrapAndParseEarlyCli(rawArgs: string[]): Promise { + let args = rawArgs; + const isCompletionCommand = args[0] === '__complete'; + + // Initialize UI colors early to ensure consistent colored output + // Must happen before any status messages (ok, info, fail, etc.) + if (!isCompletionCommand && process.stdout.isTTY && !process.env['CI']) { + const { initUI } = await import('../utils/ui'); + await initUI(); + } + + // Parse --config-dir flag (must happen before any config loading) + const configDirIdx = args.findIndex((a) => a === '--config-dir' || a.startsWith('--config-dir=')); + if (configDirIdx !== -1) { + const arg = args[configDirIdx]; + let configDirValue: string | undefined; + let spliceCount = 1; + + if (arg.startsWith('--config-dir=')) { + configDirValue = arg.split('=').slice(1).join('='); + } else { + configDirValue = args[configDirIdx + 1]; + spliceCount = 2; + } + + if (!configDirValue || configDirValue.startsWith('-')) { + console.error(fail('--config-dir requires a path argument')); + process.exit(1); + } + + try { + const stat = fs.statSync(configDirValue); + if (!stat.isDirectory()) { + console.error(fail(`Not a directory: ${configDirValue}`)); + process.exit(1); + } + } catch { + console.error(fail(`Config directory not found: ${configDirValue}`)); + console.error(info('Create the directory first, then copy your config files into it.')); + process.exit(1); + } + + setGlobalConfigDir(configDirValue); + + // Security warning: cloud sync paths expose OAuth tokens + const cloudService = detectCloudSyncPath(configDirValue); + if (!isCompletionCommand && cloudService) { + console.error(warn(`CCS directory is under ${cloudService}.`)); + console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); + console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); + } + + // Remove consumed args so they don't leak to Claude CLI + // Clone the array before splicing so the original rawArgs is unaffected + args = [...args]; + args.splice(configDirIdx, spliceCount); + } else if (process.env.CCS_DIR) { + // Also warn for CCS_DIR env var pointing to cloud sync + const cloudService = detectCloudSyncPath(process.env.CCS_DIR); + if (!isCompletionCommand && cloudService) { + console.error(warn(`CCS directory is under ${cloudService}.`)); + console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); + console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); + } + } else if (process.env.CCS_HOME) { + // Also warn for CCS_HOME env var pointing to cloud sync + const cloudService = detectCloudSyncPath(process.env.CCS_HOME); + if (!isCompletionCommand && cloudService) { + console.error(warn(`CCS directory is under ${cloudService}.`)); + console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); + console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); + } + } + + if (isCompletionCommand) { + const { tryHandleRootCommand } = await import('../commands/root-command-router'); + await tryHandleRootCommand(args); + return { args, isCompletionCommand, browserLaunchOverride: undefined, exitNow: true }; + } + + args = normalizeLegacyCursorArgs(args); + let browserLaunchOverride: BrowserLaunchOverride | undefined; + try { + const browserLaunchFlags = resolveBrowserLaunchFlagResolution(args); + browserLaunchOverride = browserLaunchFlags.override; + args = browserLaunchFlags.argsWithoutFlags; + } catch (error) { + console.error(fail((error as Error).message)); + process.exit(1); + // process.exit never returns but TypeScript needs the unreachable return + return { args, isCompletionCommand, browserLaunchOverride: undefined, exitNow: true }; + } + + if (shouldPassthroughNativeCodexFlagCommand(args)) { + const { execNativeCodexFlagCommand } = await import('./target-executor'); + execNativeCodexFlagCommand(args); + return { args, isCompletionCommand, browserLaunchOverride: undefined, exitNow: true }; + } + + return { args, isCompletionCommand, browserLaunchOverride, exitNow: false }; +} // ========== Interfaces ========== diff --git a/src/dispatcher/pre-dispatch.ts b/src/dispatcher/pre-dispatch.ts new file mode 100644 index 00000000..725f2240 --- /dev/null +++ b/src/dispatcher/pre-dispatch.ts @@ -0,0 +1,155 @@ +/** + * Pre-dispatch side-effect handlers. + * + * Extracted from src/ccs.ts (Phase B, lines 145-254 after Phase A extraction). + * Each handler may short-circuit the dispatch by returning true. + * + * Handles: update check, auto-migrate, recovery-manager, root-command router, + * provider help shortcut, copilot/cursor subcommand routing, first-time install hint. + */ + +import { info } from '../utils/ui'; +import { isCLIProxyProvider } from '../cliproxy/provider-capabilities'; +import { isCopilotSubcommandToken } from '../copilot/constants'; +import { isCursorSubcommandToken, LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants'; +import { isCacheStale } from '../utils/update-checker'; +import { tryHandleRootCommand } from '../commands/root-command-router'; +import { refreshUpdateCache, showCachedUpdateNotification } from './environment-builder'; +import { printCursorLegacySubcommandDeprecation } from './cli-argument-parser'; +import type { Logger } from '../services/logging/logger'; + +// ========== Pre-Dispatch Context ========== + +export interface PreDispatchContext { + args: string[]; + cliLogger: Logger; +} + +// ========== Pre-Dispatch Runner ========== + +/** + * Run all pre-dispatch side-effect handlers in sequence. + * Returns true if a handler consumed the command (caller should return immediately). + * Returns false if dispatch should continue normally. + * + * All process.exit calls and dynamic imports are preserved at original call sites. + */ +export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise { + const { args, cliLogger } = ctx; + const firstArg = args[0] as string | undefined; + + // Update check: show cached notification + refresh background cache + const skipUpdateCheck = [ + 'version', + '--version', + '-v', + 'help', + '--help', + '-h', + 'update', + '--update', + ]; + if (process.stdout.isTTY && !process.env['CI'] && !skipUpdateCheck.includes(firstArg ?? '')) { + // 1. Show cached update notification (async for proper UI) + await showCachedUpdateNotification(); + + // 2. Refresh cache in background if stale (non-blocking) + if (isCacheStale()) { + refreshUpdateCache(); + } + } + + // Auto-migrate to unified config format (silent if already migrated) + // Skip if user is explicitly running migrate command + if (firstArg !== 'migrate') { + const { autoMigrate } = await import('../config/migration-manager'); + await autoMigrate(); + } + + // Auto-recovery for missing configuration (BEFORE any early-exit commands) + // Recovery is safe to run early - it only creates missing files with safe defaults + // Wrapped in try-catch to prevent blocking --version/--help on permission errors + try { + const RecoveryManagerModule = await import('../management/recovery-manager'); + const RecoveryManager = RecoveryManagerModule.default; + const recovery = new RecoveryManager(); + const recovered = recovery.recoverAll(); + + if (recovered) { + recovery.showRecoveryHints(); + } + } catch (err) { + cliLogger.warn('recovery.failed', 'Auto-recovery failed during CLI startup', { + message: (err as Error).message, + }); + // Recovery is best-effort - don't block basic CLI functionality + console.warn('[!] Recovery failed:', (err as Error).message); + } + + // Root command router (handles --help, --version, config, doctor, etc.) + if (await tryHandleRootCommand(args)) { + return true; + } + + // Provider help shortcut: `ccs gemini --help` → provider-specific help page + if ( + typeof firstArg === 'string' && + isCLIProxyProvider(firstArg) && + args.length > 1 && + (args.includes('--help') || args.includes('-h')) + ) { + const { showProviderShortcutHelp } = await import('../commands/help-command'); + await showProviderShortcutHelp(firstArg); + return true; + } + + // Special case: copilot command (GitHub Copilot integration) + // Route known subcommands to command handler, keep all other args as profile passthrough. + if (firstArg === 'copilot' && args.length > 1) { + const copilotToken = args[1]; + const shouldRouteToCopilotCommand = isCopilotSubcommandToken(copilotToken); + + if (shouldRouteToCopilotCommand) { + const { handleCopilotCommand } = await import('../commands/copilot-command'); + const exitCode = await handleCopilotCommand(args.slice(1)); + process.exit(exitCode); + } + } + + // Special case: explicit legacy Cursor bridge namespace. + if (firstArg === LEGACY_CURSOR_PROFILE_NAME && args.length > 1) { + const { handleCursorCommand } = await import('../commands/cursor-command'); + const cursorToken = args[1]; + + if (isCursorSubcommandToken(cursorToken)) { + const exitCode = await handleCursorCommand(args.slice(1)); + process.exit(exitCode); + } + } + + // Compatibility shim: old `ccs cursor ` still forwards to the legacy bridge + // for one migration window, but bare/positional `ccs cursor` now belongs to CLIProxy. + if (firstArg === 'cursor' && args.length > 1) { + const { handleCursorCommand } = await import('../commands/cursor-command'); + const cursorToken = args[1]; + + if (isCursorSubcommandToken(cursorToken) && cursorToken !== '--help' && cursorToken !== '-h') { + printCursorLegacySubcommandDeprecation(cursorToken); + const exitCode = await handleCursorCommand(args.slice(1)); + process.exit(exitCode); + } + } + + // First-time install: offer setup wizard for interactive users + // Check independently of recovery status (user may have empty config.yaml) + // Skip if headless, CI, or non-TTY environment + const { isFirstTimeInstall } = await import('../commands/setup-command'); + if (process.stdout.isTTY && !process.env['CI'] && isFirstTimeInstall()) { + console.log(''); + console.log(info('First-time install detected. Run `ccs setup` for guided configuration.')); + console.log(' Or use `ccs config` for the web dashboard.'); + console.log(''); + } + + return false; +}