mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 06:18:55 +00:00
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:
+13
-226
@@ -1,13 +1,7 @@
|
|||||||
import './utils/fetch-proxy-setup';
|
import './utils/fetch-proxy-setup';
|
||||||
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import { detectClaudeCli } from './utils/claude-detector';
|
import { detectClaudeCli } from './utils/claude-detector';
|
||||||
import {
|
import { getSettingsPath, loadSettings } from './utils/config-manager';
|
||||||
getSettingsPath,
|
|
||||||
loadSettings,
|
|
||||||
setGlobalConfigDir,
|
|
||||||
detectCloudSyncPath,
|
|
||||||
} from './utils/config-manager';
|
|
||||||
import { expandPath } from './utils/helpers';
|
import { expandPath } from './utils/helpers';
|
||||||
import {
|
import {
|
||||||
validateGlmKey,
|
validateGlmKey,
|
||||||
@@ -42,7 +36,6 @@ import {
|
|||||||
getBlockedBrowserOverrideWarning,
|
getBlockedBrowserOverrideWarning,
|
||||||
getEffectiveClaudeBrowserAttachConfig,
|
getEffectiveClaudeBrowserAttachConfig,
|
||||||
resolveBrowserExposure,
|
resolveBrowserExposure,
|
||||||
resolveBrowserLaunchFlagResolution,
|
|
||||||
resolveOptionalBrowserAttachRuntime,
|
resolveOptionalBrowserAttachRuntime,
|
||||||
syncBrowserMcpToConfigDir,
|
syncBrowserMcpToConfigDir,
|
||||||
} from './utils/browser';
|
} from './utils/browser';
|
||||||
@@ -60,13 +53,8 @@ import {
|
|||||||
resolveImageAnalysisRuntimeStatus,
|
resolveImageAnalysisRuntimeStatus,
|
||||||
} from './utils/hooks';
|
} from './utils/hooks';
|
||||||
import { fail, info, warn } from './utils/ui';
|
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 centralized error handling
|
||||||
import { handleError, runCleanup } from './errors';
|
import { handleError, runCleanup } from './errors';
|
||||||
import { tryHandleRootCommand } from './commands/root-command-router';
|
|
||||||
|
|
||||||
// Import extracted utility functions
|
// Import extracted utility functions
|
||||||
import { execClaude, stripAnthropicRoutingEnv, stripBrowserEnv } from './utils/shell-executor';
|
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 { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
|
||||||
import { createLogger, runWithRequestId } from './services/logging';
|
import { createLogger, runWithRequestId } from './services/logging';
|
||||||
import type { ProfileDetectionResult } from './auth/profile-detector';
|
import type { ProfileDetectionResult } from './auth/profile-detector';
|
||||||
import type { BrowserLaunchOverride } from './utils/browser';
|
|
||||||
|
|
||||||
// Import target adapter system
|
// Import target adapter system
|
||||||
import {
|
import {
|
||||||
@@ -99,126 +86,40 @@ import {
|
|||||||
startOpenAICompatProxy,
|
startOpenAICompatProxy,
|
||||||
} from './proxy';
|
} 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 extracted dispatcher modules
|
||||||
import {
|
import {
|
||||||
detectProfile,
|
detectProfile,
|
||||||
normalizeLegacyCursorArgs,
|
|
||||||
printCursorLegacySubcommandDeprecation,
|
|
||||||
resolveRuntimeReasoningFlags,
|
resolveRuntimeReasoningFlags,
|
||||||
normalizeCodexRuntimeReasoningOverride,
|
normalizeCodexRuntimeReasoningOverride,
|
||||||
exitWithRuntimeReasoningFlagError,
|
exitWithRuntimeReasoningFlagError,
|
||||||
normalizeNativeClaudeEffortArgs,
|
normalizeNativeClaudeEffortArgs,
|
||||||
shouldNormalizeNativeClaudeEffort,
|
shouldNormalizeNativeClaudeEffort,
|
||||||
shouldPassthroughNativeCodexFlagCommand,
|
bootstrapAndParseEarlyCli,
|
||||||
} from './dispatcher/cli-argument-parser';
|
} from './dispatcher/cli-argument-parser';
|
||||||
import {
|
import {
|
||||||
resolveCodexRuntimeConfigOverrides,
|
resolveCodexRuntimeConfigOverrides,
|
||||||
refreshUpdateCache,
|
|
||||||
showCachedUpdateNotification,
|
|
||||||
resolveNativeClaudeLaunchArgs,
|
resolveNativeClaudeLaunchArgs,
|
||||||
} from './dispatcher/environment-builder';
|
} 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 ==========
|
// ========== Main Execution ==========
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
// Register target adapters
|
// Register target adapters (singleton wiring — stays in main)
|
||||||
registerTarget(new ClaudeAdapter());
|
registerTarget(new ClaudeAdapter());
|
||||||
registerTarget(new DroidAdapter());
|
registerTarget(new DroidAdapter());
|
||||||
registerTarget(new CodexAdapter());
|
registerTarget(new CodexAdapter());
|
||||||
const cliLogger = createLogger('cli');
|
const cliLogger = createLogger('cli');
|
||||||
|
|
||||||
let args = process.argv.slice(2);
|
// Phase A: bootstrap + early arg pre-parse
|
||||||
const isCompletionCommand = args[0] === '__complete';
|
const bootstrap = await bootstrapAndParseEarlyCli(process.argv.slice(2));
|
||||||
|
if (bootstrap.exitNow) {
|
||||||
// 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);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
args = normalizeLegacyCursorArgs(args);
|
const args = bootstrap.args;
|
||||||
let browserLaunchOverride: BrowserLaunchOverride | undefined;
|
const browserLaunchOverride = bootstrap.browserLaunchOverride;
|
||||||
try {
|
|
||||||
const browserLaunchFlags = resolveBrowserLaunchFlagResolution(args);
|
|
||||||
browserLaunchOverride = browserLaunchFlags.override;
|
|
||||||
args = browserLaunchFlags.argsWithoutFlags;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(fail((error as Error).message));
|
|
||||||
process.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cliLogger.info('command.start', 'CLI invocation started', {
|
cliLogger.info('command.start', 'CLI invocation started', {
|
||||||
command: args[0] || 'default',
|
command: args[0] || 'default',
|
||||||
@@ -226,126 +127,12 @@ async function main(): Promise<void> {
|
|||||||
flags: args.filter((arg) => arg.startsWith('-')).slice(0, 20),
|
flags: args.filter((arg) => arg.startsWith('-')).slice(0, 20),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (shouldPassthroughNativeCodexFlagCommand(args)) {
|
// Phase B: pre-dispatch side-effects (update check, migrate, recovery, root commands, routing)
|
||||||
execNativeCodexFlagCommand(args);
|
const preDispatchConsumed = await runPreDispatchHandlers({ args, cliLogger });
|
||||||
|
if (preDispatchConsumed) {
|
||||||
return;
|
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
|
// Use ProfileDetector to determine profile type
|
||||||
const ProfileDetectorModule = await import('./auth/profile-detector');
|
const ProfileDetectorModule = await import('./auth/profile-detector');
|
||||||
const ProfileDetector = ProfileDetectorModule.default;
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,13 +3,152 @@
|
|||||||
*
|
*
|
||||||
* Extracted from src/ccs.ts (lines 129-244, 246-296, 371-392).
|
* Extracted from src/ccs.ts (lines 129-244, 246-296, 371-392).
|
||||||
* Pure functions — no side effects except console.error and process.exit.
|
* 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 { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants';
|
||||||
import { resolveTargetType, stripTargetFlag } from '../targets/target-resolver';
|
import { resolveTargetType, stripTargetFlag } from '../targets/target-resolver';
|
||||||
import { resolveDroidReasoningRuntime } from '../targets/droid-reasoning-runtime';
|
import { resolveDroidReasoningRuntime } from '../targets/droid-reasoning-runtime';
|
||||||
import type { ProfileDetectionResult } from '../auth/profile-detector';
|
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 ==========
|
// ========== Interfaces ==========
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user