feat(targets): add native codex runtime target

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

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

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

Refs #773
This commit is contained in:
Tam Nhu Tran committed 2026-03-29 13:14:15 -04:00
1 parent 9307bb857f
commit 8f60820f33
18 files changed
+1254 -54

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
require('../ccs');
+88 -24
View File
@@ -56,6 +56,8 @@ import {
getTarget,
ClaudeAdapter,
DroidAdapter,
CodexAdapter,
evaluateTargetRuntimeCompatibility,
pruneOrphanedModels,
resolveDroidProvider,
type TargetCredentials,
@@ -66,6 +68,7 @@ import {
resolveDroidReasoningRuntime,
} from './targets/droid-reasoning-runtime';
import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router';
import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge';
// Version and Update check utilities
import { getVersion } from './utils/version';
@@ -183,6 +186,7 @@ async function main(): Promise<void> {
// Register target adapters
registerTarget(new ClaudeAdapter());
registerTarget(new DroidAdapter());
registerTarget(new CodexAdapter());
const args = process.argv.slice(2);
@@ -381,21 +385,25 @@ async function main(): Promise<void> {
process.exit(1);
}
if (profileInfo.type === 'cliproxy' && !targetAdapter.supportsProfileType('cliproxy')) {
console.error(fail(`${targetAdapter.displayName} does not support CLIProxy profiles`));
console.error(info('Use a settings-based profile with --target instead'));
process.exit(1);
}
if (profileInfo.type === 'copilot' && !targetAdapter.supportsProfileType('copilot')) {
console.error(fail(`${targetAdapter.displayName} does not support Copilot profiles`));
process.exit(1);
}
if (profileInfo.type === 'account' && !targetAdapter.supportsProfileType('account')) {
console.error(fail(`${targetAdapter.displayName} does not support account-based profiles`));
console.error(info('Use a settings-based profile with --target instead'));
process.exit(1);
if (profileInfo.type !== 'settings') {
const compatibility = evaluateTargetRuntimeCompatibility({
target: resolvedTarget,
profileType: profileInfo.type,
cliproxyProvider: profileInfo.type === 'cliproxy' ? profileInfo.provider : undefined,
isComposite:
profileInfo.type === 'cliproxy' ? Boolean(profileInfo.isComposite) : undefined,
});
if (!compatibility.supported) {
console.error(
fail(
compatibility.reason || `${targetAdapter.displayName} does not support this profile.`
)
);
if (compatibility.suggestion) {
console.error(info(compatibility.suggestion));
}
process.exit(1);
}
}
if (profileInfo.type === 'default') {
@@ -428,6 +436,8 @@ async function main(): Promise<void> {
console.error(fail(`${displayName} CLI not found.`));
if (resolvedTarget === 'droid') {
console.error(info('Install: npm i -g @factory/cli'));
} else if (resolvedTarget === 'codex') {
console.error(info('Install a recent @openai/codex build, then retry.'));
}
process.exit(1);
}
@@ -446,7 +456,7 @@ async function main(): Promise<void> {
}
let targetRemainingArgs = remainingArgs;
let droidReasoningOverride: string | number | undefined;
let runtimeReasoningOverride: string | number | undefined;
if (resolvedTarget === 'droid') {
try {
const droidRoute = routeDroidCommandArgs(remainingArgs);
@@ -455,7 +465,7 @@ async function main(): Promise<void> {
if (droidRoute.mode === 'interactive') {
const runtime = resolveDroidReasoningRuntime(remainingArgs, process.env.CCS_THINKING);
targetRemainingArgs = runtime.argsWithoutReasoningFlags;
droidReasoningOverride = runtime.reasoningOverride;
runtimeReasoningOverride = runtime.reasoningOverride;
if (runtime.duplicateDisplays.length > 0) {
console.error(
@@ -488,6 +498,28 @@ async function main(): Promise<void> {
}
throw error;
}
} else if (resolvedTarget === 'codex') {
try {
const runtime = resolveDroidReasoningRuntime(remainingArgs, process.env.CCS_THINKING);
targetRemainingArgs = runtime.argsWithoutReasoningFlags;
runtimeReasoningOverride = runtime.reasoningOverride;
if (runtime.duplicateDisplays.length > 0) {
console.error(
warn(
`[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || '<first-flag>'}`
)
);
}
} catch (error) {
if (error instanceof DroidReasoningFlagError) {
console.error(fail(error.message));
console.error(' Examples: --thinking low, --thinking 8192, --thinking off');
console.error(' Codex alias: --effort minimal|low|medium|high|xhigh');
process.exit(1);
}
throw error;
}
}
// Special case: headless delegation (-p/--prompt)
@@ -625,7 +657,7 @@ async function main(): Promise<void> {
baseUrl: envVars['ANTHROPIC_BASE_URL'],
model: envVars['ANTHROPIC_MODEL'],
}),
reasoningOverride: droidReasoningOverride,
reasoningOverride: runtimeReasoningOverride,
envVars,
};
@@ -643,7 +675,11 @@ async function main(): Promise<void> {
}
await adapter.prepareCredentials(creds);
const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs);
const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs, {
creds,
profileType: profileInfo.type,
binaryInfo: targetBinaryInfo || undefined,
});
const targetEnv = adapter.buildEnv(creds, profileInfo.type);
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
return;
@@ -722,6 +758,26 @@ async function main(): Promise<void> {
? expandPath(profileInfo.settingsPath)
: getSettingsPath(profileInfo.name);
const settings = loadSettings(expandedSettingsPath);
const cliproxyBridge = resolveCliproxyBridgeMetadata(settings);
if (resolvedTarget !== 'claude') {
const compatibility = evaluateTargetRuntimeCompatibility({
target: resolvedTarget,
profileType: profileInfo.type,
cliproxyBridgeProvider: cliproxyBridge?.provider ?? null,
});
if (!compatibility.supported) {
console.error(
fail(
compatibility.reason ||
`${targetAdapter?.displayName || resolvedTarget} does not support this profile.`
)
);
if (compatibility.suggestion) {
console.error(info(compatibility.suggestion));
}
process.exit(1);
}
}
const rawSettingsEnv = profileInfo.env ?? settings.env ?? {};
const isDeprecatedGlmtProfile = isDeprecatedGlmtProfileName(profileInfo.name);
const glmtNormalization = isDeprecatedGlmtProfile
@@ -841,11 +897,15 @@ async function main(): Promise<void> {
baseUrl: directAnthropicBaseUrl,
model: settingsEnv['ANTHROPIC_MODEL'],
}),
reasoningOverride: droidReasoningOverride,
reasoningOverride: runtimeReasoningOverride,
envVars,
};
await adapter.prepareCredentials(creds);
const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs);
const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs, {
creds,
profileType: profileInfo.type,
binaryInfo: targetBinaryInfo || undefined,
});
const targetEnv = adapter.buildEnv(creds, profileInfo.type);
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
return;
@@ -938,9 +998,9 @@ async function main(): Promise<void> {
baseUrl: process.env['ANTHROPIC_BASE_URL'],
model: process.env['ANTHROPIC_MODEL'],
}),
reasoningOverride: droidReasoningOverride,
reasoningOverride: runtimeReasoningOverride,
};
if (!creds.baseUrl || !creds.apiKey) {
if (resolvedTarget === 'droid' && (!creds.baseUrl || !creds.apiKey)) {
console.error(
fail(
`${adapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN`
@@ -950,7 +1010,11 @@ async function main(): Promise<void> {
process.exit(1);
}
await adapter.prepareCredentials(creds);
const targetArgs = adapter.buildArgs('default', targetRemainingArgs);
const targetArgs = adapter.buildArgs('default', targetRemainingArgs, {
creds,
profileType: 'default',
binaryInfo: targetBinaryInfo || undefined,
});
const targetEnv = adapter.buildEnv(creds, 'default');
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
return;
+234
View File
@@ -0,0 +1,234 @@
import { ChildProcess, spawn } from 'child_process';
import * as fs from 'fs';
import type { ProfileType } from '../types/profile';
import { runCleanup } from '../errors';
import { wireChildProcessSignals } from '../utils/signal-forwarder';
import { escapeShellArg, stripAnthropicEnv } from '../utils/shell-executor';
import type {
TargetAdapter,
TargetBinaryInfo,
TargetCredentials,
TargetType,
} from './target-adapter';
import {
codexBinarySupportsConfigOverrides,
detectCodexCli,
getCodexBinaryInfo,
} from './codex-detector';
const CODEX_RUNTIME_PROVIDER_ID = 'ccs_runtime';
const CODEX_RUNTIME_ENV_KEY = 'CCS_CODEX_API_KEY';
const CODEX_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']);
function formatTomlString(value: string): string {
return JSON.stringify(value);
}
function buildConfigOverrideArgs(overrides: string[]): string[] {
return overrides.flatMap((override) => ['-c', override]);
}
function findDisallowedCodexManagedFlags(args: string[]): string[] {
const disallowed = new Set<string>();
for (const arg of args) {
if (arg === '-c' || arg === '--config' || arg.startsWith('--config=')) {
disallowed.add('--config/-c');
continue;
}
if (arg === '-p' || arg === '--profile' || arg.startsWith('--profile=')) {
disallowed.add('--profile/-p');
continue;
}
if (arg === '--oss') {
disallowed.add('--oss');
continue;
}
if (arg === '--local-provider' || arg.startsWith('--local-provider=')) {
disallowed.add('--local-provider');
}
}
return [...disallowed];
}
function normalizeCodexReasoningOverride(value: string | number | undefined): string | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value === 'string' && CODEX_REASONING_LEVELS.has(value)) {
return value;
}
throw new Error(
'Codex target supports reasoning levels only: minimal, low, medium, high, xhigh.'
);
}
export class CodexAdapter implements TargetAdapter {
readonly type: TargetType = 'codex';
readonly displayName = 'Codex CLI';
detectBinary(): TargetBinaryInfo | null {
return getCodexBinaryInfo();
}
async prepareCredentials(_creds: TargetCredentials): Promise<void> {
// Codex uses transient -c overrides plus env_key injection.
}
buildArgs(
_profile: string,
userArgs: string[],
options?: {
creds?: TargetCredentials;
profileType?: ProfileType;
binaryInfo?: TargetBinaryInfo;
}
): string[] {
const profileType = options?.profileType || 'default';
const creds = options?.creds;
const reasoningOverride = normalizeCodexReasoningOverride(creds?.reasoningOverride);
if (profileType === 'default') {
if (reasoningOverride) {
return [
...buildConfigOverrideArgs([
`model_reasoning_effort=${formatTomlString(reasoningOverride)}`,
]),
...userArgs,
];
}
return userArgs;
}
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
const versionSummary = options?.binaryInfo?.version ? ` (${options.binaryInfo.version})` : '';
throw new Error(
`Codex CLI${versionSummary} does not advertise --config overrides. Upgrade Codex before using CCS-backed Codex profiles.`
);
}
if (!creds?.baseUrl?.trim() || !creds.apiKey?.trim()) {
throw new Error(
'Codex target requires base URL and API key for CCS-backed profile launches.'
);
}
const disallowedFlags = findDisallowedCodexManagedFlags(userArgs);
if (disallowedFlags.length > 0) {
throw new Error(
`Codex target does not allow ${disallowedFlags.join(', ')} when CCS manages the runtime provider. Remove native Codex provider selection flags and retry.`
);
}
const overrides = [
`model_provider=${formatTomlString(CODEX_RUNTIME_PROVIDER_ID)}`,
`model_providers.${CODEX_RUNTIME_PROVIDER_ID}.name=${formatTomlString('CCS Runtime')}`,
`model_providers.${CODEX_RUNTIME_PROVIDER_ID}.base_url=${formatTomlString(creds.baseUrl)}`,
`model_providers.${CODEX_RUNTIME_PROVIDER_ID}.env_key=${formatTomlString(CODEX_RUNTIME_ENV_KEY)}`,
`model_providers.${CODEX_RUNTIME_PROVIDER_ID}.wire_api=${formatTomlString('responses')}`,
];
if (creds.model?.trim()) {
overrides.push(`model=${formatTomlString(creds.model)}`);
}
if (reasoningOverride) {
overrides.push(`model_reasoning_effort=${formatTomlString(reasoningOverride)}`);
}
return [...buildConfigOverrideArgs(overrides), ...userArgs];
}
buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...stripAnthropicEnv(process.env) };
delete env[CODEX_RUNTIME_ENV_KEY];
if (profileType !== 'default') {
if (!creds.apiKey?.trim()) {
throw new Error('Codex target requires an API key for CCS-backed profile launches.');
}
env[CODEX_RUNTIME_ENV_KEY] = creds.apiKey;
}
return env;
}
exec(
args: string[],
env: NodeJS.ProcessEnv,
options?: { cwd?: string; binaryInfo?: TargetBinaryInfo }
): void {
const exitWithCleanup = (code: number): never => {
try {
runCleanup();
} catch {
// Cleanup is best-effort on launch errors.
}
process.exit(code);
};
const codexPath = options?.binaryInfo?.path || detectCodexCli();
if (!codexPath) {
console.error('[X] Codex CLI not found. Install a recent @openai/codex build first.');
return exitWithCleanup(1);
}
try {
const stat = fs.statSync(codexPath);
if (!stat.isFile()) {
console.error(`[X] Codex CLI path is not a file: ${codexPath}`);
return exitWithCleanup(1);
}
} catch (err) {
const error = err as NodeJS.ErrnoException;
console.error(
`[X] Codex CLI path is not accessible (${error.code || 'unknown'}): ${codexPath}`
);
return exitWithCleanup(1);
}
const isWindows = process.platform === 'win32';
const isPowerShellScript = isWindows && /\.ps1$/i.test(codexPath);
const needsShell = isWindows && /\.(cmd|bat)$/i.test(codexPath);
let child: ChildProcess;
if (isPowerShellScript) {
child = spawn(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', codexPath, ...args],
{ stdio: 'inherit', windowsHide: true, env }
);
} else if (needsShell) {
const cmdString = [codexPath, ...args].map(escapeShellArg).join(' ');
child = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: true,
env,
});
} else {
child = spawn(codexPath, args, { stdio: 'inherit', windowsHide: true, env });
}
wireChildProcessSignals(child, (err: NodeJS.ErrnoException) => {
if (err.code === 'EACCES') {
console.error(`[X] Codex CLI is not executable: ${codexPath}`);
console.error(' Check file permissions and executable bit.');
} else if (err.code === 'ENOENT') {
if (isPowerShellScript) {
console.error('[X] PowerShell executable not found (required for .ps1 wrapper launch).');
} else if (needsShell) {
console.error('[X] Windows command shell not found for Codex wrapper launch.');
} else {
console.error(`[X] Codex CLI not found: ${codexPath}`);
}
} else {
console.error(`[X] Failed to start Codex CLI (${codexPath}): ${err.message}`);
}
return exitWithCleanup(1);
});
}
supportsProfileType(profileType: ProfileType): boolean {
return profileType === 'default' || profileType === 'settings' || profileType === 'cliproxy';
}
}
+138
View File
@@ -0,0 +1,138 @@
import * as fs from 'fs';
import * as childProcess from 'child_process';
import { expandPath } from '../utils/helpers';
import { escapeShellArg } from '../utils/shell-executor';
import type { TargetBinaryInfo } from './target-adapter';
const CODEX_CONFIG_OVERRIDE_FEATURE = 'config-overrides';
function runCodexProbe(codexPath: string, args: string[]): string | undefined {
const isWindows = process.platform === 'win32';
const isPowerShellScript = isWindows && /\.ps1$/i.test(codexPath);
const needsShell = isWindows && /\.(cmd|bat)$/i.test(codexPath);
try {
if (isPowerShellScript) {
return childProcess.execFileSync(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', codexPath, ...args],
{
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
windowsHide: true,
}
);
}
if (needsShell) {
const cmdString = [codexPath, ...args].map(escapeShellArg).join(' ');
return childProcess.execFileSync('cmd.exe', ['/d', '/s', '/c', cmdString], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
windowsHide: true,
});
}
return childProcess.execFileSync(codexPath, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
});
} catch {
return undefined;
}
}
function readCodexVersion(codexPath: string): string | undefined {
return runCodexProbe(codexPath, ['--version'])?.trim();
}
function detectCodexFeatures(codexPath: string): readonly string[] {
const helpText = runCodexProbe(codexPath, ['--help']);
return helpText?.includes('--config <key=value>') ? [CODEX_CONFIG_OVERRIDE_FEATURE] : [];
}
export function detectCodexCli(): string | null {
if (process.env.CCS_CODEX_PATH) {
const customPath = expandPath(process.env.CCS_CODEX_PATH);
try {
if (fs.statSync(customPath).isFile()) {
return customPath;
}
console.warn('[!] CCS_CODEX_PATH points to a directory, not a file:', customPath);
console.warn(' Refusing PATH fallback while CCS_CODEX_PATH is explicitly set.');
return null;
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code === 'ENOENT') {
console.warn('[!] Warning: CCS_CODEX_PATH is set but file not found:', customPath);
} else {
console.warn(
`[!] Warning: CCS_CODEX_PATH is not accessible (${error.code || 'unknown error'}):`,
customPath
);
}
console.warn(' Refusing PATH fallback while CCS_CODEX_PATH is explicitly set.');
return null;
}
}
const isWindows = process.platform === 'win32';
try {
const cmd = isWindows ? 'where.exe codex' : 'which codex';
const result = childProcess
.execSync(cmd, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
})
.trim();
const matches = result
.split('\n')
.map((entry) => entry.trim())
.filter(Boolean);
const candidates = isWindows
? [
...matches.filter((entry) => /\.(exe|cmd|bat|ps1)$/i.test(entry)),
...matches.filter((entry) => !/\.(exe|cmd|bat|ps1)$/i.test(entry)),
]
: matches;
for (const candidate of candidates) {
try {
if (fs.statSync(candidate).isFile()) {
return candidate;
}
} catch {
// Ignore disappearing PATH candidates.
}
}
} catch {
// codex not in PATH
}
return null;
}
export function getCodexBinaryInfo(): TargetBinaryInfo | null {
const codexPath = detectCodexCli();
if (!codexPath) return null;
const isWindows = process.platform === 'win32';
return {
path: codexPath,
needsShell: isWindows && /\.(cmd|bat|ps1)$/i.test(codexPath),
version: readCodexVersion(codexPath),
features: detectCodexFeatures(codexPath),
};
}
export function codexBinarySupportsConfigOverrides(
binaryInfo: TargetBinaryInfo | null | undefined
): boolean {
return Boolean(binaryInfo?.features?.includes(CODEX_CONFIG_OVERRIDE_FEATURE));
}
+16
View File
@@ -19,7 +19,13 @@ export {
} from './target-registry';
export { ClaudeAdapter } from './claude-adapter';
export { DroidAdapter } from './droid-adapter';
export { CodexAdapter } from './codex-adapter';
export { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector';
export {
codexBinarySupportsConfigOverrides,
getCodexBinaryInfo,
detectCodexCli,
} from './codex-detector';
export {
upsertCcsModel,
removeCcsModel,
@@ -30,3 +36,13 @@ export type { DroidCustomModel } from './droid-config-manager';
export { resolveDroidProvider, normalizeDroidProvider } from './droid-provider';
export type { DroidProvider } from './droid-provider';
export { resolveTargetType, stripTargetFlag } from './target-resolver';
export {
TARGET_METADATA,
RUNTIME_TARGET_TYPES,
PERSISTED_TARGET_TYPES,
getPersistedTargetChoices,
getRuntimeTargetChoices,
isPersistedTargetType,
isRuntimeTargetType,
} from './target-metadata';
export { evaluateTargetRuntimeCompatibility } from './target-runtime-compatibility';
+12 -2
View File
@@ -11,7 +11,7 @@
*/
import type { ProfileType } from '../types/profile';
export type TargetType = 'claude' | 'droid';
export type TargetType = 'claude' | 'droid' | 'codex';
/**
* Credentials resolved by CCS profile system, ready for delivery to target CLI.
@@ -39,6 +39,8 @@ export interface TargetCredentials {
export interface TargetBinaryInfo {
path: string;
needsShell: boolean; // Windows .cmd/.bat/.ps1
version?: string;
features?: readonly string[];
}
/**
@@ -72,7 +74,15 @@ export interface TargetAdapter {
* Build target-specific argument vector.
* `userArgs` are the arguments after CCS profile/flag parsing.
*/
buildArgs(profile: string, userArgs: string[]): string[];
buildArgs(
profile: string,
userArgs: string[],
options?: {
creds?: TargetCredentials;
profileType?: ProfileType;
binaryInfo?: TargetBinaryInfo;
}
): string[];
/**
* Build environment variables for process spawn.
+87
View File
@@ -0,0 +1,87 @@
import type { TargetType } from './target-adapter';
export interface TargetMetadata {
displayName: string;
runtimeAliases: readonly string[];
legacyAliasEnvVar?: string;
persistedTarget: boolean;
}
export const TARGET_METADATA: Record<TargetType, TargetMetadata> = {
claude: {
displayName: 'Claude Code',
runtimeAliases: [],
persistedTarget: true,
},
droid: {
displayName: 'Factory Droid',
runtimeAliases: ['ccs-droid', 'ccsd'],
legacyAliasEnvVar: 'CCS_DROID_ALIASES',
persistedTarget: true,
},
codex: {
displayName: 'Codex CLI',
runtimeAliases: ['ccs-codex', 'ccsx'],
legacyAliasEnvVar: 'CCS_CODEX_ALIASES',
persistedTarget: false,
},
};
export const RUNTIME_TARGET_TYPES = Object.freeze(
Object.keys(TARGET_METADATA) as TargetType[]
) as readonly TargetType[];
export const PERSISTED_TARGET_TYPES = Object.freeze(
RUNTIME_TARGET_TYPES.filter((target) => TARGET_METADATA[target].persistedTarget)
) as readonly TargetType[];
const RUNTIME_TARGET_SET = new Set<TargetType>(RUNTIME_TARGET_TYPES);
const PERSISTED_TARGET_SET = new Set<TargetType>(PERSISTED_TARGET_TYPES);
export function isRuntimeTargetType(value: unknown): value is TargetType {
return typeof value === 'string' && RUNTIME_TARGET_SET.has(value as TargetType);
}
export function isPersistedTargetType(value: unknown): value is TargetType {
return typeof value === 'string' && PERSISTED_TARGET_SET.has(value as TargetType);
}
export function formatTargetChoices(
targets: readonly TargetType[],
conjunction: 'or' | 'comma' = 'comma'
): string {
if (targets.length === 0) return '';
if (targets.length === 1) return targets[0];
if (conjunction === 'comma') return targets.join(', ');
if (targets.length === 2) return `${targets[0]} or ${targets[1]}`;
return `${targets.slice(0, -1).join(', ')}, or ${targets[targets.length - 1]}`;
}
export function getPersistedTargetChoices(): string {
return formatTargetChoices(PERSISTED_TARGET_TYPES, 'or');
}
export function getRuntimeTargetChoices(): string {
return formatTargetChoices(RUNTIME_TARGET_TYPES, 'comma');
}
export function getBuiltinArgv0TargetMap(): Record<string, TargetType> {
const map: Record<string, TargetType> = {};
for (const target of RUNTIME_TARGET_TYPES) {
for (const alias of TARGET_METADATA[target].runtimeAliases) {
map[alias] = target;
}
}
return map;
}
export function getLegacyTargetAliasEnvVars(): Partial<Record<TargetType, string>> {
const result: Partial<Record<TargetType, string>> = {};
for (const target of RUNTIME_TARGET_TYPES) {
const envVar = TARGET_METADATA[target].legacyAliasEnvVar;
if (envVar) {
result[target] = envVar;
}
}
return result;
}
+18 -24
View File
@@ -10,21 +10,24 @@
import * as path from 'path';
import { TargetType } from './target-adapter';
import {
getBuiltinArgv0TargetMap,
getLegacyTargetAliasEnvVars,
getRuntimeTargetChoices,
isPersistedTargetType,
isRuntimeTargetType,
} from './target-metadata';
/**
* Built-in argv[0] aliases for explicit runtime entrypoints.
* `ccs-droid` is the transparent alias; `ccsd` remains as a legacy shortcut.
* Droid and Codex install dedicated runtime aliases alongside the base `ccs` bin.
*/
const BUILTIN_ARGV0_TARGET_MAP: Record<string, TargetType> = {
'ccs-droid': 'droid',
ccsd: 'droid',
};
const BUILTIN_ARGV0_TARGET_MAP: Record<string, TargetType> = getBuiltinArgv0TargetMap();
const ALIAS_NAME_REGEX = /^[a-z0-9._-]+$/;
const INTERNAL_ENTRY_TARGET_ENV_VAR = 'CCS_INTERNAL_ENTRY_TARGET';
const GENERIC_TARGET_ALIAS_ENV_VAR = 'CCS_TARGET_ALIASES';
const LEGACY_TARGET_ALIAS_ENV_VARS: Partial<Record<TargetType, string>> = {
droid: 'CCS_DROID_ALIASES',
};
const LEGACY_TARGET_ALIAS_ENV_VARS: Partial<Record<TargetType, string>> =
getLegacyTargetAliasEnvVars();
const RESERVED_BIN_NAMES = new Set<string>(['ccs', ...Object.keys(BUILTIN_ARGV0_TARGET_MAP)]);
function addAliasToMap(map: Record<string, TargetType>, alias: string, target: TargetType): void {
@@ -64,7 +67,7 @@ function parseGenericTargetAliasConfig(map: Record<string, TargetType>, rawConfi
const rawTarget = entry.slice(0, separatorIndex).trim().toLowerCase();
const rawAliases = entry.slice(separatorIndex + 1).trim();
if (!rawAliases || !isValidTarget(rawTarget)) {
if (!rawAliases || !isRuntimeTargetType(rawTarget)) {
continue;
}
@@ -100,30 +103,21 @@ function resolveEntrypointTarget(): TargetType | null {
}
const normalizedTarget = rawTarget.trim().toLowerCase();
return isValidTarget(normalizedTarget) ? normalizedTarget : null;
return isRuntimeTargetType(normalizedTarget) ? normalizedTarget : null;
}
/**
* Valid target types for --target flag validation.
*/
const VALID_TARGETS: ReadonlySet<string> = new Set<TargetType>(['claude', 'droid']);
interface ParsedTargetFlags {
targetOverride?: TargetType;
cleanedArgs: string[];
}
function isValidTarget(target: unknown): target is TargetType {
return typeof target === 'string' && VALID_TARGETS.has(target as TargetType);
}
function normalizeTargetValue(value: string): TargetType {
const normalized = value.toLowerCase();
if (isValidTarget(normalized)) {
if (isRuntimeTargetType(normalized)) {
return normalized as TargetType;
}
const available = Array.from(VALID_TARGETS).join(', ');
const available = getRuntimeTargetChoices();
throw new Error(`Unknown target "${value}". Available: ${available}`);
}
@@ -148,7 +142,7 @@ function parseTargetFlags(args: string[]): ParsedTargetFlags {
if (arg === '--target') {
const value = args[i + 1];
if (!value || value.startsWith('-')) {
throw new Error('--target requires a value (claude or droid)');
throw new Error(`--target requires a value (${getRuntimeTargetChoices()})`);
}
targetOverride = normalizeTargetValue(value);
i += 1; // Skip value
@@ -158,7 +152,7 @@ function parseTargetFlags(args: string[]): ParsedTargetFlags {
if (arg.startsWith('--target=')) {
const value = arg.slice('--target='.length).trim();
if (!value) {
throw new Error('--target requires a value (claude or droid)');
throw new Error(`--target requires a value (${getRuntimeTargetChoices()})`);
}
targetOverride = normalizeTargetValue(value);
continue;
@@ -204,7 +198,7 @@ export function resolveTargetType(
// 3. Check per-profile config
if (profileConfig?.target !== undefined) {
return isValidTarget(profileConfig.target) ? profileConfig.target : 'claude';
return isPersistedTargetType(profileConfig.target) ? profileConfig.target : 'claude';
}
// 4. Default
@@ -0,0 +1,91 @@
import type { CLIProxyProvider } from '../cliproxy/types';
import type { ProfileType } from '../types/profile';
import type { TargetType } from './target-adapter';
export interface TargetRuntimeCompatibilityInput {
target: TargetType;
profileType: ProfileType;
cliproxyProvider?: CLIProxyProvider;
cliproxyBridgeProvider?: CLIProxyProvider | null;
isComposite?: boolean;
}
export interface TargetRuntimeCompatibilityResult {
supported: boolean;
reason?: string;
suggestion?: string;
}
function unsupported(reason: string, suggestion?: string): TargetRuntimeCompatibilityResult {
return { supported: false, reason, suggestion };
}
export function evaluateTargetRuntimeCompatibility(
input: TargetRuntimeCompatibilityInput
): TargetRuntimeCompatibilityResult {
if (input.target === 'claude') {
return { supported: true };
}
if (input.target === 'droid') {
if (input.profileType === 'account') {
return unsupported(
'Factory Droid does not support account-based Claude profiles.',
'Use a settings-based profile with --target droid instead.'
);
}
if (input.profileType === 'copilot') {
return unsupported('Factory Droid does not support Copilot profiles.');
}
return { supported: true };
}
if (input.profileType === 'account') {
return unsupported(
'Codex CLI does not support Claude account-based profiles.',
'Use native Codex auth with: ccs --target codex'
);
}
if (input.profileType === 'copilot') {
return unsupported('Codex CLI does not support Copilot profiles.');
}
if (input.profileType === 'default') {
return { supported: true };
}
if (input.profileType === 'cliproxy') {
if (input.isComposite) {
return unsupported(
'Codex CLI currently does not support composite CLIProxy variants.',
'Use a Codex-only CLIProxy profile or stay on Claude/Droid for composite variants.'
);
}
if (input.cliproxyProvider !== 'codex') {
return unsupported(
`Codex CLI only supports CLIProxy provider "codex". This profile routes to "${input.cliproxyProvider || 'unknown'}".`,
'Use: ccs codex --target codex, ccs-codex codex, or stay on Claude/Droid for other providers.'
);
}
return { supported: true };
}
if (input.profileType === 'settings') {
if (input.cliproxyBridgeProvider === 'codex') {
return { supported: true };
}
if (input.cliproxyBridgeProvider) {
return unsupported(
`Codex CLI only supports CLIProxy Codex bridge profiles. This API profile bridges "${input.cliproxyBridgeProvider}".`,
'Create a Codex bridge with: ccs api create --cliproxy-provider codex'
);
}
return unsupported(
'Codex CLI currently supports native default sessions and Codex-routed CLIProxy sessions only.',
'Use Claude/Droid for generic API profiles, or create a Codex bridge with: ccs api create --cliproxy-provider codex'
);
}
return unsupported('Unsupported Codex runtime combination.');
}