mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 18:18:43 +00:00
refactor(dispatcher): extract concerns from ccs.ts (#1167)
refactor(dispatcher): extract concerns from ccs.ts (#1165)
This commit is contained in:
+31
-1633
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
/**
|
||||||
|
* Profile and target resolution — Phase C extraction from src/ccs.ts.
|
||||||
|
*
|
||||||
|
* Extracted from main() lines ~136–313 (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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user