diff --git a/package.json b/package.json index 2a178cf2..740a24a5 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,9 @@ "bin": { "ccs": "dist/ccs.js", "ccs-droid": "dist/bin/droid-runtime.js", - "ccsd": "dist/bin/droid-runtime.js" + "ccsd": "dist/bin/droid-runtime.js", + "ccs-codex": "dist/bin/codex-runtime.js", + "ccsx": "dist/bin/codex-runtime.js" }, "files": [ "dist/", diff --git a/src/bin/codex-runtime.ts b/src/bin/codex-runtime.ts new file mode 100644 index 00000000..00478915 --- /dev/null +++ b/src/bin/codex-runtime.ts @@ -0,0 +1,2 @@ +process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex'; +require('../ccs'); diff --git a/src/ccs.ts b/src/ccs.ts index 395f2748..457536dd 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -56,6 +56,8 @@ import { getTarget, ClaudeAdapter, DroidAdapter, + CodexAdapter, + evaluateTargetRuntimeCompatibility, pruneOrphanedModels, resolveDroidProvider, type TargetCredentials, @@ -66,6 +68,7 @@ import { resolveDroidReasoningRuntime, } from './targets/droid-reasoning-runtime'; import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router'; +import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge'; // Version and Update check utilities import { getVersion } from './utils/version'; @@ -183,6 +186,7 @@ async function main(): Promise { // Register target adapters registerTarget(new ClaudeAdapter()); registerTarget(new DroidAdapter()); + registerTarget(new CodexAdapter()); const args = process.argv.slice(2); @@ -381,21 +385,25 @@ async function main(): Promise { process.exit(1); } - if (profileInfo.type === 'cliproxy' && !targetAdapter.supportsProfileType('cliproxy')) { - console.error(fail(`${targetAdapter.displayName} does not support CLIProxy profiles`)); - console.error(info('Use a settings-based profile with --target instead')); - process.exit(1); - } - - if (profileInfo.type === 'copilot' && !targetAdapter.supportsProfileType('copilot')) { - console.error(fail(`${targetAdapter.displayName} does not support Copilot profiles`)); - process.exit(1); - } - - if (profileInfo.type === 'account' && !targetAdapter.supportsProfileType('account')) { - console.error(fail(`${targetAdapter.displayName} does not support account-based profiles`)); - console.error(info('Use a settings-based profile with --target instead')); - process.exit(1); + if (profileInfo.type !== 'settings') { + 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') { @@ -428,6 +436,8 @@ async function main(): Promise { 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); } @@ -446,7 +456,7 @@ async function main(): Promise { } let targetRemainingArgs = remainingArgs; - let droidReasoningOverride: string | number | undefined; + let runtimeReasoningOverride: string | number | undefined; if (resolvedTarget === 'droid') { try { const droidRoute = routeDroidCommandArgs(remainingArgs); @@ -455,7 +465,7 @@ async function main(): Promise { if (droidRoute.mode === 'interactive') { const runtime = resolveDroidReasoningRuntime(remainingArgs, process.env.CCS_THINKING); targetRemainingArgs = runtime.argsWithoutReasoningFlags; - droidReasoningOverride = runtime.reasoningOverride; + runtimeReasoningOverride = runtime.reasoningOverride; if (runtime.duplicateDisplays.length > 0) { console.error( @@ -488,6 +498,28 @@ async function main(): Promise { } throw error; } + } else if (resolvedTarget === 'codex') { + try { + const runtime = resolveDroidReasoningRuntime(remainingArgs, process.env.CCS_THINKING); + targetRemainingArgs = runtime.argsWithoutReasoningFlags; + runtimeReasoningOverride = runtime.reasoningOverride; + + if (runtime.duplicateDisplays.length > 0) { + console.error( + warn( + `[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || ''}` + ) + ); + } + } catch (error) { + if (error instanceof DroidReasoningFlagError) { + console.error(fail(error.message)); + console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); + console.error(' Codex alias: --effort minimal|low|medium|high|xhigh'); + process.exit(1); + } + throw error; + } } // Special case: headless delegation (-p/--prompt) @@ -625,7 +657,7 @@ async function main(): Promise { baseUrl: envVars['ANTHROPIC_BASE_URL'], model: envVars['ANTHROPIC_MODEL'], }), - reasoningOverride: droidReasoningOverride, + reasoningOverride: runtimeReasoningOverride, envVars, }; @@ -643,7 +675,11 @@ async function main(): Promise { } await adapter.prepareCredentials(creds); - const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs); + 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; @@ -722,6 +758,26 @@ async function main(): Promise { ? expandPath(profileInfo.settingsPath) : getSettingsPath(profileInfo.name); const settings = loadSettings(expandedSettingsPath); + const cliproxyBridge = resolveCliproxyBridgeMetadata(settings); + 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 @@ -841,11 +897,15 @@ async function main(): Promise { baseUrl: directAnthropicBaseUrl, model: settingsEnv['ANTHROPIC_MODEL'], }), - reasoningOverride: droidReasoningOverride, + reasoningOverride: runtimeReasoningOverride, envVars, }; await adapter.prepareCredentials(creds); - const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs); + 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; @@ -938,9 +998,9 @@ async function main(): Promise { baseUrl: process.env['ANTHROPIC_BASE_URL'], model: process.env['ANTHROPIC_MODEL'], }), - reasoningOverride: droidReasoningOverride, + reasoningOverride: runtimeReasoningOverride, }; - if (!creds.baseUrl || !creds.apiKey) { + if (resolvedTarget === 'droid' && (!creds.baseUrl || !creds.apiKey)) { console.error( fail( `${adapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN` @@ -950,7 +1010,11 @@ async function main(): Promise { process.exit(1); } await adapter.prepareCredentials(creds); - const targetArgs = adapter.buildArgs('default', targetRemainingArgs); + 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; diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts new file mode 100644 index 00000000..1fb24b60 --- /dev/null +++ b/src/targets/codex-adapter.ts @@ -0,0 +1,234 @@ +import { ChildProcess, spawn } from 'child_process'; +import * as fs from 'fs'; +import type { ProfileType } from '../types/profile'; +import { runCleanup } from '../errors'; +import { wireChildProcessSignals } from '../utils/signal-forwarder'; +import { escapeShellArg, stripAnthropicEnv } from '../utils/shell-executor'; +import type { + TargetAdapter, + TargetBinaryInfo, + TargetCredentials, + TargetType, +} from './target-adapter'; +import { + codexBinarySupportsConfigOverrides, + detectCodexCli, + getCodexBinaryInfo, +} from './codex-detector'; + +const CODEX_RUNTIME_PROVIDER_ID = 'ccs_runtime'; +const CODEX_RUNTIME_ENV_KEY = 'CCS_CODEX_API_KEY'; +const CODEX_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']); + +function formatTomlString(value: string): string { + return JSON.stringify(value); +} + +function buildConfigOverrideArgs(overrides: string[]): string[] { + return overrides.flatMap((override) => ['-c', override]); +} + +function findDisallowedCodexManagedFlags(args: string[]): string[] { + const disallowed = new Set(); + + for (const arg of args) { + if (arg === '-c' || arg === '--config' || arg.startsWith('--config=')) { + disallowed.add('--config/-c'); + continue; + } + if (arg === '-p' || arg === '--profile' || arg.startsWith('--profile=')) { + disallowed.add('--profile/-p'); + continue; + } + if (arg === '--oss') { + disallowed.add('--oss'); + continue; + } + if (arg === '--local-provider' || arg.startsWith('--local-provider=')) { + disallowed.add('--local-provider'); + } + } + + return [...disallowed]; +} + +function normalizeCodexReasoningOverride(value: string | number | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value === 'string' && CODEX_REASONING_LEVELS.has(value)) { + return value; + } + throw new Error( + 'Codex target supports reasoning levels only: minimal, low, medium, high, xhigh.' + ); +} + +export class CodexAdapter implements TargetAdapter { + readonly type: TargetType = 'codex'; + readonly displayName = 'Codex CLI'; + + detectBinary(): TargetBinaryInfo | null { + return getCodexBinaryInfo(); + } + + async prepareCredentials(_creds: TargetCredentials): Promise { + // Codex uses transient -c overrides plus env_key injection. + } + + buildArgs( + _profile: string, + userArgs: string[], + options?: { + creds?: TargetCredentials; + profileType?: ProfileType; + binaryInfo?: TargetBinaryInfo; + } + ): string[] { + const profileType = options?.profileType || 'default'; + const creds = options?.creds; + const reasoningOverride = normalizeCodexReasoningOverride(creds?.reasoningOverride); + + if (profileType === 'default') { + if (reasoningOverride) { + return [ + ...buildConfigOverrideArgs([ + `model_reasoning_effort=${formatTomlString(reasoningOverride)}`, + ]), + ...userArgs, + ]; + } + return userArgs; + } + + if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) { + const versionSummary = options?.binaryInfo?.version ? ` (${options.binaryInfo.version})` : ''; + throw new Error( + `Codex CLI${versionSummary} does not advertise --config overrides. Upgrade Codex before using CCS-backed Codex profiles.` + ); + } + + if (!creds?.baseUrl?.trim() || !creds.apiKey?.trim()) { + throw new Error( + 'Codex target requires base URL and API key for CCS-backed profile launches.' + ); + } + + const disallowedFlags = findDisallowedCodexManagedFlags(userArgs); + if (disallowedFlags.length > 0) { + throw new Error( + `Codex target does not allow ${disallowedFlags.join(', ')} when CCS manages the runtime provider. Remove native Codex provider selection flags and retry.` + ); + } + + const overrides = [ + `model_provider=${formatTomlString(CODEX_RUNTIME_PROVIDER_ID)}`, + `model_providers.${CODEX_RUNTIME_PROVIDER_ID}.name=${formatTomlString('CCS Runtime')}`, + `model_providers.${CODEX_RUNTIME_PROVIDER_ID}.base_url=${formatTomlString(creds.baseUrl)}`, + `model_providers.${CODEX_RUNTIME_PROVIDER_ID}.env_key=${formatTomlString(CODEX_RUNTIME_ENV_KEY)}`, + `model_providers.${CODEX_RUNTIME_PROVIDER_ID}.wire_api=${formatTomlString('responses')}`, + ]; + + if (creds.model?.trim()) { + overrides.push(`model=${formatTomlString(creds.model)}`); + } + + if (reasoningOverride) { + overrides.push(`model_reasoning_effort=${formatTomlString(reasoningOverride)}`); + } + + return [...buildConfigOverrideArgs(overrides), ...userArgs]; + } + + buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...stripAnthropicEnv(process.env) }; + delete env[CODEX_RUNTIME_ENV_KEY]; + if (profileType !== 'default') { + if (!creds.apiKey?.trim()) { + throw new Error('Codex target requires an API key for CCS-backed profile launches.'); + } + env[CODEX_RUNTIME_ENV_KEY] = creds.apiKey; + } + return env; + } + + exec( + args: string[], + env: NodeJS.ProcessEnv, + options?: { cwd?: string; binaryInfo?: TargetBinaryInfo } + ): void { + const exitWithCleanup = (code: number): never => { + try { + runCleanup(); + } catch { + // Cleanup is best-effort on launch errors. + } + process.exit(code); + }; + + const codexPath = options?.binaryInfo?.path || detectCodexCli(); + if (!codexPath) { + console.error('[X] Codex CLI not found. Install a recent @openai/codex build first.'); + return exitWithCleanup(1); + } + + try { + const stat = fs.statSync(codexPath); + if (!stat.isFile()) { + console.error(`[X] Codex CLI path is not a file: ${codexPath}`); + return exitWithCleanup(1); + } + } catch (err) { + const error = err as NodeJS.ErrnoException; + console.error( + `[X] Codex CLI path is not accessible (${error.code || 'unknown'}): ${codexPath}` + ); + return exitWithCleanup(1); + } + + const isWindows = process.platform === 'win32'; + const isPowerShellScript = isWindows && /\.ps1$/i.test(codexPath); + const needsShell = isWindows && /\.(cmd|bat)$/i.test(codexPath); + + let child: ChildProcess; + if (isPowerShellScript) { + child = spawn( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', codexPath, ...args], + { stdio: 'inherit', windowsHide: true, env } + ); + } else if (needsShell) { + const cmdString = [codexPath, ...args].map(escapeShellArg).join(' '); + child = spawn(cmdString, { + stdio: 'inherit', + windowsHide: true, + shell: true, + env, + }); + } else { + child = spawn(codexPath, args, { stdio: 'inherit', windowsHide: true, env }); + } + + wireChildProcessSignals(child, (err: NodeJS.ErrnoException) => { + if (err.code === 'EACCES') { + console.error(`[X] Codex CLI is not executable: ${codexPath}`); + console.error(' Check file permissions and executable bit.'); + } else if (err.code === 'ENOENT') { + if (isPowerShellScript) { + console.error('[X] PowerShell executable not found (required for .ps1 wrapper launch).'); + } else if (needsShell) { + console.error('[X] Windows command shell not found for Codex wrapper launch.'); + } else { + console.error(`[X] Codex CLI not found: ${codexPath}`); + } + } else { + console.error(`[X] Failed to start Codex CLI (${codexPath}): ${err.message}`); + } + return exitWithCleanup(1); + }); + } + + supportsProfileType(profileType: ProfileType): boolean { + return profileType === 'default' || profileType === 'settings' || profileType === 'cliproxy'; + } +} diff --git a/src/targets/codex-detector.ts b/src/targets/codex-detector.ts new file mode 100644 index 00000000..07b1a7a5 --- /dev/null +++ b/src/targets/codex-detector.ts @@ -0,0 +1,138 @@ +import * as fs from 'fs'; +import * as childProcess from 'child_process'; +import { expandPath } from '../utils/helpers'; +import { escapeShellArg } from '../utils/shell-executor'; +import type { TargetBinaryInfo } from './target-adapter'; + +const CODEX_CONFIG_OVERRIDE_FEATURE = 'config-overrides'; + +function runCodexProbe(codexPath: string, args: string[]): string | undefined { + const isWindows = process.platform === 'win32'; + const isPowerShellScript = isWindows && /\.ps1$/i.test(codexPath); + const needsShell = isWindows && /\.(cmd|bat)$/i.test(codexPath); + + try { + if (isPowerShellScript) { + return childProcess.execFileSync( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', codexPath, ...args], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + windowsHide: true, + } + ); + } + + if (needsShell) { + const cmdString = [codexPath, ...args].map(escapeShellArg).join(' '); + return childProcess.execFileSync('cmd.exe', ['/d', '/s', '/c', cmdString], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + windowsHide: true, + }); + } + + return childProcess.execFileSync(codexPath, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + }); + } catch { + return undefined; + } +} + +function readCodexVersion(codexPath: string): string | undefined { + return runCodexProbe(codexPath, ['--version'])?.trim(); +} + +function detectCodexFeatures(codexPath: string): readonly string[] { + const helpText = runCodexProbe(codexPath, ['--help']); + return helpText?.includes('--config ') ? [CODEX_CONFIG_OVERRIDE_FEATURE] : []; +} + +export function detectCodexCli(): string | null { + if (process.env.CCS_CODEX_PATH) { + const customPath = expandPath(process.env.CCS_CODEX_PATH); + try { + if (fs.statSync(customPath).isFile()) { + return customPath; + } + console.warn('[!] CCS_CODEX_PATH points to a directory, not a file:', customPath); + console.warn(' Refusing PATH fallback while CCS_CODEX_PATH is explicitly set.'); + return null; + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code === 'ENOENT') { + console.warn('[!] Warning: CCS_CODEX_PATH is set but file not found:', customPath); + } else { + console.warn( + `[!] Warning: CCS_CODEX_PATH is not accessible (${error.code || 'unknown error'}):`, + customPath + ); + } + console.warn(' Refusing PATH fallback while CCS_CODEX_PATH is explicitly set.'); + return null; + } + } + + const isWindows = process.platform === 'win32'; + try { + const cmd = isWindows ? 'where.exe codex' : 'which codex'; + const result = childProcess + .execSync(cmd, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + }) + .trim(); + + const matches = result + .split('\n') + .map((entry) => entry.trim()) + .filter(Boolean); + + const candidates = isWindows + ? [ + ...matches.filter((entry) => /\.(exe|cmd|bat|ps1)$/i.test(entry)), + ...matches.filter((entry) => !/\.(exe|cmd|bat|ps1)$/i.test(entry)), + ] + : matches; + + for (const candidate of candidates) { + try { + if (fs.statSync(candidate).isFile()) { + return candidate; + } + } catch { + // Ignore disappearing PATH candidates. + } + } + } catch { + // codex not in PATH + } + + return null; +} + +export function getCodexBinaryInfo(): TargetBinaryInfo | null { + const codexPath = detectCodexCli(); + if (!codexPath) return null; + + const isWindows = process.platform === 'win32'; + return { + path: codexPath, + needsShell: isWindows && /\.(cmd|bat|ps1)$/i.test(codexPath), + version: readCodexVersion(codexPath), + features: detectCodexFeatures(codexPath), + }; +} + +export function codexBinarySupportsConfigOverrides( + binaryInfo: TargetBinaryInfo | null | undefined +): boolean { + return Boolean(binaryInfo?.features?.includes(CODEX_CONFIG_OVERRIDE_FEATURE)); +} diff --git a/src/targets/index.ts b/src/targets/index.ts index 2d05d240..fba132f8 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -19,7 +19,13 @@ export { } from './target-registry'; export { ClaudeAdapter } from './claude-adapter'; export { DroidAdapter } from './droid-adapter'; +export { CodexAdapter } from './codex-adapter'; export { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector'; +export { + codexBinarySupportsConfigOverrides, + getCodexBinaryInfo, + detectCodexCli, +} from './codex-detector'; export { upsertCcsModel, removeCcsModel, @@ -30,3 +36,13 @@ export type { DroidCustomModel } from './droid-config-manager'; export { resolveDroidProvider, normalizeDroidProvider } from './droid-provider'; export type { DroidProvider } from './droid-provider'; export { resolveTargetType, stripTargetFlag } from './target-resolver'; +export { + TARGET_METADATA, + RUNTIME_TARGET_TYPES, + PERSISTED_TARGET_TYPES, + getPersistedTargetChoices, + getRuntimeTargetChoices, + isPersistedTargetType, + isRuntimeTargetType, +} from './target-metadata'; +export { evaluateTargetRuntimeCompatibility } from './target-runtime-compatibility'; diff --git a/src/targets/target-adapter.ts b/src/targets/target-adapter.ts index ffe4b890..abf5c3ce 100644 --- a/src/targets/target-adapter.ts +++ b/src/targets/target-adapter.ts @@ -11,7 +11,7 @@ */ import type { ProfileType } from '../types/profile'; -export type TargetType = 'claude' | 'droid'; +export type TargetType = 'claude' | 'droid' | 'codex'; /** * Credentials resolved by CCS profile system, ready for delivery to target CLI. @@ -39,6 +39,8 @@ export interface TargetCredentials { export interface TargetBinaryInfo { path: string; needsShell: boolean; // Windows .cmd/.bat/.ps1 + version?: string; + features?: readonly string[]; } /** @@ -72,7 +74,15 @@ export interface TargetAdapter { * Build target-specific argument vector. * `userArgs` are the arguments after CCS profile/flag parsing. */ - buildArgs(profile: string, userArgs: string[]): string[]; + buildArgs( + profile: string, + userArgs: string[], + options?: { + creds?: TargetCredentials; + profileType?: ProfileType; + binaryInfo?: TargetBinaryInfo; + } + ): string[]; /** * Build environment variables for process spawn. diff --git a/src/targets/target-metadata.ts b/src/targets/target-metadata.ts new file mode 100644 index 00000000..af28aeb4 --- /dev/null +++ b/src/targets/target-metadata.ts @@ -0,0 +1,87 @@ +import type { TargetType } from './target-adapter'; + +export interface TargetMetadata { + displayName: string; + runtimeAliases: readonly string[]; + legacyAliasEnvVar?: string; + persistedTarget: boolean; +} + +export const TARGET_METADATA: Record = { + claude: { + displayName: 'Claude Code', + runtimeAliases: [], + persistedTarget: true, + }, + droid: { + displayName: 'Factory Droid', + runtimeAliases: ['ccs-droid', 'ccsd'], + legacyAliasEnvVar: 'CCS_DROID_ALIASES', + persistedTarget: true, + }, + codex: { + displayName: 'Codex CLI', + runtimeAliases: ['ccs-codex', 'ccsx'], + legacyAliasEnvVar: 'CCS_CODEX_ALIASES', + persistedTarget: false, + }, +}; + +export const RUNTIME_TARGET_TYPES = Object.freeze( + Object.keys(TARGET_METADATA) as TargetType[] +) as readonly TargetType[]; + +export const PERSISTED_TARGET_TYPES = Object.freeze( + RUNTIME_TARGET_TYPES.filter((target) => TARGET_METADATA[target].persistedTarget) +) as readonly TargetType[]; + +const RUNTIME_TARGET_SET = new Set(RUNTIME_TARGET_TYPES); +const PERSISTED_TARGET_SET = new Set(PERSISTED_TARGET_TYPES); + +export function isRuntimeTargetType(value: unknown): value is TargetType { + return typeof value === 'string' && RUNTIME_TARGET_SET.has(value as TargetType); +} + +export function isPersistedTargetType(value: unknown): value is TargetType { + return typeof value === 'string' && PERSISTED_TARGET_SET.has(value as TargetType); +} + +export function formatTargetChoices( + targets: readonly TargetType[], + conjunction: 'or' | 'comma' = 'comma' +): string { + if (targets.length === 0) return ''; + if (targets.length === 1) return targets[0]; + if (conjunction === 'comma') return targets.join(', '); + if (targets.length === 2) return `${targets[0]} or ${targets[1]}`; + return `${targets.slice(0, -1).join(', ')}, or ${targets[targets.length - 1]}`; +} + +export function getPersistedTargetChoices(): string { + return formatTargetChoices(PERSISTED_TARGET_TYPES, 'or'); +} + +export function getRuntimeTargetChoices(): string { + return formatTargetChoices(RUNTIME_TARGET_TYPES, 'comma'); +} + +export function getBuiltinArgv0TargetMap(): Record { + const map: Record = {}; + for (const target of RUNTIME_TARGET_TYPES) { + for (const alias of TARGET_METADATA[target].runtimeAliases) { + map[alias] = target; + } + } + return map; +} + +export function getLegacyTargetAliasEnvVars(): Partial> { + const result: Partial> = {}; + for (const target of RUNTIME_TARGET_TYPES) { + const envVar = TARGET_METADATA[target].legacyAliasEnvVar; + if (envVar) { + result[target] = envVar; + } + } + return result; +} diff --git a/src/targets/target-resolver.ts b/src/targets/target-resolver.ts index a7a5999d..13b4d636 100644 --- a/src/targets/target-resolver.ts +++ b/src/targets/target-resolver.ts @@ -10,21 +10,24 @@ import * as path from 'path'; import { TargetType } from './target-adapter'; +import { + getBuiltinArgv0TargetMap, + getLegacyTargetAliasEnvVars, + getRuntimeTargetChoices, + isPersistedTargetType, + isRuntimeTargetType, +} from './target-metadata'; /** * Built-in argv[0] aliases for explicit runtime entrypoints. - * `ccs-droid` is the transparent alias; `ccsd` remains as a legacy shortcut. + * Droid and Codex install dedicated runtime aliases alongside the base `ccs` bin. */ -const BUILTIN_ARGV0_TARGET_MAP: Record = { - 'ccs-droid': 'droid', - ccsd: 'droid', -}; +const BUILTIN_ARGV0_TARGET_MAP: Record = getBuiltinArgv0TargetMap(); const ALIAS_NAME_REGEX = /^[a-z0-9._-]+$/; const INTERNAL_ENTRY_TARGET_ENV_VAR = 'CCS_INTERNAL_ENTRY_TARGET'; const GENERIC_TARGET_ALIAS_ENV_VAR = 'CCS_TARGET_ALIASES'; -const LEGACY_TARGET_ALIAS_ENV_VARS: Partial> = { - droid: 'CCS_DROID_ALIASES', -}; +const LEGACY_TARGET_ALIAS_ENV_VARS: Partial> = + getLegacyTargetAliasEnvVars(); const RESERVED_BIN_NAMES = new Set(['ccs', ...Object.keys(BUILTIN_ARGV0_TARGET_MAP)]); function addAliasToMap(map: Record, alias: string, target: TargetType): void { @@ -64,7 +67,7 @@ function parseGenericTargetAliasConfig(map: Record, rawConfi const rawTarget = entry.slice(0, separatorIndex).trim().toLowerCase(); const rawAliases = entry.slice(separatorIndex + 1).trim(); - if (!rawAliases || !isValidTarget(rawTarget)) { + if (!rawAliases || !isRuntimeTargetType(rawTarget)) { continue; } @@ -100,30 +103,21 @@ function resolveEntrypointTarget(): TargetType | null { } const normalizedTarget = rawTarget.trim().toLowerCase(); - return isValidTarget(normalizedTarget) ? normalizedTarget : null; + return isRuntimeTargetType(normalizedTarget) ? normalizedTarget : null; } -/** - * Valid target types for --target flag validation. - */ -const VALID_TARGETS: ReadonlySet = new Set(['claude', 'droid']); - interface ParsedTargetFlags { targetOverride?: TargetType; cleanedArgs: string[]; } -function isValidTarget(target: unknown): target is TargetType { - return typeof target === 'string' && VALID_TARGETS.has(target as TargetType); -} - function normalizeTargetValue(value: string): TargetType { const normalized = value.toLowerCase(); - if (isValidTarget(normalized)) { + if (isRuntimeTargetType(normalized)) { return normalized as TargetType; } - const available = Array.from(VALID_TARGETS).join(', '); + const available = getRuntimeTargetChoices(); throw new Error(`Unknown target "${value}". Available: ${available}`); } @@ -148,7 +142,7 @@ function parseTargetFlags(args: string[]): ParsedTargetFlags { if (arg === '--target') { const value = args[i + 1]; if (!value || value.startsWith('-')) { - throw new Error('--target requires a value (claude or droid)'); + throw new Error(`--target requires a value (${getRuntimeTargetChoices()})`); } targetOverride = normalizeTargetValue(value); i += 1; // Skip value @@ -158,7 +152,7 @@ function parseTargetFlags(args: string[]): ParsedTargetFlags { if (arg.startsWith('--target=')) { const value = arg.slice('--target='.length).trim(); if (!value) { - throw new Error('--target requires a value (claude or droid)'); + throw new Error(`--target requires a value (${getRuntimeTargetChoices()})`); } targetOverride = normalizeTargetValue(value); continue; @@ -204,7 +198,7 @@ export function resolveTargetType( // 3. Check per-profile config if (profileConfig?.target !== undefined) { - return isValidTarget(profileConfig.target) ? profileConfig.target : 'claude'; + return isPersistedTargetType(profileConfig.target) ? profileConfig.target : 'claude'; } // 4. Default diff --git a/src/targets/target-runtime-compatibility.ts b/src/targets/target-runtime-compatibility.ts new file mode 100644 index 00000000..65fd6024 --- /dev/null +++ b/src/targets/target-runtime-compatibility.ts @@ -0,0 +1,91 @@ +import type { CLIProxyProvider } from '../cliproxy/types'; +import type { ProfileType } from '../types/profile'; +import type { TargetType } from './target-adapter'; + +export interface TargetRuntimeCompatibilityInput { + target: TargetType; + profileType: ProfileType; + cliproxyProvider?: CLIProxyProvider; + cliproxyBridgeProvider?: CLIProxyProvider | null; + isComposite?: boolean; +} + +export interface TargetRuntimeCompatibilityResult { + supported: boolean; + reason?: string; + suggestion?: string; +} + +function unsupported(reason: string, suggestion?: string): TargetRuntimeCompatibilityResult { + return { supported: false, reason, suggestion }; +} + +export function evaluateTargetRuntimeCompatibility( + input: TargetRuntimeCompatibilityInput +): TargetRuntimeCompatibilityResult { + if (input.target === 'claude') { + return { supported: true }; + } + + if (input.target === 'droid') { + if (input.profileType === 'account') { + return unsupported( + 'Factory Droid does not support account-based Claude profiles.', + 'Use a settings-based profile with --target droid instead.' + ); + } + if (input.profileType === 'copilot') { + return unsupported('Factory Droid does not support Copilot profiles.'); + } + return { supported: true }; + } + + if (input.profileType === 'account') { + return unsupported( + 'Codex CLI does not support Claude account-based profiles.', + 'Use native Codex auth with: ccs --target codex' + ); + } + + if (input.profileType === 'copilot') { + return unsupported('Codex CLI does not support Copilot profiles.'); + } + + if (input.profileType === 'default') { + return { supported: true }; + } + + if (input.profileType === 'cliproxy') { + if (input.isComposite) { + return unsupported( + 'Codex CLI currently does not support composite CLIProxy variants.', + 'Use a Codex-only CLIProxy profile or stay on Claude/Droid for composite variants.' + ); + } + if (input.cliproxyProvider !== 'codex') { + return unsupported( + `Codex CLI only supports CLIProxy provider "codex". This profile routes to "${input.cliproxyProvider || 'unknown'}".`, + 'Use: ccs codex --target codex, ccs-codex codex, or stay on Claude/Droid for other providers.' + ); + } + return { supported: true }; + } + + if (input.profileType === 'settings') { + if (input.cliproxyBridgeProvider === 'codex') { + return { supported: true }; + } + if (input.cliproxyBridgeProvider) { + return unsupported( + `Codex CLI only supports CLIProxy Codex bridge profiles. This API profile bridges "${input.cliproxyBridgeProvider}".`, + 'Create a Codex bridge with: ccs api create --cliproxy-provider codex' + ); + } + return unsupported( + 'Codex CLI currently supports native default sessions and Codex-routed CLIProxy sessions only.', + 'Use Claude/Droid for generic API profiles, or create a Codex bridge with: ccs api create --cliproxy-provider codex' + ); + } + + return unsupported('Unsupported Codex runtime combination.'); +} diff --git a/tests/npm/cross-platform.test.js b/tests/npm/cross-platform.test.js index ed2c68d8..1382ed88 100644 --- a/tests/npm/cross-platform.test.js +++ b/tests/npm/cross-platform.test.js @@ -131,6 +131,8 @@ describe('cross-platform', () => { assert(packageJson.bin.ccs, 'bin field should specify ccs command'); assert(packageJson.bin['ccs-droid'], 'bin field should specify ccs-droid command'); assert(packageJson.bin.ccsd, 'bin field should specify ccsd command'); + assert(packageJson.bin['ccs-codex'], 'bin field should specify ccs-codex command'); + assert(packageJson.bin.ccsx, 'bin field should specify ccsx command'); assert.notStrictEqual( packageJson.bin['ccs-droid'], packageJson.bin.ccs, @@ -141,10 +143,24 @@ describe('cross-platform', () => { packageJson.bin.ccsd, 'legacy ccsd alias should share the dedicated droid runtime entrypoint' ); + assert.notStrictEqual( + packageJson.bin['ccs-codex'], + packageJson.bin.ccs, + 'ccs-codex should use a dedicated runtime entrypoint' + ); + assert.strictEqual( + packageJson.bin['ccs-codex'], + packageJson.bin.ccsx, + 'ccsx should share the dedicated codex runtime entrypoint' + ); assert( fs.existsSync(path.join(__dirname, '..', '..', packageJson.bin['ccs-droid'])), 'dedicated droid runtime entrypoint should exist' ); + assert( + fs.existsSync(path.join(__dirname, '..', '..', packageJson.bin['ccs-codex'])), + 'dedicated codex runtime entrypoint should exist' + ); assert(packageJson.scripts, 'package.json should have scripts field'); }); }); diff --git a/tests/unit/targets/ccsd-alias-integration.test.ts b/tests/unit/targets/ccsd-alias-integration.test.ts index 1c5c8f74..2ce98781 100644 --- a/tests/unit/targets/ccsd-alias-integration.test.ts +++ b/tests/unit/targets/ccsd-alias-integration.test.ts @@ -71,13 +71,35 @@ describe('ccsd alias integration', () => { expect(path.basename(argvPath)).toBe('ccs-droid'); }); + it('should preserve ccs-codex symlink basename in argv[1] under node', () => { + if (process.platform === 'win32') { + return; + } + + const argvPath = probeArgvPath('ccs-codex'); + expect(path.basename(argvPath)).toBe('ccs-codex'); + }); + + it('should preserve ccsx symlink basename in argv[1] under node', () => { + if (process.platform === 'win32') { + return; + } + + const argvPath = probeArgvPath('ccsx'); + expect(path.basename(argvPath)).toBe('ccsx'); + }); + it('should preserve extension-style alias basenames for wrapper compatibility', () => { const cmdArgvPath = probeArgvPathDirect('ccsd.cmd'); const ps1ArgvPath = probeArgvPathDirect('ccsd.ps1'); const explicitCmdArgvPath = probeArgvPathDirect('ccs-droid.cmd'); + const codexCmdArgvPath = probeArgvPathDirect('ccs-codex.cmd'); + const codexShortCmdArgvPath = probeArgvPathDirect('ccsx.cmd'); expect(path.basename(cmdArgvPath)).toBe('ccsd.cmd'); expect(path.basename(ps1ArgvPath)).toBe('ccsd.ps1'); expect(path.basename(explicitCmdArgvPath)).toBe('ccs-droid.cmd'); + expect(path.basename(codexCmdArgvPath)).toBe('ccs-codex.cmd'); + expect(path.basename(codexShortCmdArgvPath)).toBe('ccsx.cmd'); }); }); diff --git a/tests/unit/targets/codex-adapter.test.ts b/tests/unit/targets/codex-adapter.test.ts new file mode 100644 index 00000000..d74b384b --- /dev/null +++ b/tests/unit/targets/codex-adapter.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from 'bun:test'; + +import { CodexAdapter } from '../../../src/targets/codex-adapter'; + +describe('CodexAdapter', () => { + const adapter = new CodexAdapter(); + + test('supports default, settings, and cliproxy profile types', () => { + expect(adapter.supportsProfileType('default')).toBe(true); + expect(adapter.supportsProfileType('settings')).toBe(true); + expect(adapter.supportsProfileType('cliproxy')).toBe(true); + expect(adapter.supportsProfileType('account')).toBe(false); + expect(adapter.supportsProfileType('copilot')).toBe(false); + }); + + test('passes default-mode args through unchanged', () => { + expect( + adapter.buildArgs('default', ['--search'], { + profileType: 'default', + }) + ).toEqual(['--search']); + }); + + test('translates default-mode reasoning overrides into transient codex config', () => { + const args = adapter.buildArgs('default', ['--search'], { + profileType: 'default', + creds: { + profile: 'default', + baseUrl: '', + apiKey: '', + reasoningOverride: 'medium', + }, + }); + + expect(args).toEqual(['-c', 'model_reasoning_effort="medium"', '--search']); + }); + + test('injects transient config overrides for CCS-backed launches', () => { + const args = adapter.buildArgs('codex', ['--search'], { + profileType: 'cliproxy', + creds: { + profile: 'codex', + baseUrl: 'http://127.0.0.1:8317/api/provider/codex', + apiKey: 'cliproxy-token', + model: 'gpt-5.4', + reasoningOverride: 'high', + }, + binaryInfo: { + path: '/tmp/codex', + needsShell: false, + features: ['config-overrides'], + }, + }); + + expect(args).toContain('-c'); + expect(args).toContain('model_provider="ccs_runtime"'); + expect(args).toContain('model_providers.ccs_runtime.env_key="CCS_CODEX_API_KEY"'); + expect(args).toContain('model="gpt-5.4"'); + expect(args).toContain('model_reasoning_effort="high"'); + expect(args.at(-1)).toBe('--search'); + }); + + test('fails fast when Codex binary lacks config override support', () => { + expect(() => + adapter.buildArgs('codex', [], { + profileType: 'cliproxy', + creds: { + profile: 'codex', + baseUrl: 'http://127.0.0.1:8317/api/provider/codex', + apiKey: 'cliproxy-token', + }, + binaryInfo: { + path: '/tmp/codex', + needsShell: false, + version: 'codex-cli 0.1.0', + features: [], + }, + }) + ).toThrow(/does not advertise --config overrides/); + }); + + test('rejects native Codex provider-selection flags for CCS-backed launches', () => { + expect(() => + adapter.buildArgs('codex', ['--profile', 'other', '--search'], { + profileType: 'cliproxy', + creds: { + profile: 'codex', + baseUrl: 'http://127.0.0.1:8317/api/provider/codex', + apiKey: 'cliproxy-token', + }, + binaryInfo: { + path: '/tmp/codex', + needsShell: false, + features: ['config-overrides'], + }, + }) + ).toThrow(/does not allow --profile\/-p/); + }); + + test('rejects unsupported reasoning override values for CCS-backed launches', () => { + expect(() => + adapter.buildArgs('codex', ['--search'], { + profileType: 'cliproxy', + creds: { + profile: 'codex', + baseUrl: 'http://127.0.0.1:8317/api/provider/codex', + apiKey: 'cliproxy-token', + reasoningOverride: 8192, + }, + binaryInfo: { + path: '/tmp/codex', + needsShell: false, + features: ['config-overrides'], + }, + }) + ).toThrow(/supports reasoning levels only/); + }); + + test('injects CCS_CODEX_API_KEY for CCS-backed launches only', () => { + const settingsEnv = adapter.buildEnv( + { + profile: 'codex', + baseUrl: 'http://127.0.0.1:8317/api/provider/codex', + apiKey: 'cliproxy-token', + }, + 'cliproxy' + ); + expect(settingsEnv.CCS_CODEX_API_KEY).toBe('cliproxy-token'); + + const defaultEnv = adapter.buildEnv( + { + profile: 'default', + baseUrl: '', + apiKey: '', + }, + 'default' + ); + expect(defaultEnv.CCS_CODEX_API_KEY).toBeUndefined(); + }); +}); diff --git a/tests/unit/targets/codex-detector.test.ts b/tests/unit/targets/codex-detector.test.ts new file mode 100644 index 00000000..c392bcf7 --- /dev/null +++ b/tests/unit/targets/codex-detector.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import * as childProcess from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { detectCodexCli, getCodexBinaryInfo } from '../../../src/targets/codex-detector'; + +describe('codex-detector', () => { + let tmpDir: string; + let originalPath: string | undefined; + let originalCodexPath: string | undefined; + const originalPlatform = process.platform; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-detector-test-')); + originalPath = process.env.PATH; + originalCodexPath = process.env.CCS_CODEX_PATH; + process.env.PATH = ''; + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + + if (originalPath !== undefined) process.env.PATH = originalPath; + else delete process.env.PATH; + + if (originalCodexPath !== undefined) process.env.CCS_CODEX_PATH = originalCodexPath; + else delete process.env.CCS_CODEX_PATH; + + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should prefer CCS_CODEX_PATH when it points to a file', () => { + const fakeCodex = path.join(tmpDir, 'codex'); + fs.writeFileSync(fakeCodex, '#!/bin/sh\necho codex\n'); + process.env.CCS_CODEX_PATH = fakeCodex; + + expect(detectCodexCli()).toBe(fakeCodex); + }); + + it('should return null when CCS_CODEX_PATH points to a directory', () => { + process.env.CCS_CODEX_PATH = tmpDir; + expect(detectCodexCli()).toBeNull(); + }); + + it('should return binary info without throwing when help probing fails', () => { + const fakeCodex = path.join(tmpDir, 'codex'); + fs.writeFileSync(fakeCodex, ''); + process.env.CCS_CODEX_PATH = fakeCodex; + + expect(() => getCodexBinaryInfo()).not.toThrow(); + }); + + it('probes Windows cmd wrappers through the shell so config override support is detected', () => { + const fakeCodex = path.join(tmpDir, 'codex.cmd'); + fs.writeFileSync(fakeCodex, ''); + process.env.CCS_CODEX_PATH = fakeCodex; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + const execFileSyncSpy = spyOn(childProcess, 'execFileSync').mockImplementation((command, args) => { + return String(command).includes('cmd.exe') && Array.isArray(args) && args.join(' ').includes('--help') + ? 'Codex CLI\n -c, --config \n' + : 'codex-cli 0.118.0-alpha.3'; + }); + + const info = getCodexBinaryInfo(); + + expect(execFileSyncSpy).toHaveBeenCalled(); + expect(info?.needsShell).toBe(true); + expect(info?.features).toContain('config-overrides'); + + execFileSyncSpy.mockRestore(); + }); +}); diff --git a/tests/unit/targets/codex-settings-bridge-launch.test.ts b/tests/unit/targets/codex-settings-bridge-launch.test.ts new file mode 100644 index 00000000..814d4ce5 --- /dev/null +++ b/tests/unit/targets/codex-settings-bridge-launch.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +interface RunResult { + status: number | null; + stdout: string; + stderr: string; +} + +function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult { + const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts'); + const result = spawnSync(process.execPath, [ccsEntry, ...args], { + encoding: 'utf8', + env, + timeout: 20000, + }); + + return { + status: result.status, + stdout: result.stdout || '', + stderr: result.stderr || '', + }; +} + +describe('Codex settings bridge launch', () => { + let tmpHome = ''; + let ccsDir = ''; + let settingsPath = ''; + let fakeCodexPath = ''; + let codexArgsLogPath = ''; + let codexEnvLogPath = ''; + let baseEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + if (process.platform === 'win32') { + return; + } + + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-bridge-launch-')); + ccsDir = path.join(tmpHome, '.ccs'); + settingsPath = path.join(ccsDir, 'codex-api.settings.json'); + fakeCodexPath = path.join(tmpHome, 'fake-codex.sh'); + codexArgsLogPath = path.join(tmpHome, 'codex-args.txt'); + codexEnvLogPath = path.join(tmpHome, 'codex-env.txt'); + + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { 'codex-api': settingsPath } }, null, 2) + '\n' + ); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'bridge-token', + ANTHROPIC_MODEL: 'gpt-5.3-codex', + }, + }, + null, + 2 + ) + '\n' + ); + fs.writeFileSync( + fakeCodexPath, + `#!/bin/sh +if [ "$1" = "--version" ]; then + echo "codex-cli 0.118.0-alpha.3" + exit 0 +fi + +if [ "$1" = "--help" ]; then + cat <<'EOF' +Codex CLI + -c, --config +EOF + exit 0 +fi + +printf "%s\\n" "$@" > "${codexArgsLogPath}" +printf "%s" "$CCS_CODEX_API_KEY" > "${codexEnvLogPath}" +exit 0 +`, + { encoding: 'utf8', mode: 0o755 } + ); + fs.chmodSync(fakeCodexPath, 0o755); + + baseEnv = { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_DEBUG: '1', + }; + }); + + afterEach(() => { + if (process.platform === 'win32') { + return; + } + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it('launches Codex bridge settings profiles and injects runtime overrides', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['codex-api', '--target', 'codex', '--effort', 'high', 'smoke'], baseEnv); + + expect(result.status).toBe(0); + expect(result.stderr).not.toContain('does not support this profile'); + + const argsLog = fs.readFileSync(codexArgsLogPath, 'utf8'); + expect(argsLog).toContain('model_provider="ccs_runtime"'); + expect(argsLog).toContain('model_providers.ccs_runtime.base_url="http://127.0.0.1:8317/api/provider/codex"'); + expect(argsLog).toContain('model_reasoning_effort="high"'); + expect(argsLog).toContain('smoke'); + expect(fs.readFileSync(codexEnvLogPath, 'utf8')).toBe('bridge-token'); + }); + + it('rejects native Codex profile flags when CCS manages the bridge runtime', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['codex-api', '--target', 'codex', '--profile', 'other', 'smoke'], baseEnv); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('does not allow --profile/-p'); + expect(fs.existsSync(codexArgsLogPath)).toBe(false); + }); +}); diff --git a/tests/unit/targets/target-registry.test.ts b/tests/unit/targets/target-registry.test.ts index 046036b6..634c199a 100644 --- a/tests/unit/targets/target-registry.test.ts +++ b/tests/unit/targets/target-registry.test.ts @@ -13,6 +13,7 @@ import { getRegisteredTargets, ClaudeAdapter, DroidAdapter, + CodexAdapter, } from '../../../src/targets'; describe('target-registry', () => { @@ -20,6 +21,7 @@ describe('target-registry', () => { // Re-register adapters (registry is module-scoped singleton) registerTarget(new ClaudeAdapter()); registerTarget(new DroidAdapter()); + registerTarget(new CodexAdapter()); }); it('should register and retrieve claude adapter', () => { @@ -39,6 +41,12 @@ describe('target-registry', () => { expect(adapter.type).toBe('claude'); }); + it('should register and retrieve codex adapter', () => { + const adapter = getTarget('codex'); + expect(adapter.type).toBe('codex'); + expect(adapter.displayName).toBe('Codex CLI'); + }); + it('should throw for unknown target', () => { expect(() => getTarget('unknown' as never)).toThrow(/Unknown target "unknown"/); }); @@ -46,6 +54,7 @@ describe('target-registry', () => { it('should check target existence', () => { expect(hasTarget('claude')).toBe(true); expect(hasTarget('droid')).toBe(true); + expect(hasTarget('codex')).toBe(true); expect(hasTarget('unknown' as never)).toBe(false); }); @@ -53,6 +62,7 @@ describe('target-registry', () => { const targets = getRegisteredTargets(); expect(targets).toContain('claude'); expect(targets).toContain('droid'); + expect(targets).toContain('codex'); }); }); diff --git a/tests/unit/targets/target-resolver.test.ts b/tests/unit/targets/target-resolver.test.ts index 6e60a177..68299433 100644 --- a/tests/unit/targets/target-resolver.test.ts +++ b/tests/unit/targets/target-resolver.test.ts @@ -7,6 +7,7 @@ import { resolveTargetType, stripTargetFlag } from '../../../src/targets/target- describe('resolveTargetType', () => { const originalArgv = process.argv; const originalDroidAliases = process.env.CCS_DROID_ALIASES; + const originalCodexAliases = process.env.CCS_CODEX_ALIASES; const originalTargetAliases = process.env.CCS_TARGET_ALIASES; const originalInternalEntryTarget = process.env.CCS_INTERNAL_ENTRY_TARGET; @@ -18,6 +19,12 @@ describe('resolveTargetType', () => { process.env.CCS_DROID_ALIASES = originalDroidAliases; } + if (originalCodexAliases === undefined) { + delete process.env.CCS_CODEX_ALIASES; + } else { + process.env.CCS_CODEX_ALIASES = originalCodexAliases; + } + if (originalTargetAliases === undefined) { delete process.env.CCS_TARGET_ALIASES; } else { @@ -41,6 +48,11 @@ describe('resolveTargetType', () => { expect(resolveTargetType(['--target', 'droid'])).toBe('droid'); }); + it('should detect --target codex', () => { + process.argv = ['node', 'ccs']; + expect(resolveTargetType(['--target', 'codex'])).toBe('codex'); + }); + it('should detect --target claude', () => { process.argv = ['node', 'ccs']; expect(resolveTargetType(['--target', 'claude'])).toBe('claude'); @@ -56,6 +68,11 @@ describe('resolveTargetType', () => { expect(resolveTargetType([], { target: 'invalid-target' as never })).toBe('claude'); }); + it('should ignore runtime-only codex target when it appears in persisted profile config', () => { + process.argv = ['node', 'ccs']; + expect(resolveTargetType([], { target: 'codex' })).toBe('claude'); + }); + it('should prioritize --target flag over profile config', () => { process.argv = ['node', 'ccs']; expect(resolveTargetType(['--target', 'claude'], { target: 'droid' })).toBe('claude'); @@ -71,15 +88,31 @@ describe('resolveTargetType', () => { expect(resolveTargetType([])).toBe('droid'); }); + it('should detect built-in ccs-codex argv[0] alias', () => { + process.argv = ['node', 'ccs-codex']; + expect(resolveTargetType([])).toBe('codex'); + }); + + it('should detect built-in ccsx argv[0] alias', () => { + process.argv = ['node', 'ccsx']; + expect(resolveTargetType([])).toBe('codex'); + }); + it('should detect custom target aliases from CCS_TARGET_ALIASES', () => { process.env.CCS_TARGET_ALIASES = 'droid=droidx,my-droid'; process.argv = ['node', 'my-droid']; expect(resolveTargetType([])).toBe('droid'); }); + it('should detect codex aliases from CCS_TARGET_ALIASES', () => { + process.env.CCS_TARGET_ALIASES = 'codex=codexx,team-codex'; + process.argv = ['node', 'team-codex']; + expect(resolveTargetType([])).toBe('codex'); + }); + it('should ignore unsupported targets in CCS_TARGET_ALIASES', () => { - process.env.CCS_TARGET_ALIASES = 'codex=ccsx;droid=ccs-droid-custom'; - process.argv = ['node', 'ccsx']; + process.env.CCS_TARGET_ALIASES = 'not-a-target=mystery-codex;droid=ccs-droid-custom'; + process.argv = ['node', 'mystery-codex']; expect(resolveTargetType([])).toBe('claude'); }); @@ -89,6 +122,12 @@ describe('resolveTargetType', () => { expect(resolveTargetType([])).toBe('droid'); }); + it('should detect custom argv[0] aliases from CCS_CODEX_ALIASES', () => { + process.env.CCS_CODEX_ALIASES = 'codexx,my-codex'; + process.argv = ['node', 'my-codex']; + expect(resolveTargetType([])).toBe('codex'); + }); + it('should merge CCS_TARGET_ALIASES and CCS_DROID_ALIASES', () => { process.env.CCS_TARGET_ALIASES = 'droid=team-droid'; process.env.CCS_DROID_ALIASES = 'legacy-droid'; @@ -100,6 +139,17 @@ describe('resolveTargetType', () => { expect(resolveTargetType([])).toBe('droid'); }); + it('should merge CCS_TARGET_ALIASES and CCS_CODEX_ALIASES', () => { + process.env.CCS_TARGET_ALIASES = 'codex=team-codex'; + process.env.CCS_CODEX_ALIASES = 'legacy-codex'; + + process.argv = ['node', 'team-codex']; + expect(resolveTargetType([])).toBe('codex'); + + process.argv = ['node', 'legacy-codex']; + expect(resolveTargetType([])).toBe('codex'); + }); + it('should ignore invalid custom alias entries', () => { process.env.CCS_DROID_ALIASES = 'valid_alias,../bad,'; process.argv = ['node', '../bad']; @@ -112,6 +162,12 @@ describe('resolveTargetType', () => { expect(resolveTargetType([])).toBe('droid'); }); + it('should detect internal entry target for codex runtime bins', () => { + process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex'; + process.argv = ['node', 'ccs']; + expect(resolveTargetType([])).toBe('codex'); + }); + it('should normalize argv[0] and custom aliases case-insensitively', () => { process.env.CCS_DROID_ALIASES = 'DroidCaps'; process.argv = ['node', 'DROIDCAPS']; @@ -128,11 +184,21 @@ describe('resolveTargetType', () => { expect(resolveTargetType([])).toBe('droid'); }); + it('should strip .cmd extension on built-in codex alias', () => { + process.argv = ['node', 'ccs-codex.cmd']; + expect(resolveTargetType([])).toBe('codex'); + }); + it('should strip .bat extension on Windows argv[0]', () => { process.argv = ['node', 'ccsd.bat']; expect(resolveTargetType([])).toBe('droid'); }); + it('should strip .bat extension on codex shortcut alias', () => { + process.argv = ['node', 'ccsx.bat']; + expect(resolveTargetType([])).toBe('codex'); + }); + it('should strip .ps1 extension on Windows argv[0]', () => { process.argv = ['node', 'ccsd.ps1']; expect(resolveTargetType([])).toBe('droid'); @@ -153,6 +219,11 @@ describe('resolveTargetType', () => { expect(resolveTargetType([])).toBe('droid'); }); + it('should handle full path argv[0] for ccs-codex', () => { + process.argv = ['node', '/usr/local/bin/ccs-codex']; + expect(resolveTargetType([])).toBe('codex'); + }); + it('should prioritize --target over argv[0]', () => { process.argv = ['node', 'ccsd']; expect(resolveTargetType(['--target', 'claude'])).toBe('claude'); @@ -176,8 +247,10 @@ describe('resolveTargetType', () => { }); it('should keep reserved command names authoritative', () => { - process.env.CCS_TARGET_ALIASES = 'claude=ccs,ccs-droid,ccsd;droid=mydroid'; + process.env.CCS_TARGET_ALIASES = + 'claude=ccs,ccs-droid,ccsd,ccs-codex,ccsx;droid=mydroid;codex=mycodex'; process.env.CCS_DROID_ALIASES = 'ccs,ccs-droid,ccsd,legacy-droid'; + process.env.CCS_CODEX_ALIASES = 'ccs,ccs-codex,ccsx,legacy-codex'; process.argv = ['node', 'ccs']; expect(resolveTargetType([])).toBe('claude'); @@ -188,11 +261,23 @@ describe('resolveTargetType', () => { process.argv = ['node', 'ccsd']; expect(resolveTargetType([])).toBe('droid'); + process.argv = ['node', 'ccs-codex']; + expect(resolveTargetType([])).toBe('codex'); + + process.argv = ['node', 'ccsx']; + expect(resolveTargetType([])).toBe('codex'); + process.argv = ['node', 'mydroid']; expect(resolveTargetType([])).toBe('droid'); process.argv = ['node', 'legacy-droid']; expect(resolveTargetType([])).toBe('droid'); + + process.argv = ['node', 'mycodex']; + expect(resolveTargetType([])).toBe('codex'); + + process.argv = ['node', 'legacy-codex']; + expect(resolveTargetType([])).toBe('codex'); }); it('should throw for invalid --target value', () => { diff --git a/tests/unit/targets/target-runtime-compatibility.test.ts b/tests/unit/targets/target-runtime-compatibility.test.ts new file mode 100644 index 00000000..cc169ec6 --- /dev/null +++ b/tests/unit/targets/target-runtime-compatibility.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test'; + +import { evaluateTargetRuntimeCompatibility } from '../../../src/targets/target-runtime-compatibility'; + +describe('evaluateTargetRuntimeCompatibility', () => { + test('supports native Codex default sessions', () => { + expect( + evaluateTargetRuntimeCompatibility({ + target: 'codex', + profileType: 'default', + }).supported + ).toBe(true); + }); + + test('supports Codex CLIProxy provider sessions only for provider codex', () => { + expect( + evaluateTargetRuntimeCompatibility({ + target: 'codex', + profileType: 'cliproxy', + cliproxyProvider: 'codex', + isComposite: false, + }).supported + ).toBe(true); + + const unsupported = evaluateTargetRuntimeCompatibility({ + target: 'codex', + profileType: 'cliproxy', + cliproxyProvider: 'gemini', + isComposite: false, + }); + expect(unsupported.supported).toBe(false); + expect(unsupported.reason).toMatch(/only supports CLIProxy provider "codex"/); + }); + + test('rejects composite CLIProxy variants on Codex target', () => { + const compatibility = evaluateTargetRuntimeCompatibility({ + target: 'codex', + profileType: 'cliproxy', + cliproxyProvider: 'codex', + isComposite: true, + }); + + expect(compatibility.supported).toBe(false); + expect(compatibility.reason).toMatch(/does not support composite CLIProxy variants/); + }); + + test('supports only Codex bridge API profiles on Codex target', () => { + expect( + evaluateTargetRuntimeCompatibility({ + target: 'codex', + profileType: 'settings', + cliproxyBridgeProvider: 'codex', + }).supported + ).toBe(true); + + const compatibility = evaluateTargetRuntimeCompatibility({ + target: 'codex', + profileType: 'settings', + cliproxyBridgeProvider: 'gemini', + }); + expect(compatibility.supported).toBe(false); + expect(compatibility.reason).toMatch(/only supports CLIProxy Codex bridge profiles/); + }); + + test('rejects account and copilot profiles on Codex target', () => { + expect( + evaluateTargetRuntimeCompatibility({ + target: 'codex', + profileType: 'account', + }).supported + ).toBe(false); + expect( + evaluateTargetRuntimeCompatibility({ + target: 'codex', + profileType: 'copilot', + }).supported + ).toBe(false); + }); +});