mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
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:
+3
-1
@@ -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/",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
|
||||
require('../ccs');
|
||||
+88
-24
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user