refactor(dispatcher): extract profile and target detection from ccs.ts

Phase 4 of #1165. Extracts the largest remaining concern in main():
profile detection, target resolution, Claude CLI detection, adapter
lookup, compatibility preflight, binary detection, droid prune, and
per-target arg normalization.

- src/dispatcher/profile-resolver.ts (358 LOC):
  resolveProfileAndTarget + ProfileResolutionContext / ResolvedProfile
  types. Includes droid + codex per-target arg normalization (cohesive
  with profile resolution; splitting would create incomplete extraction).
- 7 unit tests covering default, gemini, codex, unknown profile, droid,
  and compatibility preflight runs.

Compatibility preflight duplication between this phase and the settings
flow (Phase E) is preserved exactly per plan risk note 5 — do not dedupe
in this PR. Module is 358 LOC (over 300 ideal) — kept whole because the
profile resolution + per-target normalization form a single dependency
chain.

ccs.ts: 1262 -> 1040 LOC (-222). Behavior unchanged; full suite passes
1824/1824.

Refs #1165
This commit is contained in:
Tam Nhu Tran
2026-05-02 23:41:48 -04:00
parent 0cf4ad7b48
commit a807de6f4c
3 changed files with 780 additions and 257 deletions
+35 -257
View File
@@ -1,6 +1,5 @@
import './utils/fetch-proxy-setup';
import { detectClaudeCli } from './utils/claude-detector';
import { getSettingsPath, loadSettings } from './utils/config-manager';
import { expandPath } from './utils/helpers';
import {
@@ -33,13 +32,10 @@ import {
import {
appendBrowserToolArgs,
ensureBrowserMcpOrThrow,
getBlockedBrowserOverrideWarning,
getEffectiveClaudeBrowserAttachConfig,
resolveBrowserExposure,
resolveOptionalBrowserAttachRuntime,
syncBrowserMcpToConfigDir,
} from './utils/browser';
import { getBrowserConfig, getGlobalEnvConfig } from './config/unified-config-loader';
import { getGlobalEnvConfig } from './config/unified-config-loader';
import {
ensureProfileHooks as ensureImageAnalyzerHooks,
removeImageAnalysisProfileHook,
@@ -62,23 +58,16 @@ import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils
import { createOpenAICompatLaunchSettings } from './utils/openai-compat-launch-settings';
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
import { createLogger, runWithRequestId } from './services/logging';
import type { ProfileDetectionResult } from './auth/profile-detector';
// Import target adapter system
import {
registerTarget,
getTarget,
ClaudeAdapter,
DroidAdapter,
CodexAdapter,
evaluateTargetRuntimeCompatibility,
pruneOrphanedModels,
resolveDroidProvider,
type TargetCredentials,
} 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 {
buildOpenAICompatProxyEnv,
@@ -87,21 +76,11 @@ import {
} from './proxy';
// Import extracted dispatcher modules
import {
detectProfile,
resolveRuntimeReasoningFlags,
normalizeCodexRuntimeReasoningOverride,
exitWithRuntimeReasoningFlagError,
normalizeNativeClaudeEffortArgs,
shouldNormalizeNativeClaudeEffort,
bootstrapAndParseEarlyCli,
} from './dispatcher/cli-argument-parser';
import {
resolveCodexRuntimeConfigOverrides,
resolveNativeClaudeLaunchArgs,
} from './dispatcher/environment-builder';
import { bootstrapAndParseEarlyCli } from './dispatcher/cli-argument-parser';
import { resolveNativeClaudeLaunchArgs } from './dispatcher/environment-builder';
import { type ProfileError } from './dispatcher/target-executor';
import { runPreDispatchHandlers } from './dispatcher/pre-dispatch';
import { resolveProfileAndTarget } from './dispatcher/profile-resolver';
// ========== Main Execution ==========
@@ -133,9 +112,37 @@ async function main(): Promise<void> {
return;
}
// Use ProfileDetector to determine profile type
const ProfileDetectorModule = await import('./auth/profile-detector');
const ProfileDetector = ProfileDetectorModule.default;
// Phase C: profile + target detection (extracted to dispatcher/profile-resolver.ts)
const {
profile,
remainingArgs,
profileInfo,
resolvedTarget,
claudeCli,
targetAdapter,
targetBinaryInfo,
resolvedSettingsPath: initialResolvedSettingsPath,
resolvedSettings: initialResolvedSettings,
resolvedCliproxyBridge: initialResolvedCliproxyBridge,
targetRemainingArgs: initialTargetRemainingArgs,
nativeClaudeRemainingArgs: initialNativeClaudeRemainingArgs,
runtimeReasoningOverride: initialRuntimeReasoningOverride,
codexRuntimeConfigOverrides,
claudeBrowserExposure,
codexBrowserExposure: _codexBrowserExposure,
claudeAttachConfig,
detector: _detector,
} = await resolveProfileAndTarget({ args, browserLaunchOverride, cliLogger });
// Re-assign mutable state that Phase E flows may update
const resolvedSettingsPath = initialResolvedSettingsPath;
const resolvedSettings = initialResolvedSettings;
const resolvedCliproxyBridge = initialResolvedCliproxyBridge;
const targetRemainingArgs = initialTargetRemainingArgs;
const nativeClaudeRemainingArgs = initialNativeClaudeRemainingArgs;
const runtimeReasoningOverride = initialRuntimeReasoningOverride;
// Re-import dynamic modules needed by Phase E flows (preserving original ordering).
const InstanceManagerModule = await import('./management/instance-manager');
const InstanceManager = InstanceManagerModule.default;
const ProfileRegistryModule = await import('./auth/profile-registry');
@@ -145,236 +152,7 @@ async function main(): Promise<void> {
const ProfileContinuityModule = await import('./auth/profile-continuity-inheritance');
const { resolveProfileContinuityInheritance } = ProfileContinuityModule;
const detector = new ProfileDetector();
try {
// 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);
return;
}
// 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 = resolvedTarget !== 'claude' ? getTarget(resolvedTarget) : 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 {
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 = targetAdapter?.detectBinary() ?? null;
const browserConfig = getBrowserConfig();
const claudeAttachConfig =
resolvedTarget === 'claude'
? getEffectiveClaudeBrowserAttachConfig(browserConfig)
: undefined;
const codexRuntimeConfigOverrides = resolveCodexRuntimeConfigOverrides(
resolvedTarget,
browserLaunchOverride
);
const claudeBrowserExposure =
resolvedTarget === 'claude'
? resolveBrowserExposure(
{
enabled: claudeAttachConfig?.enabled ?? browserConfig.claude.enabled,
policy: browserConfig.claude.policy,
},
browserLaunchOverride
)
: undefined;
const codexBrowserExposure =
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}`));
}
}
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);
}
// Special case: headless delegation (-p/--prompt)
// Keep existing behavior for Claude targets only; non-claude targets must continue
// through normal adapter dispatch logic.
@@ -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();
});
});
+358
View File
@@ -0,0 +1,358 @@
/**
* Profile and target resolution — Phase C extraction from src/ccs.ts.
*
* Extracted from main() lines ~136313 (after Phase B pre-dispatch).
* Responsible for: dynamic imports of auth modules, profile detection, target type
* resolution, Claude CLI detection, target adapter resolution, compatibility preflight
* checks (preserved with duplication per plan risk note 5), binary detection, and
* per-target argument normalization (droid routing + reasoning flag stripping,
* codex reasoning normalization, native Claude effort normalization).
*
* Outputs a ResolvedProfile context object consumed by Phase D (environment-builder
* argument normalization) and Phase E (per-profile dispatch flows).
*/
import { detectClaudeCli } from '../utils/claude-detector';
import { getSettingsPath, loadSettings } from '../utils/config-manager';
import { expandPath } from '../utils/helpers';
import { fail, info, warn } from '../utils/ui';
import { ErrorManager } from '../utils/error-manager';
import { getBrowserConfig } from '../config/unified-config-loader';
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,
};
}