mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-18 14:13:21 +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:
1 parent
9307bb857f
commit
8f60820f33
18 files changed
+1254
-54
No files matched your search
@@ -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.');
|
||||
}
|
||||
Reference in new issue
Block a user