refactor(dispatcher): extract bootstrap and pre-dispatch handlers from ccs.ts

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
This commit is contained in:
Tam Nhu Tran
2026-05-02 23:27:05 -04:00
parent f759cfeaaa
commit 0cf4ad7b48
5 changed files with 610 additions and 227 deletions
+13 -226
View File
@@ -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<void> {
// 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<void> {
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 <subcommand>` 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;
@@ -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<typeof spyOn>;
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=<path> (= 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();
});
});
@@ -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<typeof spyOn>;
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);
});
});
+140 -1
View File
@@ -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<DispatcherBootstrap> {
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 ==========
+155
View File
@@ -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<boolean> {
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 <subcommand>` 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;
}