refactor(dispatcher): extract concerns from ccs.ts (#1167)

refactor(dispatcher): extract concerns from ccs.ts (#1165)
This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-03 11:57:26 -04:00
committed by GitHub
17 changed files with 2888 additions and 1633 deletions
+31 -1633
View File
File diff suppressed because it is too large Load Diff
@@ -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);
});
});
@@ -0,0 +1,387 @@
/**
* Tests for resolveProfileAndTarget() — Phase C extraction.
*
* The function has deep dynamic-import dependencies (ProfileDetector, targets,
* browser config, etc.). Strategy: mock the heavy boundary modules and verify
* the control-flow contract and shape of the returned ResolvedProfile object.
*
* Coverage goals:
* - Default 'claude' profile resolves with null targetAdapter
* - CLIProxy profile (gemini) resolves correctly
* - Settings profile (glm) resolves with settings loaded + compatibility check
* - Unknown profile triggers ProfileError (thrown, caught by main's try/catch)
* - --target droid resolves droid adapter + binary
* - Compatibility check runs for non-settings profiles (duplication preserved)
*/
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
import type { Logger } from '../../services/logging/logger';
// ========== Stub Logger ==========
function makeStubLogger(): Logger {
return {
info: mock(() => {}),
warn: mock(() => {}),
error: mock(() => {}),
debug: mock(() => {}),
stage: mock(() => {}),
} as unknown as Logger;
}
// ========== Module mocks ==========
// We need to mock heavy dependencies at the module level before importing the SUT.
// Bun's module mock API replaces modules in the registry for the current test file.
// Mock ProfileDetector — most tests override detectProfileType per-case
const mockDetectProfileType = mock((_profile: string) => ({
type: 'default' as const,
name: 'default',
target: undefined,
}));
const mockGetAllProfiles = mock(() => ({
settings: [] as string[],
cliproxy: [],
cliproxyVariants: [],
}));
mock.module('../../auth/profile-detector', () => ({
default: class MockProfileDetector {
detectProfileType = mockDetectProfileType;
getAllProfiles = mockGetAllProfiles;
},
}));
// Mock management / registry / context modules (dynamically imported, no-op for these tests)
mock.module('../../management/instance-manager', () => ({ default: class {} }));
mock.module('../../auth/profile-registry', () => ({ default: class {} }));
mock.module('../../auth/account-context', () => ({
resolveAccountContextPolicy: mock(() => 'default'),
isAccountContextMetadata: mock(() => false),
}));
mock.module('../../auth/profile-continuity-inheritance', () => ({
resolveProfileContinuityInheritance: mock(() => null),
}));
// Mock target-resolver
const mockResolveTargetType = mock((_args: string[]) => 'claude' as const);
const mockStripTargetFlag = mock((args: string[]) => args);
mock.module('../../targets/target-resolver', () => ({
resolveTargetType: mockResolveTargetType,
stripTargetFlag: mockStripTargetFlag,
}));
// Mock targets registry
const mockGetTarget = mock((_name: string) => null);
const mockEvaluateTargetRuntimeCompatibility = mock(() => ({ supported: true }));
const mockPruneOrphanedModels = mock(async () => {});
mock.module('../../targets', () => ({
getTarget: mockGetTarget,
evaluateTargetRuntimeCompatibility: mockEvaluateTargetRuntimeCompatibility,
pruneOrphanedModels: mockPruneOrphanedModels,
}));
// Mock claude-detector
const mockDetectClaudeCli = mock(() => '/usr/local/bin/claude');
mock.module('../../utils/claude-detector', () => ({
detectClaudeCli: mockDetectClaudeCli,
}));
// Mock ErrorManager
mock.module('../../utils/error-manager', () => ({
ErrorManager: { showClaudeNotFound: mock(async () => {}) },
}));
// Mock config-manager
mock.module('../../utils/config-manager', () => ({
getSettingsPath: mock((name: string) => `/tmp/.ccs/profiles/${name}/settings.json`),
loadSettings: mock(() => ({ env: {} })),
}));
// Mock helpers
mock.module('../../utils/helpers', () => ({
expandPath: mock((p: string) => p),
}));
// Mock browser config
mock.module('../../config/unified-config-loader', () => ({
getBrowserConfig: mock(() => ({
claude: { enabled: false, policy: 'never' },
codex: { enabled: false, policy: 'never' },
})),
}));
// Mock browser utilities
mock.module('../../utils/browser', () => ({
getEffectiveClaudeBrowserAttachConfig: mock(() => undefined),
resolveBrowserExposure: mock(() => ({ enabled: false })),
getBlockedBrowserOverrideWarning: mock(() => undefined),
}));
// Mock cliproxy bridge
mock.module('../../api/services/cliproxy-profile-bridge', () => ({
resolveCliproxyBridgeMetadata: mock(() => undefined),
}));
// Mock environment-builder (only resolveCodexRuntimeConfigOverrides is called)
mock.module('../environment-builder', () => ({
resolveCodexRuntimeConfigOverrides: mock(() => [] as string[]),
resolveNativeClaudeLaunchArgs: mock((a: string[]) => a),
refreshUpdateCache: mock(() => {}),
showCachedUpdateNotification: mock(async () => {}),
}));
// Mock cli-argument-parser (detectProfile + normalization helpers)
const mockDetectProfile = mock((args: string[]) => ({
profile: args[0] || 'default',
remainingArgs: args.slice(1),
}));
mock.module('../cli-argument-parser', () => ({
detectProfile: mockDetectProfile,
resolveRuntimeReasoningFlags: mock((args: string[]) => ({
argsWithoutReasoningFlags: args,
reasoningOverride: undefined,
reasoningSource: undefined,
})),
normalizeCodexRuntimeReasoningOverride: mock(() => undefined),
exitWithRuntimeReasoningFlagError: mock(() => {}),
normalizeNativeClaudeEffortArgs: mock((a: string[]) => a),
shouldNormalizeNativeClaudeEffort: mock(() => false),
bootstrapAndParseEarlyCli: mock(async () => ({
exitNow: false,
args: [],
browserLaunchOverride: undefined,
})),
}));
// Mock droid command/reasoning modules
mock.module('../../targets/droid-command-router', () => ({
DroidCommandRouterError: class DroidCommandRouterError extends Error {},
routeDroidCommandArgs: mock((args: string[]) => ({
mode: 'interactive',
argsForDroid: args,
duplicateReasoningDisplays: [],
reasoningSourceDisplay: undefined,
autoPrependedExec: false,
})),
}));
mock.module('../../targets/droid-reasoning-runtime', () => ({
DroidReasoningFlagError: class DroidReasoningFlagError extends Error {
constructor(msg: string, _flag?: string) {
super(msg);
}
},
}));
// ========== Import SUT after mocks are registered ==========
import { resolveProfileAndTarget } from '../profile-resolver';
// ========== Tests ==========
describe('resolveProfileAndTarget', () => {
let exitSpy: ReturnType<typeof spyOn>;
let consoleErrorSpy: ReturnType<typeof spyOn>;
const stubLogger = makeStubLogger();
beforeEach(() => {
exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as () => never);
consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {});
process.env['CI'] = '1';
process.env['CCS_HOME'] = '/tmp/ccs-test-profile-resolver';
// Reset relevant mocks to defaults
mockDetectProfileType.mockImplementation((_profile: string) => ({
type: 'default' as const,
name: 'default',
target: undefined,
}));
mockResolveTargetType.mockImplementation((_args: string[]) => 'claude' as const);
mockStripTargetFlag.mockImplementation((args: string[]) => args);
mockGetTarget.mockImplementation((_name: string) => null);
mockDetectClaudeCli.mockImplementation(() => '/usr/local/bin/claude');
mockEvaluateTargetRuntimeCompatibility.mockImplementation(() => ({ supported: true }));
});
afterEach(() => {
exitSpy.mockRestore();
consoleErrorSpy.mockRestore();
delete process.env['CI'];
delete process.env['CCS_HOME'];
});
it('resolves default claude profile with null targetAdapter', async () => {
const result = await resolveProfileAndTarget({
args: [],
browserLaunchOverride: undefined,
cliLogger: stubLogger,
});
expect(result.resolvedTarget).toBe('claude');
expect(result.targetAdapter).toBeNull();
expect(result.claudeCli).toBe('/usr/local/bin/claude');
expect(result.profileInfo.type).toBe('default');
expect(result.targetBinaryInfo).toBeNull();
expect(result.targetRemainingArgs).toEqual([]);
expect(result.nativeClaudeRemainingArgs).toEqual([]);
expect(exitSpy).not.toHaveBeenCalled();
});
it('resolves gemini cliproxy profile', async () => {
mockDetectProfileType.mockImplementation((_profile: string) => ({
type: 'cliproxy' as const,
name: 'gemini',
provider: 'gemini',
target: undefined,
}));
mockDetectProfile.mockImplementation((args: string[]) => ({
profile: 'gemini',
remainingArgs: args.slice(1),
}));
const result = await resolveProfileAndTarget({
args: ['gemini'],
browserLaunchOverride: undefined,
cliLogger: stubLogger,
});
expect(result.profile).toBe('gemini');
expect(result.profileInfo.type).toBe('cliproxy');
expect(result.resolvedTarget).toBe('claude');
expect(result.targetAdapter).toBeNull();
expect(exitSpy).not.toHaveBeenCalled();
});
it('resolves settings profile (glm) with --target droid and loads settings', async () => {
// Settings loading only runs in the non-claude preflight block (resolvedTarget !== 'claude').
const mockDroidAdapter = {
displayName: 'Factory Droid',
detectBinary: mock(() => ({ path: '/usr/local/bin/droid', version: '1.0.0' })),
supportsProfileType: mock(() => true),
};
mockGetTarget.mockImplementation((_name: string) => mockDroidAdapter);
mockResolveTargetType.mockImplementation((_args: string[]) => 'droid' as const);
mockDetectProfileType.mockImplementation((_profile: string) => ({
type: 'settings' as const,
name: 'glm',
settingsPath: undefined,
target: undefined,
}));
mockDetectProfile.mockImplementation((args: string[]) => ({
profile: 'glm',
remainingArgs: args.slice(1),
}));
const result = await resolveProfileAndTarget({
args: ['glm', '--target', 'droid'],
browserLaunchOverride: undefined,
cliLogger: stubLogger,
});
expect(result.profile).toBe('glm');
expect(result.profileInfo.type).toBe('settings');
expect(result.resolvedTarget).toBe('droid');
// Settings are loaded inside the non-claude preflight block
expect(result.resolvedSettingsPath).toBeDefined();
expect(result.resolvedSettings).toBeDefined();
expect(exitSpy).not.toHaveBeenCalled();
});
it('calls process.exit(1) for unknown profile when claude CLI not found', async () => {
// Simulate claude not found for claude target
mockDetectClaudeCli.mockImplementation(() => null);
await resolveProfileAndTarget({
args: [],
browserLaunchOverride: undefined,
cliLogger: stubLogger,
});
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('resolves --target droid with droid adapter and binary', async () => {
const mockBinaryInfo = { path: '/usr/local/bin/droid', version: '1.0.0' };
const mockDroidAdapter = {
displayName: 'Factory Droid',
detectBinary: mock(() => mockBinaryInfo),
supportsProfileType: mock(() => true),
};
mockGetTarget.mockImplementation((_name: string) => mockDroidAdapter);
mockResolveTargetType.mockImplementation((_args: string[]) => 'droid' as const);
mockDetectProfileType.mockImplementation((_profile: string) => ({
type: 'settings' as const,
name: 'glm',
settingsPath: undefined,
target: undefined,
}));
mockDetectProfile.mockImplementation((args: string[]) => ({
profile: 'glm',
remainingArgs: args.slice(1),
}));
const result = await resolveProfileAndTarget({
args: ['glm', '--target', 'droid'],
browserLaunchOverride: undefined,
cliLogger: stubLogger,
});
expect(result.resolvedTarget).toBe('droid');
expect(result.targetAdapter).toBe(mockDroidAdapter);
expect(result.targetBinaryInfo).toBe(mockBinaryInfo);
expect(exitSpy).not.toHaveBeenCalled();
});
it('calls process.exit(1) when droid binary not found', async () => {
const mockDroidAdapter = {
displayName: 'Factory Droid',
detectBinary: mock(() => null),
supportsProfileType: mock(() => true),
};
mockGetTarget.mockImplementation((_name: string) => mockDroidAdapter);
mockResolveTargetType.mockImplementation((_args: string[]) => 'droid' as const);
mockDetectProfileType.mockImplementation((_profile: string) => ({
type: 'default' as const,
name: 'default',
target: undefined,
}));
await resolveProfileAndTarget({
args: ['--target', 'droid'],
browserLaunchOverride: undefined,
cliLogger: stubLogger,
});
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('runs compatibility check for non-settings profiles (duplication preserved)', async () => {
const mockDroidAdapter = {
displayName: 'Factory Droid',
detectBinary: mock(() => ({ path: '/usr/local/bin/droid', version: '1.0.0' })),
supportsProfileType: mock(() => true),
};
mockGetTarget.mockImplementation((_name: string) => mockDroidAdapter);
mockResolveTargetType.mockImplementation((_args: string[]) => 'droid' as const);
mockDetectProfileType.mockImplementation((_profile: string) => ({
type: 'cliproxy' as const,
name: 'gemini',
provider: 'gemini',
target: undefined,
}));
mockDetectProfile.mockImplementation((args: string[]) => ({
profile: 'gemini',
remainingArgs: args.slice(1),
}));
await resolveProfileAndTarget({
args: ['gemini', '--target', 'droid'],
browserLaunchOverride: undefined,
cliLogger: stubLogger,
});
// Compatibility check must have been called (the duplication from Phase C)
expect(mockEvaluateTargetRuntimeCompatibility).toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
});
});
+338
View File
@@ -0,0 +1,338 @@
/**
* CLI argument parsing and normalization utilities.
*
* Extracted from src/ccs.ts (lines 129-244, 246-296, 371-392).
* Pure functions — no side effects except console.error and process.exit.
*
* Also contains bootstrapAndParseEarlyCli() — the Phase A bootstrap extracted
* from main() (lines 128-232 of the original). Handles: adapter registration,
* UI init, --config-dir flag, cloud-sync warnings, completion short-circuit,
* normalizeLegacyCursorArgs, resolveBrowserLaunchFlagResolution, codex passthrough.
*/
import * as fs from 'fs';
import { fail, warn, info } from '../utils/ui';
import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants';
import { resolveTargetType, stripTargetFlag } from '../targets/target-resolver';
import { resolveDroidReasoningRuntime } from '../targets/droid-reasoning-runtime';
import type { ProfileDetectionResult } from '../auth/profile-detector';
import { setGlobalConfigDir, detectCloudSyncPath } from '../utils/config-manager';
import { resolveBrowserLaunchFlagResolution } from '../utils/browser';
import type { BrowserLaunchOverride } from '../utils/browser';
// ========== Bootstrap Result ==========
/**
* Result of the early CLI bootstrap pass.
* All fields are consumed by main() to decide how to continue.
*/
export interface DispatcherBootstrap {
/** Normalized/mutated args after pre-parse (--config-dir stripped, browser flags stripped, legacy cursor normalized) */
args: string[];
isCompletionCommand: boolean;
browserLaunchOverride: BrowserLaunchOverride | undefined;
/** true when the caller should return immediately (completion handled, codex passthrough triggered, process.exit called) */
exitNow: boolean;
}
/**
* Phase A bootstrap: runs everything that must happen before config loading and profile detection.
*
* Side-effects preserved from original main():
* - Dynamic import of initUI
* - setGlobalConfigDir (--config-dir flag)
* - Cloud-sync warnings
* - Completion short-circuit via tryHandleRootCommand
* - Codex native passthrough via execNativeCodexFlagCommand
*
* Adapter registration (registerTarget calls) stays in main() because it is
* singleton wiring with no dependency on the parsed args.
*/
export async function bootstrapAndParseEarlyCli(rawArgs: string[]): Promise<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 ==========
export interface DetectedProfile {
profile: string;
remainingArgs: string[];
}
export interface RuntimeReasoningResolution {
argsWithoutReasoningFlags: string[];
reasoningOverride: string | number | undefined;
reasoningSource: 'flag' | 'env' | undefined;
sourceDisplay: string | undefined;
}
// ========== Constants ==========
export const CODEX_RUNTIME_REASONING_LEVELS = new Set([
'minimal',
'low',
'medium',
'high',
'xhigh',
]);
export const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']);
export const NATIVE_CLAUDE_EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
export const NATIVE_CLAUDE_EFFORT_LEVEL_SET = new Set<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 shouldPassthroughNativeCodexFlagCommand(args: string[]): boolean {
return getNativeCodexPassthroughArgs(args) !== null;
}
export function getNativeCodexPassthroughArgs(args: string[]): string[] | null {
const targetArgs = stripTargetFlag(args);
if (resolveTargetType(args) !== 'codex' || targetArgs.length === 0) {
return null;
}
const firstArg = targetArgs[0] || '';
if (CODEX_NATIVE_PASSTHROUGH_FLAGS.has(firstArg)) {
return targetArgs;
}
const secondArg = targetArgs[1] || '';
if (firstArg === 'codex' && CODEX_NATIVE_PASSTHROUGH_FLAGS.has(secondArg)) {
return targetArgs.slice(1);
}
return null;
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Shared context type for Phase E profile dispatch flows.
*
* ProfileDispatchContext extends ResolvedProfile with the dynamic imports
* needed by all flows (InstanceManager, ProfileRegistry, AccountContext,
* ProfileContinuity), and the browser-exposure fields already computed by
* Phase C (profile-resolver.ts).
*/
import type { ResolvedProfile } from './profile-resolver';
import type { loadSettings } from '../config/config-loader-facade';
import type { getEffectiveClaudeBrowserAttachConfig } from '../utils/browser';
import type { resolveCliproxyBridgeMetadata } from '../api/services/cliproxy-profile-bridge';
import type { resolveTargetType } from '../targets/target-resolver';
// Re-export ResolvedProfile fields as ProfileDispatchContext —
// all browser-exposure fields are already present in ResolvedProfile.
export type ProfileDispatchContext = ResolvedProfile & {
/** Dynamic-imported modules needed by account/settings flows */
InstanceManager: Awaited<typeof import('../management/instance-manager')>['default'];
ProfileRegistry: Awaited<typeof import('../auth/profile-registry')>['default'];
resolveAccountContextPolicy: Awaited<
typeof import('../auth/account-context')
>['resolveAccountContextPolicy'];
isAccountContextMetadata: Awaited<
typeof import('../auth/account-context')
>['isAccountContextMetadata'];
resolveProfileContinuityInheritance: Awaited<
typeof import('../auth/profile-continuity-inheritance')
>['resolveProfileContinuityInheritance'];
};
// Re-export for convenience
export type { ResolvedProfile };
export type {
loadSettings,
getEffectiveClaudeBrowserAttachConfig,
resolveCliproxyBridgeMetadata,
resolveTargetType,
};
+125
View File
@@ -0,0 +1,125 @@
/**
* Runtime environment and launch-args builders.
*
* Extracted from src/ccs.ts (lines 146-163, 300-327, 329-369).
* Covers update-cache helpers and launch-arg resolution for native Claude/Codex targets.
*/
import { warn } from '../utils/ui';
import { resolveBrowserExposure } from '../utils/browser';
import { getBrowserConfig, getOfficialChannelsConfig } from '../config/config-loader-facade';
import {
buildOfficialChannelsArgs,
getOfficialChannelsEnvironmentStatus,
officialChannelRequiresMacOS,
resolveOfficialChannelsLaunchPlan,
} from '../channels/official-channels-runtime';
import { getOfficialChannelReadiness } from '../channels/official-channels-store';
import { buildCodexBrowserMcpOverrides } from '../utils/browser-codex-overrides';
import { getVersion } from '../utils/version';
import {
checkForUpdates,
showUpdateNotification,
checkCachedUpdate,
} from '../utils/update-checker';
import { resolveTargetType } from '../targets/target-resolver';
import type { BrowserLaunchOverride } from '../utils/browser';
// ========== Codex Runtime Config ==========
export function resolveCodexRuntimeConfigOverrides(
target: ReturnType<typeof resolveTargetType>,
browserLaunchOverride: BrowserLaunchOverride | undefined
): string[] {
if (target !== 'codex') {
return [];
}
const codexBrowserExposure = resolveBrowserExposure(
getBrowserConfig().codex,
browserLaunchOverride
);
if (!codexBrowserExposure.exposeForLaunch) {
return [];
}
return buildCodexBrowserMcpOverrides();
}
// ========== Update Cache Helpers ==========
/**
* Perform background update check (refreshes cache, no notification).
*/
export async function refreshUpdateCache(): Promise<void> {
try {
const currentVersion = getVersion();
// npm is now the only supported installation method
await checkForUpdates(currentVersion, true, 'npm');
} catch (_e) {
// Silently fail - update check shouldn't crash main CLI
}
}
/**
* Show update notification if cached result indicates update available.
* Returns true if notification was shown.
*/
export async function showCachedUpdateNotification(): Promise<boolean> {
try {
const currentVersion = getVersion();
const updateInfo = checkCachedUpdate(currentVersion);
if (updateInfo) {
await showUpdateNotification(updateInfo);
return true;
}
} catch (_e) {
// Silently fail
}
return false;
}
// ========== Native Claude Launch Args ==========
export function resolveNativeClaudeLaunchArgs(
args: string[],
profileType: 'default' | 'account',
targetConfigDir?: string
): string[] {
const config = getOfficialChannelsConfig();
const environment = getOfficialChannelsEnvironmentStatus(
targetConfigDir ? { CLAUDE_CONFIG_DIR: targetConfigDir } : undefined
);
const channelReadiness = {
telegram: getOfficialChannelReadiness('telegram'),
discord: getOfficialChannelReadiness('discord'),
imessage: !officialChannelRequiresMacOS('imessage') || process.platform === 'darwin',
};
const plan = resolveOfficialChannelsLaunchPlan({
args,
config,
target: 'claude',
profileType,
environment,
channelReadiness,
});
for (const message of plan.skippedMessages) {
console.error(warn(message));
}
if (
config.selected.length > 0 &&
environment.auth.state === 'eligible' &&
environment.auth.orgRequirementMessage
) {
console.error(warn(environment.auth.orgRequirementMessage));
}
if (!plan.applied) {
return args;
}
return buildOfficialChannelsArgs(args, plan.appliedChannels, plan.wantsPermissionBypass);
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Account dispatch flow — account-based profile (work, personal) with instance isolation.
*
* Extracted from src/ccs.ts main() profileInfo.type === 'account' branch.
* Uses CLAUDE_CONFIG_DIR for per-profile instance isolation on all platforms.
*/
import { execClaude } from '../../utils/shell-executor';
import { maybeWarnAboutResumeLaneMismatch } from '../../auth/resume-lane-warning';
import { resolveNativeClaudeLaunchArgs } from '../environment-builder';
import type { ProfileDispatchContext } from '../dispatcher-context';
export async function runAccountFlow(ctx: ProfileDispatchContext): Promise<void> {
const {
profileInfo,
claudeCli,
nativeClaudeRemainingArgs,
InstanceManager,
ProfileRegistry,
resolveAccountContextPolicy,
isAccountContextMetadata,
} = ctx;
const registry = new ProfileRegistry();
const instanceMgr = new InstanceManager();
const accountMetadata = isAccountContextMetadata(profileInfo.profile)
? profileInfo.profile
: undefined;
const isBareProfile =
typeof profileInfo.profile === 'object' &&
profileInfo.profile !== null &&
(profileInfo.profile as { bare?: unknown }).bare === true;
const contextPolicy = resolveAccountContextPolicy(accountMetadata);
// Ensure instance exists (lazy init if needed)
const instancePath = await instanceMgr.ensureInstance(profileInfo.name, contextPolicy, {
bare: isBareProfile,
});
// Update last_used timestamp (check unified config first, fallback to legacy)
if (registry.hasAccountUnified(profileInfo.name)) {
registry.touchAccountUnified(profileInfo.name);
} else {
registry.touchProfile(profileInfo.name);
}
// Execute Claude with instance isolation.
// Skip WebSearch hook — account profiles use native server-side WebSearch.
// Skip Image Analyzer hook — account profiles have native vision support.
const envVars: NodeJS.ProcessEnv = {
CLAUDE_CONFIG_DIR: instancePath,
CCS_PROFILE_TYPE: 'account',
CCS_WEBSEARCH_SKIP: '1',
CCS_IMAGE_ANALYSIS_SKIP: '1',
};
await maybeWarnAboutResumeLaneMismatch(profileInfo.name, instancePath, nativeClaudeRemainingArgs);
const launchArgs = resolveNativeClaudeLaunchArgs(
nativeClaudeRemainingArgs,
'account',
instancePath
);
execClaude(claudeCli, launchArgs, envVars);
}
+211
View File
@@ -0,0 +1,211 @@
/**
* CLIProxy dispatch flow — OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants.
*
* Extracted from src/ccs.ts main() profileInfo.type === 'cliproxy' branch.
*/
import { expandPath } from '../../utils/helpers';
import { fail, info } from '../../utils/ui';
import {
execClaudeWithCLIProxy,
type CLIProxyProvider,
ensureCliproxyService,
isAuthenticated,
} from '../../cliproxy';
import { getEffectiveEnvVars, getCompositeEnvVars } from '../../cliproxy/config/env-builder';
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
import {
ensureProfileHooks as ensureImageAnalyzerHooks,
removeImageAnalysisProfileHook,
} from '../../utils/hooks/image-analyzer-profile-hook-injector';
import { prepareImageAnalysisFallbackHook } from '../../utils/hooks';
import { resolveDroidProvider, type TargetCredentials } from '../../targets';
import type { ProfileDispatchContext } from '../dispatcher-context';
export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise<void> {
const {
profileInfo,
resolvedTarget,
claudeCli,
targetAdapter,
targetRemainingArgs,
runtimeReasoningOverride,
codexRuntimeConfigOverrides,
remainingArgs,
} = ctx;
const imageAnalysisMcpReady =
resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true;
if (resolvedTarget === 'claude') {
ensureWebSearchMcpOrThrow();
}
const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider);
const expandedCliproxySettingsPath = profileInfo.settingsPath
? expandPath(profileInfo.settingsPath)
: undefined;
if (resolvedTarget === 'claude') {
if (imageAnalysisMcpReady) {
removeImageAnalysisProfileHook(profileInfo.name, expandedCliproxySettingsPath);
} else {
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
ensureImageAnalyzerHooks({
profileName: profileInfo.name,
profileType: profileInfo.type,
cliproxyProvider: provider,
isComposite: profileInfo.isComposite,
settingsPath: expandedCliproxySettingsPath,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
}
}
const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles
const variantPort = profileInfo.port; // variant-specific port for isolation
const cliproxyPort = variantPort || CLIPROXY_DEFAULT_PORT;
if (resolvedTarget !== 'claude') {
const adapter = targetAdapter;
if (!adapter) {
console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
process.exitCode = 1;
return;
}
if (!adapter.supportsProfileType('cliproxy')) {
console.error(fail(`${adapter.displayName} does not support CLIProxy profiles`));
process.exitCode = 1;
return;
}
// Keep CLIProxy management/auth flags on Claude flow only.
const unsupportedCliproxyFlags = [
'--auth',
'--logout',
'--accounts',
'--add',
'--use',
'--config',
'--headless',
'--paste-callback',
'--port-forward',
'--nickname',
'--kiro-auth-method',
'--kiro-idc-start-url',
'--kiro-idc-region',
'--kiro-idc-flow',
'--backend',
'--proxy-host',
'--proxy-port',
'--proxy-protocol',
'--proxy-auth-token',
'--proxy-timeout',
'--local-proxy',
'--remote-only',
'--no-fallback',
'--allow-self-signed',
'--1m',
'--no-1m',
];
const providedUnsupportedFlag = unsupportedCliproxyFlags.find(
(flag) =>
targetRemainingArgs.includes(flag) ||
targetRemainingArgs.some((arg) => arg.startsWith(`${flag}=`))
);
if (providedUnsupportedFlag) {
console.error(
fail(
`${providedUnsupportedFlag} is only supported when running CLIProxy profiles on Claude target`
)
);
console.error(info(`Run with Claude target: ccs ${profileInfo.name} --target claude ...`));
process.exitCode = 1;
return;
}
// For Droid execution path, require existing OAuth auth and running local proxy.
if (profileInfo.isComposite && profileInfo.compositeTiers) {
const compositeProviders = [
...new Set(Object.values(profileInfo.compositeTiers).map((tier) => tier.provider)),
] as CLIProxyProvider[];
const missingProvider = compositeProviders.find((p) => !isAuthenticated(p));
if (missingProvider) {
console.error(fail(`Missing OAuth auth for composite tier provider: ${missingProvider}`));
console.error(info(`Authenticate first: ccs ${missingProvider} --auth`));
process.exitCode = 1;
return;
}
} else if (!isAuthenticated(provider)) {
console.error(fail(`No OAuth authentication found for provider: ${provider}`));
console.error(info(`Authenticate first: ccs ${provider} --auth`));
process.exitCode = 1;
return;
}
const ensureServiceResult = await ensureCliproxyService(
cliproxyPort,
targetRemainingArgs.includes('--verbose') || targetRemainingArgs.includes('-v')
);
if (!ensureServiceResult.started) {
console.error(fail(ensureServiceResult.error || 'Failed to start local CLIProxy service'));
process.exitCode = 1;
return;
}
const envVars =
profileInfo.isComposite && profileInfo.compositeTiers && profileInfo.compositeDefaultTier
? getCompositeEnvVars(
profileInfo.compositeTiers,
profileInfo.compositeDefaultTier,
cliproxyPort,
customSettingsPath
)
: getEffectiveEnvVars(provider, cliproxyPort, customSettingsPath);
const creds: TargetCredentials = {
profile: profileInfo.name,
baseUrl: envVars['ANTHROPIC_BASE_URL'] || '',
apiKey: envVars['ANTHROPIC_AUTH_TOKEN'] || '',
model: envVars['ANTHROPIC_MODEL'] || undefined,
provider: resolveDroidProvider({
provider: envVars['CCS_DROID_PROVIDER'] || envVars['DROID_PROVIDER'],
baseUrl: envVars['ANTHROPIC_BASE_URL'],
model: envVars['ANTHROPIC_MODEL'],
}),
reasoningOverride: runtimeReasoningOverride,
runtimeConfigOverrides: codexRuntimeConfigOverrides,
envVars,
};
if (!creds.baseUrl || !creds.apiKey) {
console.error(
fail(
`Missing CLIProxy runtime credentials for ${profileInfo.name} (ANTHROPIC_BASE_URL/AUTH_TOKEN)`
)
);
console.error(
info('Reconfigure with: ccs config > CLIProxy, or run ccs <provider> --config')
);
process.exitCode = 1;
return;
}
await adapter.prepareCredentials(creds);
const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs, {
creds,
profileType: profileInfo.type,
binaryInfo: ctx.targetBinaryInfo || undefined,
});
const targetEnv = adapter.buildEnv(creds, profileInfo.type);
adapter.exec(targetArgs, targetEnv, { binaryInfo: ctx.targetBinaryInfo || undefined });
return;
}
await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, {
customSettingsPath,
port: cliproxyPort,
isComposite: profileInfo.isComposite,
compositeTiers: profileInfo.compositeTiers,
compositeDefaultTier: profileInfo.compositeDefaultTier,
profileName: profileInfo.name,
});
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Copilot dispatch flow — GitHub Copilot subscription via copilot-api proxy.
*
* Extracted from src/ccs.ts main() profileInfo.type === 'copilot' branch.
*/
import { fail, info } from '../../utils/ui';
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
import {
ensureProfileHooks as ensureImageAnalyzerHooks,
removeImageAnalysisProfileHook,
} from '../../utils/hooks/image-analyzer-profile-hook-injector';
import { prepareImageAnalysisFallbackHook } from '../../utils/hooks';
import type { ProfileDispatchContext } from '../dispatcher-context';
export async function runCopilotFlow(ctx: ProfileDispatchContext): Promise<void> {
const {
profileInfo,
resolvedTarget,
claudeCli,
remainingArgs,
resolveProfileContinuityInheritance,
} = ctx;
ensureWebSearchMcpOrThrow();
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
if (resolvedTarget === 'claude') {
if (imageAnalysisMcpReady) {
removeImageAnalysisProfileHook(profileInfo.name);
} else {
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
ensureImageAnalyzerHooks({
profileName: profileInfo.name,
profileType: profileInfo.type,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
}
}
const { executeCopilotProfile } = await import('../../copilot');
const copilotConfig = profileInfo.copilotConfig;
if (!copilotConfig) {
console.error(fail('Copilot configuration not found'));
process.exit(1);
}
const continuityInheritance = await resolveProfileContinuityInheritance({
profileName: profileInfo.name,
profileType: profileInfo.type,
target: resolvedTarget,
});
if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
console.error(
info(
`Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"`
)
);
}
const exitCode = await executeCopilotProfile(
copilotConfig,
remainingArgs,
continuityInheritance.claudeConfigDir,
claudeCli
);
process.exit(exitCode);
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Cursor dispatch flow — local Cursor daemon profile.
*
* Extracted from src/ccs.ts main() profileInfo.type === 'cursor' branch.
*/
import { fail, info } from '../../utils/ui';
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector';
import { installImageAnalyzerHook } from '../../utils/hooks';
import type { ProfileDispatchContext } from '../dispatcher-context';
export async function runCursorFlow(ctx: ProfileDispatchContext): Promise<void> {
const {
profileInfo,
resolvedTarget,
claudeCli,
remainingArgs,
resolveProfileContinuityInheritance,
} = ctx;
ensureWebSearchMcpOrThrow();
installImageAnalyzerHook();
ensureImageAnalyzerHooks({
profileName: profileInfo.name,
profileType: profileInfo.type,
});
const { executeCursorProfile } = await import('../../cursor');
const cursorConfig = profileInfo.cursorConfig;
if (!cursorConfig) {
console.error(fail('Cursor configuration not found'));
process.exit(1);
}
const continuityInheritance = await resolveProfileContinuityInheritance({
profileName: profileInfo.name,
profileType: profileInfo.type,
target: resolvedTarget,
});
if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
console.error(
info(
`Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"`
)
);
}
const exitCode = await executeCursorProfile(
cursorConfig,
remainingArgs,
continuityInheritance.claudeConfigDir,
claudeCli
);
process.exit(exitCode);
}
+136
View File
@@ -0,0 +1,136 @@
/**
* Default dispatch flow — no profile configured, use Claude's own defaults.
*
* Extracted from src/ccs.ts main() else branch (profileInfo.type === 'default').
* Skip WebSearch hook — native Claude has server-side WebSearch.
* Skip Image Analyzer hook — native Claude has native vision support.
*/
import { fail, info, warn } from '../../utils/ui';
import {
appendBrowserToolArgs,
ensureBrowserMcpOrThrow,
resolveOptionalBrowserAttachRuntime,
syncBrowserMcpToConfigDir,
} from '../../utils/browser';
import { execClaude } from '../../utils/shell-executor';
import { resolveDroidProvider, type TargetCredentials } from '../../targets';
import { resolveNativeClaudeLaunchArgs } from '../environment-builder';
import type { ProfileDispatchContext } from '../dispatcher-context';
export async function runDefaultFlow(ctx: ProfileDispatchContext): Promise<void> {
const {
profileInfo,
resolvedTarget,
claudeCli,
targetAdapter,
targetBinaryInfo,
targetRemainingArgs,
nativeClaudeRemainingArgs,
runtimeReasoningOverride,
codexRuntimeConfigOverrides,
claudeBrowserExposure,
claudeAttachConfig,
resolveProfileContinuityInheritance,
} = ctx;
const envVars: NodeJS.ProcessEnv = {
CCS_PROFILE_TYPE: 'default',
CCS_WEBSEARCH_SKIP: '1',
CCS_IMAGE_ANALYSIS_SKIP: '1',
};
const browserAttachRuntime =
resolvedTarget === 'claude' &&
claudeBrowserExposure?.exposeForLaunch &&
claudeAttachConfig?.enabled
? await resolveOptionalBrowserAttachRuntime(claudeAttachConfig)
: undefined;
const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv;
if (browserAttachRuntime?.warning) {
process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`);
}
if (resolvedTarget === 'claude') {
if (browserRuntimeEnv) {
ensureBrowserMcpOrThrow();
Object.assign(envVars, browserRuntimeEnv);
}
const defaultContinuityInheritance = await resolveProfileContinuityInheritance({
profileName: profileInfo.name,
profileType: profileInfo.type,
target: resolvedTarget,
});
if (defaultContinuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
console.error(
info(
`Continuity inheritance active: profile "${profileInfo.name}" -> account "${defaultContinuityInheritance.sourceAccount}"`
)
);
}
if (defaultContinuityInheritance.claudeConfigDir) {
envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir;
if (
browserRuntimeEnv &&
!syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir)
) {
throw new Error(
'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.'
);
}
}
}
// Dispatch through target adapter for non-claude targets
if (resolvedTarget !== 'claude') {
const adapter = targetAdapter;
if (!adapter) {
console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
process.exit(1);
}
if (!adapter.supportsProfileType('default')) {
console.error(fail(`${adapter.displayName} does not support default profile mode`));
process.exit(1);
}
const creds: TargetCredentials = {
profile: 'default',
baseUrl: process.env['ANTHROPIC_BASE_URL'] || '',
apiKey: process.env['ANTHROPIC_AUTH_TOKEN'] || '',
model: process.env['ANTHROPIC_MODEL'],
provider: resolveDroidProvider({
provider: process.env['CCS_DROID_PROVIDER'] || process.env['DROID_PROVIDER'],
baseUrl: process.env['ANTHROPIC_BASE_URL'],
model: process.env['ANTHROPIC_MODEL'],
}),
reasoningOverride: runtimeReasoningOverride,
runtimeConfigOverrides: codexRuntimeConfigOverrides,
browserRuntimeEnv,
};
if (resolvedTarget === 'droid' && (!creds.baseUrl || !creds.apiKey)) {
console.error(
fail(
`${adapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN`
)
);
console.error(info('Use a settings-based profile instead: ccs glm --target droid'));
process.exit(1);
}
await adapter.prepareCredentials(creds);
const targetArgs = adapter.buildArgs('default', targetRemainingArgs, {
creds,
profileType: 'default',
binaryInfo: targetBinaryInfo || undefined,
});
const targetEnv = adapter.buildEnv(creds, 'default');
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
return;
}
const launchArgs = resolveNativeClaudeLaunchArgs(
browserRuntimeEnv
? appendBrowserToolArgs(nativeClaudeRemainingArgs)
: nativeClaudeRemainingArgs,
'default',
envVars.CLAUDE_CONFIG_DIR
);
execClaude(claudeCli, launchArgs, envVars);
}
+401
View File
@@ -0,0 +1,401 @@
/**
* Settings dispatch flow — settings-based profiles (glm, glmt, kimi, etc.).
*
* Extracted from src/ccs.ts main() profileInfo.type === 'settings' branch.
* Image-analysis prep is large enough to split; see settings-image-analysis-prep.ts.
*/
import { getSettingsPath } from '../../utils/config-manager';
import { loadSettings } from '../../config/config-loader-facade';
import { expandPath } from '../../utils/helpers';
import {
validateGlmKey,
validateMiniMaxKey,
validateAnthropicKey,
} from '../../utils/api-key-validator';
import {
ensureWebSearchMcpOrThrow,
displayWebSearchStatus,
getWebSearchHookEnv,
syncWebSearchMcpToConfigDir,
appendThirdPartyWebSearchToolArgs,
createWebSearchTraceContext,
} from '../../utils/websearch-manager';
import {
ensureImageAnalysisMcpOrThrow,
syncImageAnalysisMcpToConfigDir,
appendThirdPartyImageAnalysisToolArgs,
} from '../../utils/image-analysis';
import {
appendBrowserToolArgs,
ensureBrowserMcpOrThrow,
resolveOptionalBrowserAttachRuntime,
syncBrowserMcpToConfigDir,
} from '../../utils/browser';
import { getGlobalEnvConfig } from '../../config/config-loader-facade';
import {
ensureProfileHooks as ensureImageAnalyzerHooks,
removeImageAnalysisProfileHook,
} from '../../utils/hooks/image-analyzer-profile-hook-injector';
import { fail, info, warn } from '../../utils/ui';
import { execClaude, stripAnthropicRoutingEnv, stripBrowserEnv } from '../../utils/shell-executor';
import {
isDeprecatedGlmtProfileName,
normalizeDeprecatedGlmtEnv,
} from '../../utils/glmt-deprecation';
import { createOpenAICompatLaunchSettings } from '../../utils/openai-compat-launch-settings';
import {
resolveDroidProvider,
evaluateTargetRuntimeCompatibility,
type TargetCredentials,
} from '../../targets';
import { resolveCliproxyBridgeMetadata } from '../../api/services/cliproxy-profile-bridge';
import {
buildOpenAICompatProxyEnv,
resolveOpenAICompatProfileConfig,
startOpenAICompatProxy,
} from '../../proxy';
import { resolveSettingsImageAnalysisEnv } from './settings-image-analysis-prep';
import type { ProfileDispatchContext } from '../dispatcher-context';
export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void> {
const {
profileInfo,
resolvedTarget,
claudeCli,
targetAdapter,
targetBinaryInfo,
targetRemainingArgs,
nativeClaudeRemainingArgs,
runtimeReasoningOverride,
codexRuntimeConfigOverrides,
claudeBrowserExposure,
claudeAttachConfig,
resolvedSettingsPath: preResolvedSettingsPath,
resolvedSettings: preResolvedSettings,
resolvedCliproxyBridge: preResolvedCliproxyBridge,
resolveProfileContinuityInheritance,
remainingArgs,
} = ctx;
const imageAnalysisMcpReady =
resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true;
const browserAttachRuntime =
resolvedTarget === 'claude' &&
claudeBrowserExposure?.exposeForLaunch &&
claudeAttachConfig?.enabled
? await resolveOptionalBrowserAttachRuntime(claudeAttachConfig)
: undefined;
const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv;
if (browserAttachRuntime?.warning) {
process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`);
}
if (resolvedTarget === 'claude') {
ensureWebSearchMcpOrThrow();
if (browserRuntimeEnv) {
ensureBrowserMcpOrThrow();
}
}
// Display WebSearch status (single line, equilibrium UX)
displayWebSearchStatus();
const continuityInheritance =
resolvedTarget === 'claude'
? await resolveProfileContinuityInheritance({
profileName: profileInfo.name,
profileType: profileInfo.type,
target: resolvedTarget,
})
: {};
if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
console.error(
info(
`Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"`
)
);
}
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
if (
browserRuntimeEnv &&
inheritedClaudeConfigDir &&
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
) {
throw new Error(
'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.'
);
}
const expandedSettingsPath =
preResolvedSettingsPath ??
(profileInfo.settingsPath
? expandPath(profileInfo.settingsPath)
: getSettingsPath(profileInfo.name));
const settings = preResolvedSettings ?? loadSettings(expandedSettingsPath);
const cliproxyBridge = preResolvedCliproxyBridge ?? resolveCliproxyBridgeMetadata(settings);
let imageAnalysisFallbackHookReady: boolean | undefined;
if (resolvedTarget === 'claude') {
if (imageAnalysisMcpReady) {
removeImageAnalysisProfileHook(profileInfo.name, expandedSettingsPath);
} else {
imageAnalysisFallbackHookReady = (
await import('../../utils/hooks')
).prepareImageAnalysisFallbackHook();
ensureImageAnalyzerHooks({
profileName: profileInfo.name,
profileType: profileInfo.type,
settingsPath: expandedSettingsPath,
settings,
cliproxyBridge,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
}
}
if (resolvedTarget !== 'claude') {
const compatibility = evaluateTargetRuntimeCompatibility({
target: resolvedTarget,
profileType: profileInfo.type,
cliproxyBridgeProvider: cliproxyBridge?.provider ?? null,
});
if (!compatibility.supported) {
console.error(
fail(
compatibility.reason ||
`${targetAdapter?.displayName || resolvedTarget} does not support this profile.`
)
);
if (compatibility.suggestion) {
console.error(info(compatibility.suggestion));
}
process.exit(1);
}
}
const rawSettingsEnv = profileInfo.env ?? settings.env ?? {};
const isDeprecatedGlmtProfile = isDeprecatedGlmtProfileName(profileInfo.name);
const glmtNormalization = isDeprecatedGlmtProfile
? normalizeDeprecatedGlmtEnv(rawSettingsEnv)
: null;
const settingsEnv = glmtNormalization?.env ?? rawSettingsEnv;
if (glmtNormalization) {
for (const message of glmtNormalization.warnings) {
console.error(warn(message));
}
}
// Pre-flight validation for Z.AI-compatible profiles.
if (profileInfo.name === 'glm' || isDeprecatedGlmtProfile) {
const apiKey = settingsEnv['ANTHROPIC_AUTH_TOKEN'];
if (apiKey) {
const validation = await validateGlmKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']);
if (!validation.valid) {
console.error('');
console.error(fail(validation.error || 'API key validation failed'));
if (validation.suggestion) {
console.error('');
console.error(validation.suggestion);
}
console.error('');
console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs glm "prompt"'));
process.exit(1);
}
}
}
if (profileInfo.name === 'mm') {
const apiKey = settingsEnv['ANTHROPIC_AUTH_TOKEN'];
if (apiKey) {
const validation = await validateMiniMaxKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']);
if (!validation.valid) {
console.error('');
console.error(fail(validation.error || 'API key validation failed'));
if (validation.suggestion) {
console.error('');
console.error(validation.suggestion);
}
console.error('');
console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs mm "prompt"'));
process.exit(1);
}
}
}
// Pre-flight validation for Anthropic direct profiles (ANTHROPIC_API_KEY + no BASE_URL)
{
const anthropicApiKey = settingsEnv['ANTHROPIC_API_KEY'];
const hasBaseUrl = !!settingsEnv['ANTHROPIC_BASE_URL'];
if (anthropicApiKey && !hasBaseUrl) {
const validation = await validateAnthropicKey(anthropicApiKey);
if (!validation.valid) {
console.error('');
console.error(fail(validation.error || 'API key validation failed'));
if (validation.suggestion) {
console.error('');
console.error(validation.suggestion);
}
console.error('');
console.error(
info(`To skip validation: CCS_SKIP_PREFLIGHT=1 ccs ${profileInfo.name} "prompt"`)
);
process.exit(1);
}
}
}
// Image analysis env resolution (split into sibling helper to stay under LOC budget)
const imageAnalysisEnv = await resolveSettingsImageAnalysisEnv({
profileInfo,
resolvedTarget,
settings,
cliproxyBridge,
imageAnalysisMcpReady,
imageAnalysisFallbackHookReady,
remainingArgs,
targetRemainingArgs,
});
const webSearchEnv = getWebSearchHookEnv();
// Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
const globalEnvConfig = getGlobalEnvConfig();
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
// Log global env injection for visibility (debug mode only)
if (globalEnvConfig.enabled && Object.keys(globalEnv).length > 0 && process.env.CCS_DEBUG) {
const envNames = Object.keys(globalEnv).join(', ');
console.error(info(`Global env: ${envNames}`));
}
// For Claude target launches that already pass `--settings`, keep runtime env free of
// ANTHROPIC routing/auth while preserving non-routing profile env so nested Team/subagent
// sessions can still inherit model intent and other profile-scoped runtime flags.
const settingsRuntimeEnv = stripBrowserEnv({ ...globalEnv, ...settingsEnv });
const claudeRuntimeEnvVars: NodeJS.ProcessEnv = {
...stripAnthropicRoutingEnv(settingsRuntimeEnv),
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
...webSearchEnv,
...imageAnalysisEnv,
...(browserRuntimeEnv || {}),
CCS_PROFILE_TYPE: 'settings',
CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1',
};
// Non-Claude targets still need effective credentials injected directly.
const envVars: NodeJS.ProcessEnv = {
...settingsRuntimeEnv,
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
...webSearchEnv,
...imageAnalysisEnv,
...(browserRuntimeEnv || {}),
CCS_PROFILE_TYPE: 'settings',
};
// Dispatch through target adapter for non-claude targets
if (resolvedTarget !== 'claude') {
const adapter = targetAdapter;
if (!adapter) {
console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
process.exit(1);
}
const directAnthropicBaseUrl =
settingsEnv['ANTHROPIC_BASE_URL'] ||
(settingsEnv['ANTHROPIC_API_KEY'] ? 'https://api.anthropic.com' : '');
const creds: TargetCredentials = {
profile: profileInfo.name,
baseUrl: directAnthropicBaseUrl,
apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || settingsEnv['ANTHROPIC_API_KEY'] || '',
model: settingsEnv['ANTHROPIC_MODEL'],
provider: resolveDroidProvider({
provider: settingsEnv['CCS_DROID_PROVIDER'] || settingsEnv['DROID_PROVIDER'],
baseUrl: directAnthropicBaseUrl,
model: settingsEnv['ANTHROPIC_MODEL'],
}),
reasoningOverride: runtimeReasoningOverride,
runtimeConfigOverrides: codexRuntimeConfigOverrides,
envVars,
};
await adapter.prepareCredentials(creds);
const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs, {
creds,
profileType: profileInfo.type,
binaryInfo: targetBinaryInfo || undefined,
});
const targetEnv = adapter.buildEnv(creds, profileInfo.type);
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
return;
}
const imageAnalysisArgs = imageAnalysisMcpReady
? appendThirdPartyImageAnalysisToolArgs(nativeClaudeRemainingArgs)
: nativeClaudeRemainingArgs;
const browserArgs = browserRuntimeEnv
? appendBrowserToolArgs(imageAnalysisArgs)
: imageAnalysisArgs;
const openAICompatProfile = resolveOpenAICompatProfileConfig(
profileInfo.name,
expandedSettingsPath,
settingsEnv
);
if (openAICompatProfile) {
const proxyStart = await startOpenAICompatProxy(openAICompatProfile, {
insecure: openAICompatProfile.insecure,
});
if (!proxyStart.success) {
console.error(fail(proxyStart.error || 'Failed to start local OpenAI-compatible proxy'));
process.exit(1);
}
console.error(
info(
`Using local OpenAI-compatible proxy for "${profileInfo.name}" on port ${proxyStart.port}`
)
);
const proxyEnv = {
...envVars,
...buildOpenAICompatProxyEnv(
openAICompatProfile,
proxyStart.port,
proxyStart.authToken || '',
inheritedClaudeConfigDir
),
};
delete proxyEnv.ANTHROPIC_API_KEY;
const launchSettings = createOpenAICompatLaunchSettings(expandedSettingsPath, settings);
const launchArgs = [
'--settings',
launchSettings.settingsPath,
...appendThirdPartyWebSearchToolArgs(browserArgs),
];
const traceEnv = createWebSearchTraceContext({
launcher: 'ccs.settings-profile.proxy',
args: launchArgs,
profile: profileInfo.name,
profileType: profileInfo.type,
settingsPath: expandedSettingsPath,
});
execClaude(claudeCli, launchArgs, { ...proxyEnv, ...traceEnv }, launchSettings.cleanup);
return;
}
const launchArgs = [
'--settings',
expandedSettingsPath,
...appendThirdPartyWebSearchToolArgs(browserArgs),
];
const traceEnv = createWebSearchTraceContext({
launcher: 'ccs.settings-profile',
args: launchArgs,
profile: profileInfo.name,
profileType: profileInfo.type,
settingsPath: expandedSettingsPath,
});
execClaude(claudeCli, launchArgs, { ...claudeRuntimeEnvVars, ...traceEnv });
}
@@ -0,0 +1,126 @@
/**
* Image analysis environment preparation for settings-based profiles.
*
* Split from settings-flow.ts to keep that file under the 300 LOC budget.
* Resolves runtime status, connection, and env overrides for image analysis MCP.
*/
import { warn, info } from '../../utils/ui';
import {
applyImageAnalysisRuntimeOverrides,
getImageAnalysisHookEnv,
resolveImageAnalysisRuntimeConnection,
resolveImageAnalysisRuntimeStatus,
} from '../../utils/hooks';
import { ensureCliproxyService } from '../../cliproxy';
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
import type { ProfileDetectionResult } from '../../auth/profile-detector';
import type { loadSettings } from '../../config/config-loader-facade';
import type { resolveCliproxyBridgeMetadata } from '../../api/services/cliproxy-profile-bridge';
import type { resolveTargetType } from '../../targets/target-resolver';
export interface SettingsImageAnalysisPrepContext {
profileInfo: ProfileDetectionResult;
resolvedTarget: ReturnType<typeof resolveTargetType>;
settings: ReturnType<typeof loadSettings>;
cliproxyBridge: ReturnType<typeof resolveCliproxyBridgeMetadata> | undefined;
imageAnalysisMcpReady: boolean;
imageAnalysisFallbackHookReady: boolean | undefined;
remainingArgs: string[];
targetRemainingArgs: string[];
}
/**
* Resolve the complete image analysis env vars for a settings-based profile launch.
* Handles native-read fallback and proxy-stopped recovery.
*/
export async function resolveSettingsImageAnalysisEnv(
ctx: SettingsImageAnalysisPrepContext
): Promise<NodeJS.ProcessEnv> {
const {
profileInfo,
resolvedTarget,
settings,
cliproxyBridge,
imageAnalysisMcpReady,
imageAnalysisFallbackHookReady,
remainingArgs,
targetRemainingArgs,
} = ctx;
const imageAnalysisStatus = await resolveImageAnalysisRuntimeStatus({
profileName: profileInfo.name,
profileType: profileInfo.type,
settings,
cliproxyBridge,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
const runtimeConnection = resolveImageAnalysisRuntimeConnection();
let imageAnalysisEnv = getImageAnalysisHookEnv({
profileName: profileInfo.name,
profileType: profileInfo.type,
settings,
cliproxyBridge,
});
imageAnalysisEnv = applyImageAnalysisRuntimeOverrides(imageAnalysisEnv, {
backendId: imageAnalysisStatus.backendId,
model: imageAnalysisStatus.model,
runtimePath: imageAnalysisStatus.runtimePath,
baseUrl: runtimeConnection.baseUrl,
apiKey: runtimeConnection.apiKey,
allowSelfSigned: runtimeConnection.allowSelfSigned,
});
imageAnalysisEnv = {
...imageAnalysisEnv,
CCS_IMAGE_ANALYSIS_SKIP_HOOK: resolvedTarget === 'claude' && imageAnalysisMcpReady ? '1' : '0',
};
const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER'];
if (
resolvedTarget === 'claude' &&
imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' &&
imageAnalysisProvider
) {
const verboseProxyLaunch =
remainingArgs.includes('--verbose') ||
remainingArgs.includes('-v') ||
targetRemainingArgs.includes('--verbose') ||
targetRemainingArgs.includes('-v');
if (imageAnalysisStatus.effectiveRuntimeMode === 'native-read') {
console.error(
info(
`${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This session will use native Read.`
)
);
imageAnalysisEnv = {
...imageAnalysisEnv,
CCS_CURRENT_PROVIDER: '',
CCS_IMAGE_ANALYSIS_SKIP: '1',
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '',
CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: '',
CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: '',
CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED: '0',
};
} else if (imageAnalysisStatus.proxyReadiness === 'stopped') {
const ensureServiceResult = await ensureCliproxyService(
CLIPROXY_DEFAULT_PORT,
verboseProxyLaunch
);
if (!ensureServiceResult.started) {
console.error(
warn(
`Image analysis via ${imageAnalysisProvider} is unavailable because CCS could not start the local CLIProxy service. This session will use native Read.`
)
);
imageAnalysisEnv = {
...imageAnalysisEnv,
CCS_CURRENT_PROVIDER: '',
CCS_IMAGE_ANALYSIS_SKIP: '1',
};
}
}
}
return imageAnalysisEnv;
}
+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;
}
+359
View File
@@ -0,0 +1,359 @@
/**
* Profile and target resolution — Phase C extraction from src/ccs.ts.
*
* Extracted from main() lines ~136313 (after Phase B pre-dispatch).
* Responsible for: dynamic imports of auth modules, profile detection, target type
* resolution, Claude CLI detection, target adapter resolution, compatibility preflight
* checks (preserved with duplication per plan risk note 5), binary detection, and
* per-target argument normalization (droid routing + reasoning flag stripping,
* codex reasoning normalization, native Claude effort normalization).
*
* Outputs a ResolvedProfile context object consumed by Phase D (environment-builder
* argument normalization) and Phase E (per-profile dispatch flows).
*/
import { detectClaudeCli } from '../utils/claude-detector';
import { getSettingsPath } from '../utils/config-manager';
import { loadSettings } from '../config/config-loader-facade';
import { expandPath } from '../utils/helpers';
import { fail, info, warn } from '../utils/ui';
import { ErrorManager } from '../utils/error-manager';
import { getBrowserConfig } from '../config/config-loader-facade';
import {
getEffectiveClaudeBrowserAttachConfig,
resolveBrowserExposure,
getBlockedBrowserOverrideWarning,
} from '../utils/browser';
import { getTarget, evaluateTargetRuntimeCompatibility, pruneOrphanedModels } from '../targets';
import { resolveTargetType, stripTargetFlag } from '../targets/target-resolver';
import { DroidReasoningFlagError } from '../targets/droid-reasoning-runtime';
import { DroidCommandRouterError, routeDroidCommandArgs } from '../targets/droid-command-router';
import { resolveCliproxyBridgeMetadata } from '../api/services/cliproxy-profile-bridge';
import { resolveCodexRuntimeConfigOverrides } from './environment-builder';
import {
detectProfile,
resolveRuntimeReasoningFlags,
normalizeCodexRuntimeReasoningOverride,
exitWithRuntimeReasoningFlagError,
normalizeNativeClaudeEffortArgs,
shouldNormalizeNativeClaudeEffort,
} from './cli-argument-parser';
import type ProfileDetector from '../auth/profile-detector';
import type { ProfileDetectionResult } from '../auth/profile-detector';
import type { BrowserLaunchOverride } from '../utils/browser';
import type { ResolvedBrowserExposure } from '../utils/browser/browser-policy';
import type { Logger } from '../services/logging/logger';
import type { TargetAdapter, TargetBinaryInfo } from '../targets';
// ========== Interfaces ==========
export interface ProfileResolutionContext {
args: string[];
browserLaunchOverride: BrowserLaunchOverride | undefined;
cliLogger: Logger;
}
export interface ResolvedProfile {
/** Detected profile name (first positional arg or 'default') */
profile: string;
/** Args remaining after stripping profile token and --target flags */
remainingArgs: string[];
profileInfo: ProfileDetectionResult;
resolvedTarget: ReturnType<typeof resolveTargetType>;
claudeCli: string;
targetAdapter: TargetAdapter | null;
targetBinaryInfo: TargetBinaryInfo | null;
resolvedSettingsPath: string | undefined;
resolvedSettings: ReturnType<typeof loadSettings> | undefined;
resolvedCliproxyBridge: ReturnType<typeof resolveCliproxyBridgeMetadata> | undefined;
/** Per-target normalized args (droid/codex routing applied) */
targetRemainingArgs: string[];
/** Claude-specific args with effort normalization applied */
nativeClaudeRemainingArgs: string[];
runtimeReasoningOverride: string | number | undefined;
codexRuntimeConfigOverrides: string[];
claudeBrowserExposure: ResolvedBrowserExposure | undefined;
codexBrowserExposure: ResolvedBrowserExposure | undefined;
claudeAttachConfig: ReturnType<typeof getEffectiveClaudeBrowserAttachConfig> | undefined;
/** ProfileDetector instance — Phase E account/settings flows need getAllProfiles() */
detector: InstanceType<typeof ProfileDetector>;
}
// ========== Profile and Target Resolver ==========
/**
* Resolve profile detection, target type, adapter, binary, and per-target arg normalization.
*
* Throws ProfileError (caught by main() try/catch) for unknown profiles.
* Calls process.exit(1) for hard-stop conditions (adapter not found, binary not found, etc.).
*
* NOTE: The compatibility preflight block for non-settings profiles (else branch) is
* INTENTIONALLY duplicated between here (Phase C) and the settings flow (Phase E).
* Per plan risk note 5, do NOT dedupe in this PR.
*/
export async function resolveProfileAndTarget(
ctx: ProfileResolutionContext
): Promise<ResolvedProfile> {
const { args, browserLaunchOverride } = ctx;
// Dynamic imports — preserved at original call sites for latency/circular-dep avoidance.
// InstanceManager, ProfileRegistry, AccountContext, ProfileContinuity are loaded here
// to match original ordering; their resolved values flow into Phase E via the detector
// instance or are re-imported inside flow handlers.
const ProfileDetectorModule = await import('../auth/profile-detector');
const ProfileDetectorClass = ProfileDetectorModule.default;
await import('../management/instance-manager');
await import('../auth/profile-registry');
await import('../auth/account-context');
await import('../auth/profile-continuity-inheritance');
const detector = new ProfileDetectorClass();
// Detect profile (strip --target flags before profile detection)
const cleanArgs = stripTargetFlag(args);
const { profile, remainingArgs } = detectProfile(cleanArgs);
const profileInfo: ProfileDetectionResult = detector.detectProfileType(profile);
let resolvedTarget: ReturnType<typeof resolveTargetType>;
try {
resolvedTarget = resolveTargetType(
args,
profileInfo.target ? { target: profileInfo.target } : undefined
);
} catch (error) {
console.error(fail((error as Error).message));
process.exit(1);
// Unreachable; needed so TS knows resolvedTarget is always assigned below
throw error;
}
// Detect Claude CLI (needed for claude target and all CLIProxy-derived flows)
const claudeCliRaw = detectClaudeCli();
if (resolvedTarget === 'claude' && !claudeCliRaw) {
await ErrorManager.showClaudeNotFound();
process.exit(1);
}
const claudeCli = claudeCliRaw || '';
// Resolve non-claude target adapter once.
const targetAdapter: TargetAdapter | null =
resolvedTarget !== 'claude' ? (getTarget(resolvedTarget) ?? null) : null;
let resolvedSettingsPath: string | undefined;
let resolvedSettings: ReturnType<typeof loadSettings> | undefined;
let resolvedCliproxyBridge: ReturnType<typeof resolveCliproxyBridgeMetadata> | undefined;
// Preflight unsupported profile/target combinations BEFORE binary detection,
// so users get the most actionable error even when the target CLI is not installed.
if (resolvedTarget !== 'claude') {
if (!targetAdapter) {
console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
process.exit(1);
}
if (profileInfo.type === 'settings') {
resolvedSettingsPath = profileInfo.settingsPath
? expandPath(profileInfo.settingsPath)
: getSettingsPath(profileInfo.name);
resolvedSettings = loadSettings(resolvedSettingsPath);
resolvedCliproxyBridge = resolveCliproxyBridgeMetadata(resolvedSettings);
const compatibility = evaluateTargetRuntimeCompatibility({
target: resolvedTarget,
profileType: profileInfo.type,
cliproxyBridgeProvider: resolvedCliproxyBridge?.provider ?? null,
});
if (!compatibility.supported) {
console.error(
fail(
compatibility.reason || `${targetAdapter.displayName} does not support this profile.`
)
);
if (compatibility.suggestion) {
console.error(info(compatibility.suggestion));
}
process.exit(1);
}
} else {
// NOTE: Intentional duplication — Phase E settings flow re-runs this check post-load.
// Per plan risk note 5, preserve verbatim; do NOT dedupe.
const compatibility = evaluateTargetRuntimeCompatibility({
target: resolvedTarget,
profileType: profileInfo.type,
cliproxyProvider: profileInfo.type === 'cliproxy' ? profileInfo.provider : undefined,
isComposite: profileInfo.type === 'cliproxy' ? Boolean(profileInfo.isComposite) : undefined,
});
if (!compatibility.supported) {
console.error(
fail(
compatibility.reason || `${targetAdapter.displayName} does not support this profile.`
)
);
if (compatibility.suggestion) {
console.error(info(compatibility.suggestion));
}
process.exit(1);
}
}
if (profileInfo.type === 'default') {
if (!targetAdapter.supportsProfileType('default')) {
console.error(fail(`${targetAdapter.displayName} does not support default profile mode`));
process.exit(1);
}
// For default mode, Droid requires explicit credentials from environment.
if (resolvedTarget === 'droid') {
const baseUrl = process.env['ANTHROPIC_BASE_URL'] || '';
const apiKey = process.env['ANTHROPIC_AUTH_TOKEN'] || '';
if (!baseUrl.trim() || !apiKey.trim()) {
console.error(
fail(
`${targetAdapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN`
)
);
console.error(info('Use a settings-based profile instead: ccs glm --target droid'));
process.exit(1);
}
}
}
}
// For non-claude targets, verify target binary exists once and pass it through.
const targetBinaryInfo: TargetBinaryInfo | null = targetAdapter?.detectBinary() ?? null;
const browserConfig = getBrowserConfig();
const claudeAttachConfig =
resolvedTarget === 'claude' ? getEffectiveClaudeBrowserAttachConfig(browserConfig) : undefined;
const codexRuntimeConfigOverrides = resolveCodexRuntimeConfigOverrides(
resolvedTarget,
browserLaunchOverride
);
const claudeBrowserExposure: ResolvedBrowserExposure | undefined =
resolvedTarget === 'claude'
? resolveBrowserExposure(
{
enabled: claudeAttachConfig?.enabled ?? browserConfig.claude.enabled,
policy: browserConfig.claude.policy,
},
browserLaunchOverride
)
: undefined;
const codexBrowserExposure: ResolvedBrowserExposure | undefined =
resolvedTarget === 'codex'
? resolveBrowserExposure(browserConfig.codex, browserLaunchOverride)
: undefined;
const blockedBrowserOverrideWarning =
resolvedTarget === 'claude' && claudeBrowserExposure
? getBlockedBrowserOverrideWarning('Claude Browser Attach', claudeBrowserExposure)
: resolvedTarget === 'codex' && codexBrowserExposure
? getBlockedBrowserOverrideWarning('Codex Browser Tools', codexBrowserExposure)
: undefined;
if (blockedBrowserOverrideWarning) {
console.error(warn(blockedBrowserOverrideWarning));
}
if (resolvedTarget !== 'claude' && !targetBinaryInfo) {
const displayName = targetAdapter?.displayName || resolvedTarget;
console.error(fail(`${displayName} CLI not found.`));
if (resolvedTarget === 'droid') {
console.error(info('Install: npm i -g @factory/cli'));
} else if (resolvedTarget === 'codex') {
console.error(info('Install a recent @openai/codex build, then retry.'));
}
process.exit(1);
}
// Best-effort: prune stale Droid model entries at runtime so settings.json stays clean.
if (resolvedTarget === 'droid') {
try {
const allProfiles = detector.getAllProfiles();
const activeProfiles = allProfiles.settings.filter((name) => /^[a-zA-Z0-9._-]+$/.test(name));
await pruneOrphanedModels(activeProfiles);
} catch (error) {
console.error(warn(`[!] Droid prune skipped: ${(error as Error).message}`));
}
}
// Per-target argument normalization
let targetRemainingArgs = remainingArgs;
let runtimeReasoningOverride: string | number | undefined;
let nativeClaudeRemainingArgs = remainingArgs;
if (resolvedTarget === 'droid') {
try {
const droidRoute = routeDroidCommandArgs(remainingArgs);
targetRemainingArgs = droidRoute.argsForDroid;
if (droidRoute.mode === 'interactive') {
const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env['CCS_THINKING']);
targetRemainingArgs = runtime.argsWithoutReasoningFlags;
runtimeReasoningOverride = runtime.reasoningOverride;
} else {
if (droidRoute.duplicateReasoningDisplays.length > 0) {
console.error(
warn(
`[!] Multiple reasoning flags detected. Using first occurrence: ${droidRoute.reasoningSourceDisplay || '<first-flag>'}`
)
);
}
if (droidRoute.autoPrependedExec && process.stdout.isTTY) {
console.error(
info('Detected Droid exec-only flags. Routing as: droid exec <flags> [prompt]')
);
}
}
} catch (error) {
if (error instanceof DroidReasoningFlagError || error instanceof DroidCommandRouterError) {
exitWithRuntimeReasoningFlagError(error.message, {
codexAliasLevels: 'minimal|low|medium|high|xhigh',
includeDroidExecExample: true,
});
}
throw error;
}
} else if (resolvedTarget === 'codex') {
try {
const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env['CCS_THINKING']);
targetRemainingArgs = runtime.argsWithoutReasoningFlags;
const normalizedReasoning = normalizeCodexRuntimeReasoningOverride(runtime.reasoningOverride);
if (runtime.reasoningOverride !== undefined && !normalizedReasoning) {
if (runtime.reasoningSource === 'flag') {
throw new DroidReasoningFlagError(
'Codex target supports reasoning levels only: minimal, low, medium, high, xhigh.',
'--effort'
);
}
runtimeReasoningOverride = undefined;
} else {
runtimeReasoningOverride = normalizedReasoning;
}
} catch (error) {
if (error instanceof DroidReasoningFlagError) {
exitWithRuntimeReasoningFlagError(error.message, {
codexAliasLevels: 'minimal|low|medium|high|xhigh',
});
}
throw error;
}
} else if (resolvedTarget === 'claude' && shouldNormalizeNativeClaudeEffort(profileInfo.type)) {
nativeClaudeRemainingArgs = normalizeNativeClaudeEffortArgs(remainingArgs);
}
return {
profile,
remainingArgs,
profileInfo,
resolvedTarget,
claudeCli,
targetAdapter,
targetBinaryInfo,
resolvedSettingsPath,
resolvedSettings,
resolvedCliproxyBridge,
targetRemainingArgs,
nativeClaudeRemainingArgs,
runtimeReasoningOverride,
codexRuntimeConfigOverrides,
claudeBrowserExposure,
codexBrowserExposure,
claudeAttachConfig,
detector,
};
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Native target execution — short-circuit dispatch for passthrough flag commands,
* and top-level profile dispatcher (Phase E switch).
*
* Extracted from src/ccs.ts (lines 291-295, 394-426).
* Handles direct execution of native Codex flag passthrough (--help, --version, etc.)
* before the main profile dispatch loop runs.
*
* dispatchProfile() collapses the 6-branch switch in main() to a single call.
*/
import { fail, info } from '../utils/ui';
import { getTarget } from '../targets';
import { getNativeCodexPassthroughArgs } from './cli-argument-parser';
import { runCliproxyFlow } from './flows/cliproxy-flow';
import { runCopilotFlow } from './flows/copilot-flow';
import { runCursorFlow } from './flows/cursor-flow';
import { runSettingsFlow } from './flows/settings-flow';
import { runAccountFlow } from './flows/account-flow';
import { runDefaultFlow } from './flows/default-flow';
import type { TargetCredentials } from '../targets';
import type { ProfileDispatchContext } from './dispatcher-context';
// ========== Interfaces ==========
export interface ProfileError extends Error {
profileName?: string;
availableProfiles?: string;
suggestions?: string[];
}
// ========== Native Codex Flag Command Executor ==========
export function execNativeCodexFlagCommand(args: string[]): void {
const adapter = getTarget('codex');
if (!adapter) {
console.error(fail('Target adapter not found for "codex"'));
process.exit(1);
}
const binaryInfo = adapter.detectBinary();
if (!binaryInfo) {
console.error(fail('Codex CLI not found.'));
console.error(info('Install a recent @openai/codex build, then retry.'));
process.exit(1);
}
const targetArgs = getNativeCodexPassthroughArgs(args);
if (!targetArgs) {
console.error(fail('Native Codex passthrough args could not be resolved.'));
process.exit(1);
}
const creds: TargetCredentials = {
profile: 'default',
baseUrl: '',
apiKey: '',
};
const builtArgs = adapter.buildArgs('default', targetArgs, {
creds,
profileType: 'default',
binaryInfo,
});
const targetEnv = adapter.buildEnv(creds, 'default');
adapter.exec(builtArgs, targetEnv, { binaryInfo });
}
// ========== Profile Dispatcher ==========
/**
* Dispatch to the correct per-profile-type flow.
*
* Collapses the 6-branch if/else-if switch that previously lived in main() into a
* single call site. The headless -p delegation short-circuit is handled in main()
* before this function is called.
*/
export async function dispatchProfile(ctx: ProfileDispatchContext): Promise<void> {
const { profileInfo } = ctx;
switch (profileInfo.type) {
case 'cliproxy':
return runCliproxyFlow(ctx);
case 'copilot':
return runCopilotFlow(ctx);
case 'cursor':
return runCursorFlow(ctx);
case 'settings':
return runSettingsFlow(ctx);
case 'account':
return runAccountFlow(ctx);
default:
return runDefaultFlow(ctx);
}
}