mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 00:22:34 +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