feat(targets): add native codex runtime target

- add a Codex adapter, detector, runtime aliases, and compatibility matrix

- keep Codex runtime-only while preserving persisted targets for claude and droid

- cover Codex launch, reasoning, wrapper detection, and bridge routing regressions

Refs #773
This commit is contained in:
Tam Nhu Tran
2026-03-29 13:14:15 -04:00
parent 9307bb857f
commit 8f60820f33
18 changed files with 1254 additions and 54 deletions
+3 -1
View File
@@ -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/",
+2
View File
@@ -0,0 +1,2 @@
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
require('../ccs');
+88 -24
View File
@@ -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<void> {
// 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<void> {
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<void> {
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<void> {
}
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<void> {
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<void> {
}
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 || '<first-flag>'}`
)
);
}
} 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<void> {
baseUrl: envVars['ANTHROPIC_BASE_URL'],
model: envVars['ANTHROPIC_MODEL'],
}),
reasoningOverride: droidReasoningOverride,
reasoningOverride: runtimeReasoningOverride,
envVars,
};
@@ -643,7 +675,11 @@ async function main(): Promise<void> {
}
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<void> {
? 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<void> {
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<void> {
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<void> {
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;
+234
View File
@@ -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<string>();
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<void> {
// 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';
}
}
+138
View File
@@ -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 <key=value>') ? [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));
}
+16
View File
@@ -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';
+12 -2
View File
@@ -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.
+87
View File
@@ -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<TargetType, TargetMetadata> = {
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<TargetType>(RUNTIME_TARGET_TYPES);
const PERSISTED_TARGET_SET = new Set<TargetType>(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<string, TargetType> {
const map: Record<string, TargetType> = {};
for (const target of RUNTIME_TARGET_TYPES) {
for (const alias of TARGET_METADATA[target].runtimeAliases) {
map[alias] = target;
}
}
return map;
}
export function getLegacyTargetAliasEnvVars(): Partial<Record<TargetType, string>> {
const result: Partial<Record<TargetType, string>> = {};
for (const target of RUNTIME_TARGET_TYPES) {
const envVar = TARGET_METADATA[target].legacyAliasEnvVar;
if (envVar) {
result[target] = envVar;
}
}
return result;
}
+18 -24
View File
@@ -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<string, TargetType> = {
'ccs-droid': 'droid',
ccsd: 'droid',
};
const BUILTIN_ARGV0_TARGET_MAP: Record<string, TargetType> = 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<Record<TargetType, string>> = {
droid: 'CCS_DROID_ALIASES',
};
const LEGACY_TARGET_ALIAS_ENV_VARS: Partial<Record<TargetType, string>> =
getLegacyTargetAliasEnvVars();
const RESERVED_BIN_NAMES = new Set<string>(['ccs', ...Object.keys(BUILTIN_ARGV0_TARGET_MAP)]);
function addAliasToMap(map: Record<string, TargetType>, alias: string, target: TargetType): void {
@@ -64,7 +67,7 @@ function parseGenericTargetAliasConfig(map: Record<string, TargetType>, 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<string> = new Set<TargetType>(['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
@@ -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.');
}
+16
View File
@@ -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');
});
});
@@ -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');
});
});
+140
View File
@@ -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();
});
});
+75
View File
@@ -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 <key=value>\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();
});
});
@@ -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 <key=value>
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);
});
});
@@ -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');
});
});
+88 -3
View File
@@ -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', () => {
@@ -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);
});
});