mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-05 14:14:43 +00:00
369 lines
12 KiB
TypeScript
369 lines
12 KiB
TypeScript
/**
|
|
* CLI argument parsing and normalization utilities.
|
|
*
|
|
* Extracted from src/ccs.ts (lines 129-244, 246-296, 371-392).
|
|
* Pure functions — no side effects except console.error and process.exit.
|
|
*
|
|
* Also contains bootstrapAndParseEarlyCli() — the Phase A bootstrap extracted
|
|
* from main() (lines 128-232 of the original). Handles: adapter registration,
|
|
* UI init, --config-dir flag, cloud-sync warnings, completion short-circuit,
|
|
* normalizeLegacyCursorArgs, resolveBrowserLaunchFlagResolution, codex passthrough.
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import { fail, warn, info } from '../utils/ui';
|
|
import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants';
|
|
import { resolveTargetType, stripTargetFlag } from '../targets/target-resolver';
|
|
import { resolveDroidReasoningRuntime } from '../targets/droid-reasoning-runtime';
|
|
import type { ProfileDetectionResult } from '../auth/profile-detector';
|
|
import { setGlobalConfigDir, detectCloudSyncPath } from '../utils/config-manager';
|
|
import { resolveBrowserLaunchFlagResolution } from '../utils/browser';
|
|
import type { BrowserLaunchOverride } from '../utils/browser';
|
|
|
|
// ========== Bootstrap Result ==========
|
|
|
|
/**
|
|
* Result of the early CLI bootstrap pass.
|
|
* All fields are consumed by main() to decide how to continue.
|
|
*/
|
|
export interface DispatcherBootstrap {
|
|
/** Normalized/mutated args after pre-parse (--config-dir stripped, browser flags stripped, legacy cursor normalized) */
|
|
args: string[];
|
|
isCompletionCommand: boolean;
|
|
browserLaunchOverride: BrowserLaunchOverride | undefined;
|
|
/** true when the caller should return immediately (completion handled, codex passthrough triggered, process.exit called) */
|
|
exitNow: boolean;
|
|
}
|
|
|
|
/**
|
|
* Phase A bootstrap: runs everything that must happen before config loading and profile detection.
|
|
*
|
|
* Side-effects preserved from original main():
|
|
* - Dynamic import of initUI
|
|
* - setGlobalConfigDir (--config-dir flag)
|
|
* - Cloud-sync warnings
|
|
* - Completion short-circuit via tryHandleRootCommand
|
|
* - Codex native passthrough via execNativeCodexCommand
|
|
*
|
|
* 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 (shouldPassthroughNativeCodexCommand(args)) {
|
|
const { execNativeCodexCommand } = await import('./target-executor');
|
|
execNativeCodexCommand(args);
|
|
return { args, isCompletionCommand, browserLaunchOverride: undefined, exitNow: true };
|
|
}
|
|
|
|
return { args, isCompletionCommand, browserLaunchOverride, exitNow: false };
|
|
}
|
|
|
|
// ========== Interfaces ==========
|
|
|
|
export interface DetectedProfile {
|
|
profile: string;
|
|
remainingArgs: string[];
|
|
}
|
|
|
|
export interface RuntimeReasoningResolution {
|
|
argsWithoutReasoningFlags: string[];
|
|
reasoningOverride: string | number | undefined;
|
|
reasoningSource: 'flag' | 'env' | undefined;
|
|
sourceDisplay: string | undefined;
|
|
}
|
|
|
|
// ========== Constants ==========
|
|
|
|
export const CODEX_RUNTIME_REASONING_LEVELS = new Set([
|
|
'minimal',
|
|
'low',
|
|
'medium',
|
|
'high',
|
|
'xhigh',
|
|
]);
|
|
|
|
export const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']);
|
|
export const CODEX_NATIVE_PASSTHROUGH_SUBCOMMANDS = new Set([
|
|
'a',
|
|
'app',
|
|
'app-server',
|
|
'apply',
|
|
'cloud',
|
|
'completion',
|
|
'debug',
|
|
'e',
|
|
'exec',
|
|
'exec-server',
|
|
'features',
|
|
'fork',
|
|
'help',
|
|
'login',
|
|
'logout',
|
|
'mcp',
|
|
'mcp-server',
|
|
'plugin',
|
|
'remote-control',
|
|
'resume',
|
|
'review',
|
|
'sandbox',
|
|
]);
|
|
|
|
export const NATIVE_CLAUDE_EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
|
|
|
|
export const NATIVE_CLAUDE_EFFORT_LEVEL_SET = new Set<string>(NATIVE_CLAUDE_EFFORT_LEVELS);
|
|
|
|
// ========== Profile Detection ==========
|
|
|
|
/**
|
|
* Smart profile detection — first non-flag arg is the profile name.
|
|
*/
|
|
export function detectProfile(args: string[]): DetectedProfile {
|
|
if (args.length === 0 || args[0].startsWith('-')) {
|
|
// No args or first arg is a flag → use default profile
|
|
return { profile: 'default', remainingArgs: args };
|
|
} else {
|
|
// First arg doesn't start with '-' → treat as profile name
|
|
return { profile: args[0], remainingArgs: args.slice(1) };
|
|
}
|
|
}
|
|
|
|
export function normalizeLegacyCursorArgs(args: string[]): string[] {
|
|
if (args[0] === 'legacy' && args[1] === 'cursor') {
|
|
return [LEGACY_CURSOR_PROFILE_NAME, ...args.slice(2)];
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
export function printCursorLegacySubcommandDeprecation(subcommand: string): void {
|
|
console.error(
|
|
warn(`\`ccs cursor ${subcommand}\` is deprecated for the legacy Cursor IDE bridge.`)
|
|
);
|
|
console.error(
|
|
warn(
|
|
`Use \`ccs legacy cursor ${subcommand}\` for the old bridge, or \`ccs cursor --auth|--accounts|--config\` for the CLIProxy provider.`
|
|
)
|
|
);
|
|
console.error('');
|
|
}
|
|
|
|
// ========== Runtime Reasoning Flags ==========
|
|
|
|
export function resolveRuntimeReasoningFlags(
|
|
args: string[],
|
|
envThinkingValue: string | undefined
|
|
): RuntimeReasoningResolution {
|
|
const runtime = resolveDroidReasoningRuntime(args, envThinkingValue);
|
|
|
|
if (runtime.duplicateDisplays.length > 0) {
|
|
console.error(
|
|
warn(
|
|
`[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || '<first-flag>'}`
|
|
)
|
|
);
|
|
}
|
|
|
|
return {
|
|
argsWithoutReasoningFlags: runtime.argsWithoutReasoningFlags,
|
|
reasoningOverride: runtime.reasoningOverride,
|
|
reasoningSource: runtime.sourceFlag
|
|
? 'flag'
|
|
: runtime.reasoningOverride !== undefined
|
|
? 'env'
|
|
: undefined,
|
|
sourceDisplay: runtime.sourceDisplay,
|
|
};
|
|
}
|
|
|
|
export function normalizeCodexRuntimeReasoningOverride(
|
|
value: string | number | undefined
|
|
): string | undefined {
|
|
return typeof value === 'string' && CODEX_RUNTIME_REASONING_LEVELS.has(value) ? value : undefined;
|
|
}
|
|
|
|
export function exitWithRuntimeReasoningFlagError(
|
|
message: string,
|
|
options: {
|
|
codexAliasLevels: string;
|
|
includeDroidExecExample?: boolean;
|
|
}
|
|
): never {
|
|
console.error(fail(message));
|
|
console.error(' Examples: --thinking low, --thinking 8192, --thinking off');
|
|
console.error(` Codex alias: --effort ${options.codexAliasLevels}`);
|
|
if (options.includeDroidExecExample) {
|
|
console.error(' Droid exec: --reasoning-effort high');
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
// ========== Native Claude Effort Normalization ==========
|
|
|
|
export function normalizeNativeClaudeEffortArgs(args: string[]): string[] {
|
|
const normalizedArgs: string[] = [];
|
|
const allowedValues = NATIVE_CLAUDE_EFFORT_LEVELS.join(', ');
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
if (arg === '--effort') {
|
|
const rawValue = args[i + 1];
|
|
if (!rawValue || rawValue.startsWith('-') || !rawValue.trim()) {
|
|
throw new Error(`--effort requires a value: ${allowedValues}`);
|
|
}
|
|
const value = rawValue.toLowerCase();
|
|
if (!NATIVE_CLAUDE_EFFORT_LEVEL_SET.has(value)) {
|
|
throw new Error(`Invalid --effort value: ${rawValue}. Expected one of: ${allowedValues}.`);
|
|
}
|
|
normalizedArgs.push(arg, value);
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (arg.startsWith('--effort=')) {
|
|
const rawValue = arg.slice('--effort='.length);
|
|
if (!rawValue.trim()) {
|
|
throw new Error(`--effort requires a value: ${allowedValues}`);
|
|
}
|
|
const value = rawValue.toLowerCase();
|
|
if (!NATIVE_CLAUDE_EFFORT_LEVEL_SET.has(value)) {
|
|
throw new Error(`Invalid --effort value: ${rawValue}. Expected one of: ${allowedValues}.`);
|
|
}
|
|
normalizedArgs.push(`--effort=${value}`);
|
|
continue;
|
|
}
|
|
|
|
normalizedArgs.push(arg);
|
|
}
|
|
|
|
return normalizedArgs;
|
|
}
|
|
|
|
export function shouldNormalizeNativeClaudeEffort(
|
|
profileType: ProfileDetectionResult['type']
|
|
): boolean {
|
|
return profileType === 'default' || profileType === 'account' || profileType === 'settings';
|
|
}
|
|
|
|
// ========== Native Codex Passthrough ==========
|
|
|
|
export function shouldPassthroughNativeCodexCommand(args: string[]): boolean {
|
|
return getNativeCodexPassthroughArgs(args) !== null;
|
|
}
|
|
|
|
export function getNativeCodexPassthroughArgs(args: string[]): string[] | null {
|
|
const targetArgs = stripTargetFlag(args);
|
|
if (resolveTargetType(args) !== 'codex' || targetArgs.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const firstArg = targetArgs[0] || '';
|
|
if (CODEX_NATIVE_PASSTHROUGH_FLAGS.has(firstArg)) {
|
|
return targetArgs;
|
|
}
|
|
if (CODEX_NATIVE_PASSTHROUGH_SUBCOMMANDS.has(firstArg)) {
|
|
return targetArgs;
|
|
}
|
|
|
|
const secondArg = targetArgs[1] || '';
|
|
if (firstArg === 'codex' && CODEX_NATIVE_PASSTHROUGH_FLAGS.has(secondArg)) {
|
|
return targetArgs.slice(1);
|
|
}
|
|
if (firstArg === 'codex' && CODEX_NATIVE_PASSTHROUGH_SUBCOMMANDS.has(secondArg)) {
|
|
return targetArgs.slice(1);
|
|
}
|
|
|
|
return null;
|
|
}
|