mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 20:14:34 +00:00
Merge pull request #850 from kaitranntt/kai/feat/issue-773-codex-runtime-target
feat: add codex runtime target and dashboard parity
This commit is contained in:
85 files changed
+7867
-208
No files matched your search
@@ -8,6 +8,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { Config, Settings } from '../../types';
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata';
|
||||
import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager';
|
||||
import { ensureProfileHooksOrThrow } from '../../utils/websearch/profile-hook-injector';
|
||||
import { isSensitiveKey } from '../../utils/sensitive-keys';
|
||||
@@ -29,7 +30,7 @@ const SETTINGS_FILE_SUFFIX = '.settings.json';
|
||||
const REDACTED_TOKEN_SENTINEL = '__CCS_REDACTED__';
|
||||
|
||||
function parseTargetValue(value: unknown): TargetType | null {
|
||||
if (value === 'claude' || value === 'droid') {
|
||||
if (isPersistedTargetType(value)) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
@@ -359,7 +360,7 @@ export function importApiProfileBundle(
|
||||
if (input.profile.target !== undefined && bundleTarget === null) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid bundle profile target. Expected: claude or droid.',
|
||||
error: `Invalid bundle profile target. Expected: ${getPersistedTargetChoices()}.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,14 +10,13 @@ import { loadConfigSafe } from '../../utils/config-manager';
|
||||
import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
|
||||
import { expandPath } from '../../utils/helpers';
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
import { isPersistedTargetType } from '../../targets/target-metadata';
|
||||
import type { Settings } from '../../types/config';
|
||||
import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types';
|
||||
import { resolveCliproxyBridgeMetadata } from './cliproxy-profile-bridge';
|
||||
|
||||
const VALID_TARGETS: ReadonlySet<TargetType> = new Set<TargetType>(['claude', 'droid']);
|
||||
|
||||
function sanitizeTarget(target: unknown): TargetType {
|
||||
if (typeof target === 'string' && VALID_TARGETS.has(target as TargetType)) {
|
||||
if (isPersistedTargetType(target)) {
|
||||
return target as TargetType;
|
||||
}
|
||||
return 'claude';
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
|
||||
require('../ccs');
|
||||
+185
-42
@@ -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';
|
||||
@@ -84,6 +87,15 @@ interface DetectedProfile {
|
||||
remainingArgs: string[];
|
||||
}
|
||||
|
||||
interface RuntimeReasoningResolution {
|
||||
argsWithoutReasoningFlags: string[];
|
||||
reasoningOverride: string | number | undefined;
|
||||
reasoningSource: 'flag' | 'env' | undefined;
|
||||
sourceDisplay: string | undefined;
|
||||
}
|
||||
|
||||
const CODEX_RUNTIME_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']);
|
||||
|
||||
/**
|
||||
* Smart profile detection
|
||||
*/
|
||||
@@ -97,6 +109,54 @@ function detectProfile(args: string[]): DetectedProfile {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRuntimeReasoningFlags(
|
||||
args: string[],
|
||||
envThinkingValue: string | undefined
|
||||
): RuntimeReasoningResolution {
|
||||
const runtime = resolveDroidReasoningRuntime(args, envThinkingValue);
|
||||
|
||||
if (runtime.duplicateDisplays.length > 0) {
|
||||
console.error(
|
||||
warn(
|
||||
`[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || '<first-flag>'}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
argsWithoutReasoningFlags: runtime.argsWithoutReasoningFlags,
|
||||
reasoningOverride: runtime.reasoningOverride,
|
||||
reasoningSource: runtime.sourceFlag
|
||||
? 'flag'
|
||||
: runtime.reasoningOverride !== undefined
|
||||
? 'env'
|
||||
: undefined,
|
||||
sourceDisplay: runtime.sourceDisplay,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCodexRuntimeReasoningOverride(
|
||||
value: string | number | undefined
|
||||
): string | undefined {
|
||||
return typeof value === 'string' && CODEX_RUNTIME_REASONING_LEVELS.has(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function exitWithRuntimeReasoningFlagError(
|
||||
message: string,
|
||||
options: {
|
||||
codexAliasLevels: string;
|
||||
includeDroidExecExample?: boolean;
|
||||
}
|
||||
): never {
|
||||
console.error(fail(message));
|
||||
console.error(' Examples: --thinking low, --thinking 8192, --thinking off');
|
||||
console.error(` Codex alias: --effort ${options.codexAliasLevels}`);
|
||||
if (options.includeDroidExecExample) {
|
||||
console.error(' Droid exec: --reasoning-effort high');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ========== Main Execution ==========
|
||||
|
||||
interface ProfileError extends Error {
|
||||
@@ -183,6 +243,7 @@ async function main(): Promise<void> {
|
||||
// Register target adapters
|
||||
registerTarget(new ClaudeAdapter());
|
||||
registerTarget(new DroidAdapter());
|
||||
registerTarget(new CodexAdapter());
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
@@ -372,6 +433,9 @@ async function main(): Promise<void> {
|
||||
|
||||
// Resolve non-claude target adapter once.
|
||||
const targetAdapter = resolvedTarget !== 'claude' ? getTarget(resolvedTarget) : null;
|
||||
let resolvedSettingsPath: string | undefined;
|
||||
let resolvedSettings: ReturnType<typeof loadSettings> | undefined;
|
||||
let resolvedCliproxyBridge: ReturnType<typeof resolveCliproxyBridgeMetadata> | undefined;
|
||||
|
||||
// Preflight unsupported profile/target combinations BEFORE binary detection,
|
||||
// so users get the most actionable error even when the target CLI is not installed.
|
||||
@@ -381,21 +445,47 @@ 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') {
|
||||
resolvedSettingsPath = profileInfo.settingsPath
|
||||
? expandPath(profileInfo.settingsPath)
|
||||
: getSettingsPath(profileInfo.name);
|
||||
resolvedSettings = loadSettings(resolvedSettingsPath);
|
||||
resolvedCliproxyBridge = resolveCliproxyBridgeMetadata(resolvedSettings);
|
||||
const compatibility = evaluateTargetRuntimeCompatibility({
|
||||
target: resolvedTarget,
|
||||
profileType: profileInfo.type,
|
||||
cliproxyBridgeProvider: resolvedCliproxyBridge?.provider ?? null,
|
||||
});
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
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 +518,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,24 +538,16 @@ 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);
|
||||
targetRemainingArgs = droidRoute.argsForDroid;
|
||||
|
||||
if (droidRoute.mode === 'interactive') {
|
||||
const runtime = resolveDroidReasoningRuntime(remainingArgs, process.env.CCS_THINKING);
|
||||
const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env.CCS_THINKING);
|
||||
targetRemainingArgs = runtime.argsWithoutReasoningFlags;
|
||||
droidReasoningOverride = runtime.reasoningOverride;
|
||||
|
||||
if (runtime.duplicateDisplays.length > 0) {
|
||||
console.error(
|
||||
warn(
|
||||
`[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || '<first-flag>'}`
|
||||
)
|
||||
);
|
||||
}
|
||||
runtimeReasoningOverride = runtime.reasoningOverride;
|
||||
} else {
|
||||
if (droidRoute.duplicateReasoningDisplays.length > 0) {
|
||||
console.error(
|
||||
@@ -480,11 +564,36 @@ async function main(): Promise<void> {
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DroidReasoningFlagError || error instanceof DroidCommandRouterError) {
|
||||
console.error(fail(error.message));
|
||||
console.error(' Examples: --thinking low, --thinking 8192, --thinking off');
|
||||
console.error(' Codex alias: --effort medium|high|xhigh');
|
||||
console.error(' Droid exec: --reasoning-effort high');
|
||||
process.exit(1);
|
||||
exitWithRuntimeReasoningFlagError(error.message, {
|
||||
codexAliasLevels: 'minimal|low|medium|high|xhigh',
|
||||
includeDroidExecExample: true,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else if (resolvedTarget === 'codex') {
|
||||
try {
|
||||
const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env.CCS_THINKING);
|
||||
targetRemainingArgs = runtime.argsWithoutReasoningFlags;
|
||||
const normalizedReasoning = normalizeCodexRuntimeReasoningOverride(
|
||||
runtime.reasoningOverride
|
||||
);
|
||||
if (runtime.reasoningOverride !== undefined && !normalizedReasoning) {
|
||||
if (runtime.reasoningSource === 'flag') {
|
||||
throw new DroidReasoningFlagError(
|
||||
'Codex target supports reasoning levels only: minimal, low, medium, high, xhigh.',
|
||||
'--effort'
|
||||
);
|
||||
}
|
||||
runtimeReasoningOverride = undefined;
|
||||
} else {
|
||||
runtimeReasoningOverride = normalizedReasoning;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DroidReasoningFlagError) {
|
||||
exitWithRuntimeReasoningFlagError(error.message, {
|
||||
codexAliasLevels: 'minimal|low|medium|high|xhigh',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -625,7 +734,7 @@ async function main(): Promise<void> {
|
||||
baseUrl: envVars['ANTHROPIC_BASE_URL'],
|
||||
model: envVars['ANTHROPIC_MODEL'],
|
||||
}),
|
||||
reasoningOverride: droidReasoningOverride,
|
||||
reasoningOverride: runtimeReasoningOverride,
|
||||
envVars,
|
||||
};
|
||||
|
||||
@@ -643,7 +752,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;
|
||||
@@ -718,10 +831,32 @@ async function main(): Promise<void> {
|
||||
);
|
||||
}
|
||||
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
|
||||
const expandedSettingsPath = profileInfo.settingsPath
|
||||
? expandPath(profileInfo.settingsPath)
|
||||
: getSettingsPath(profileInfo.name);
|
||||
const settings = loadSettings(expandedSettingsPath);
|
||||
const expandedSettingsPath =
|
||||
resolvedSettingsPath ??
|
||||
(profileInfo.settingsPath
|
||||
? expandPath(profileInfo.settingsPath)
|
||||
: getSettingsPath(profileInfo.name));
|
||||
const settings = resolvedSettings ?? loadSettings(expandedSettingsPath);
|
||||
const cliproxyBridge = resolvedCliproxyBridge ?? 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 +976,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 +1077,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 +1089,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;
|
||||
|
||||
@@ -292,7 +292,7 @@ export function getOfficialChannelsStateScopeMessage(): string {
|
||||
}
|
||||
|
||||
export function getOfficialChannelsSupportMessage(): string {
|
||||
return 'Works only for native Claude default/account sessions. It does not apply to API, OAuth, or Droid targets such as `ccs glm`, `ccs gemini`, `ccs codex`, or `ccs --target droid`.';
|
||||
return 'Works only for native Claude default/account sessions. It does not apply to API, OAuth, or non-Claude targets such as `ccs glm`, `ccs gemini`, `ccs codex`, `ccs --target droid`, or `ccs --target codex`.';
|
||||
}
|
||||
|
||||
export function getOfficialChannelsAccountStatusCaveat(): string {
|
||||
|
||||
@@ -417,6 +417,17 @@ export async function handleApiCreateCommand(args: string[]): Promise<void> {
|
||||
` ${color(`ccs ${result.name} --target droid "your prompt"`, 'command')} ${dim('# target flag alternative')}`
|
||||
);
|
||||
}
|
||||
if (cliproxyProvider === 'codex') {
|
||||
console.log(
|
||||
` ${color(`ccs ${result.name} --target codex "your prompt"`, 'command')} ${dim('# native Codex runtime')}`
|
||||
);
|
||||
console.log(
|
||||
` ${color(`ccs-codex ${result.name} "your prompt"`, 'command')} ${dim('# explicit Codex alias')}`
|
||||
);
|
||||
console.log(
|
||||
` ${color(`ccsx ${result.name} "your prompt"`, 'command')} ${dim('# short alias')}`
|
||||
);
|
||||
}
|
||||
console.log('');
|
||||
console.log(dim('Manage provider accounts, keys, and models in: ccs cliproxy'));
|
||||
return;
|
||||
|
||||
@@ -107,6 +107,10 @@ export async function showApiCommandHelp(writeLine: HelpWriter = console.log): P
|
||||
writeLine(
|
||||
` ${color('ccs api create gemini-droid --cliproxy-provider gemini --target droid', 'command')}`
|
||||
);
|
||||
writeLine(` ${color('ccs api create codex-api --cliproxy-provider codex', 'command')}`);
|
||||
writeLine(
|
||||
` ${color('ccs codex-api --target codex', 'command')} ${dim('# runtime-only native Codex launch')}`
|
||||
);
|
||||
writeLine('');
|
||||
writeLine(` ${dim('# Create with name')}`);
|
||||
writeLine(` ${color('ccs api create myapi', 'command')}`);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ModelMapping } from '../../api/services';
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata';
|
||||
import {
|
||||
applyExtendedContextSuffix,
|
||||
hasExtendedContextSuffix,
|
||||
@@ -108,7 +109,7 @@ export function extractPositionalArgs(args: string[]): string[] {
|
||||
|
||||
function parseTargetValue(value: string): TargetType | null {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'claude' || normalized === 'droid') {
|
||||
if (isPersistedTargetType(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
@@ -132,7 +133,7 @@ export function parseOptionalTargetFlag(
|
||||
if (!target) {
|
||||
return {
|
||||
remainingArgs: extracted.remainingArgs,
|
||||
errors: [`Invalid --target value "${extracted.value}". Use: claude or droid`],
|
||||
errors: [`Invalid --target value "${extracted.value}". Use: ${getPersistedTargetChoices()}`],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -251,7 +252,9 @@ export function parseApiCommandArgs(
|
||||
(value) => {
|
||||
const target = parseTargetValue(value);
|
||||
if (!target) {
|
||||
result.errors.push(`Invalid --target value "${value}". Use: claude or droid`);
|
||||
result.errors.push(
|
||||
`Invalid --target value "${value}". Use: ${getPersistedTargetChoices()}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
result.target = target;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CLIProxyProfileName, CLIPROXY_PROFILES } from '../../auth/profile-detec
|
||||
import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../../cliproxy/model-catalog';
|
||||
import { CLIProxyProvider, CLIProxyBackend } from '../../cliproxy/types';
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata';
|
||||
import { isUnifiedMode } from '../../config/unified-config-loader';
|
||||
import { initUI, header, color, ok, fail, warn, info, infoBox, dim } from '../../utils/ui';
|
||||
import { InteractivePrompt } from '../../utils/prompt';
|
||||
@@ -42,7 +43,7 @@ interface CliproxyProfileArgs {
|
||||
|
||||
function parseTargetValue(rawValue: string): TargetType | null {
|
||||
const normalized = rawValue.trim().toLowerCase();
|
||||
if (normalized === 'claude' || normalized === 'droid') {
|
||||
if (isPersistedTargetType(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
@@ -73,7 +74,9 @@ export function parseProfileArgs(args: string[]): CliproxyProfileArgs {
|
||||
i += 1;
|
||||
const parsedTarget = parseTargetValue(rawValue);
|
||||
if (!parsedTarget) {
|
||||
result.errors.push(`Invalid --target value "${rawValue}". Use: claude or droid`);
|
||||
result.errors.push(
|
||||
`Invalid --target value "${rawValue}". Use: ${getPersistedTargetChoices()}`
|
||||
);
|
||||
} else {
|
||||
result.target = parsedTarget;
|
||||
}
|
||||
@@ -82,7 +85,9 @@ export function parseProfileArgs(args: string[]): CliproxyProfileArgs {
|
||||
const rawValue = arg.slice('--target='.length);
|
||||
const parsedTarget = parseTargetValue(rawValue);
|
||||
if (!parsedTarget) {
|
||||
result.errors.push(`Invalid --target value "${rawValue}". Use: claude or droid`);
|
||||
result.errors.push(
|
||||
`Invalid --target value "${rawValue}". Use: ${getPersistedTargetChoices()}`
|
||||
);
|
||||
} else {
|
||||
result.target = parsedTarget;
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
'ccs <provider> --thinking <value>',
|
||||
'Set thinking budget (low/medium/high/xhigh/auto/off or number)',
|
||||
],
|
||||
['ccs codex --effort <level>', 'Set codex reasoning effort (medium/high/xhigh)'],
|
||||
['ccs codex --effort <level>', 'Set codex reasoning effort (minimal/low/medium/high/xhigh)'],
|
||||
['ccs <provider> --1m', 'Request explicit 1M context when the selected model supports [1m]'],
|
||||
['ccs <provider> --no-1m', 'Force standard context / clear [1m]'],
|
||||
['ccs <provider> --logout', 'Clear authentication'],
|
||||
@@ -391,7 +391,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
'Flags',
|
||||
[
|
||||
['--config-dir <path>', 'Use custom CCS config directory'],
|
||||
['--target <cli>', 'Target CLI: claude (default), droid'],
|
||||
['--target <cli>', 'Target CLI: claude (default), droid, codex (runtime-only)'],
|
||||
['-h, --help', 'Show this help message'],
|
||||
['-v, --version', 'Show version and installation info'],
|
||||
['-sc, --shell-completion', 'Install shell auto-completion'],
|
||||
@@ -405,6 +405,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
[
|
||||
['ccs-droid <profile> [args]', 'Explicit Droid runtime alias'],
|
||||
['ccsd <profile> [args]', 'Legacy shortcut for: ccs-droid <profile> [args]'],
|
||||
['ccs-codex <profile> [args]', 'Explicit Codex runtime alias'],
|
||||
['ccsx <profile> [args]', 'Short alias for: ccs-codex <profile> [args]'],
|
||||
],
|
||||
writeLine
|
||||
);
|
||||
@@ -416,6 +418,15 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
['ccs glm --target droid', 'Run GLM profile on Droid CLI'],
|
||||
['ccs-droid glm', 'Same as above (explicit alias)'],
|
||||
['ccsd glm', 'Legacy shortcut for ccs-droid'],
|
||||
['ccs --target codex', 'Open a native Codex session with your existing ~/.codex setup'],
|
||||
['ccs-codex', 'Same as above (explicit Codex alias)'],
|
||||
['ccsx', 'Short alias for ccs-codex'],
|
||||
['ccs codex --target codex', 'Run built-in CLIProxy Codex on native Codex CLI'],
|
||||
[
|
||||
'ccs api create codex-api --cliproxy-provider codex',
|
||||
'Create a routed API bridge that can also run on Codex',
|
||||
],
|
||||
['ccs codex-api --target codex', 'Run a Codex bridge profile on native Codex CLI'],
|
||||
['ccs-droid codex', 'Run built-in CLIProxy Codex profile on Droid'],
|
||||
['ccs-droid agy', 'Run built-in CLIProxy Antigravity profile on Droid'],
|
||||
[
|
||||
@@ -512,7 +523,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
['--thinking xhigh', '32K tokens - Maximum depth'],
|
||||
['--thinking <number>', 'Custom token budget (512-100000)'],
|
||||
['', ''],
|
||||
['--effort <level>', 'Codex alias for reasoning effort (medium/high/xhigh)'],
|
||||
['--effort <level>', 'Codex alias for reasoning effort (minimal/low/medium/high/xhigh)'],
|
||||
['--effort xhigh', 'Pin Codex effort to xhigh for this run'],
|
||||
['', ''],
|
||||
['Droid exec:', 'Use native Droid flag: --reasoning-effort <level>'],
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
export interface CompatibleCliDocLink {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
category: 'overview' | 'configuration' | 'byok' | 'reference';
|
||||
source: 'factory' | 'provider' | 'openai' | 'github';
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CompatibleCliProviderDocLink {
|
||||
provider: string;
|
||||
label: string;
|
||||
apiFormat: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface CompatibleCliDocsReference {
|
||||
providerValues: string[];
|
||||
settingsHierarchy: string[];
|
||||
notes: string[];
|
||||
links: CompatibleCliDocLink[];
|
||||
providerDocs: CompatibleCliProviderDocLink[];
|
||||
}
|
||||
|
||||
export type CodexBinarySource = 'CCS_CODEX_PATH' | 'PATH' | 'missing';
|
||||
|
||||
export interface CodexBinaryDiagnostics {
|
||||
installed: boolean;
|
||||
path: string | null;
|
||||
installDir: string | null;
|
||||
source: CodexBinarySource;
|
||||
version: string | null;
|
||||
overridePath: string | null;
|
||||
supportsConfigOverrides: boolean;
|
||||
}
|
||||
|
||||
export interface CodexConfigFileDiagnostics {
|
||||
label: string;
|
||||
path: string;
|
||||
resolvedPath: string;
|
||||
exists: boolean;
|
||||
isSymlink: boolean;
|
||||
isRegularFile: boolean;
|
||||
sizeBytes: number | null;
|
||||
mtimeMs: number | null;
|
||||
parseError: string | null;
|
||||
readError: string | null;
|
||||
}
|
||||
|
||||
export interface CodexModelProviderDiagnostics {
|
||||
name: string;
|
||||
baseUrl: string | null;
|
||||
envKey: string | null;
|
||||
wireApi: string | null;
|
||||
requiresOpenaiAuth: boolean;
|
||||
supportsWebsockets: boolean;
|
||||
hasQueryParams: boolean;
|
||||
hasHttpHeaders: boolean;
|
||||
usesExperimentalBearerToken: boolean;
|
||||
}
|
||||
|
||||
export interface CodexFeatureFlagDiagnostics {
|
||||
name: string;
|
||||
state: 'enabled' | 'disabled' | 'custom';
|
||||
}
|
||||
|
||||
export interface CodexProjectTrustDiagnostics {
|
||||
path: string;
|
||||
trustLevel: string;
|
||||
}
|
||||
|
||||
export interface CodexMcpServerDiagnostics {
|
||||
name: string;
|
||||
transport: 'stdio' | 'streamable-http' | 'unknown';
|
||||
enabled: boolean;
|
||||
required: boolean;
|
||||
startupTimeoutSec: number | null;
|
||||
toolTimeoutSec: number | null;
|
||||
enabledToolsCount: number;
|
||||
disabledToolsCount: number;
|
||||
usesInlineBearerToken: boolean;
|
||||
}
|
||||
|
||||
export interface CodexSupportMatrixEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
supported: boolean;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface CodexUserConfigDiagnostics {
|
||||
model: string | null;
|
||||
modelReasoningEffort: string | null;
|
||||
modelProvider: string | null;
|
||||
activeProfile: string | null;
|
||||
approvalPolicy: string | null;
|
||||
sandboxMode: string | null;
|
||||
webSearch: string | null;
|
||||
toolOutputTokenLimit: number | null;
|
||||
personality: string | null;
|
||||
topLevelKeys: string[];
|
||||
profileCount: number;
|
||||
profileNames: string[];
|
||||
modelProviderCount: number;
|
||||
modelProviders: CodexModelProviderDiagnostics[];
|
||||
featureCount: number;
|
||||
enabledFeatures: CodexFeatureFlagDiagnostics[];
|
||||
disabledFeatures: CodexFeatureFlagDiagnostics[];
|
||||
trustedProjectCount: number;
|
||||
untrustedProjectCount: number;
|
||||
projectTrust: CodexProjectTrustDiagnostics[];
|
||||
mcpServerCount: number;
|
||||
mcpServers: CodexMcpServerDiagnostics[];
|
||||
}
|
||||
|
||||
export interface CodexDashboardDiagnostics {
|
||||
binary: CodexBinaryDiagnostics;
|
||||
file: CodexConfigFileDiagnostics;
|
||||
workspacePath: string;
|
||||
config: CodexUserConfigDiagnostics;
|
||||
supportMatrix: CodexSupportMatrixEntry[];
|
||||
warnings: string[];
|
||||
docsReference: CompatibleCliDocsReference;
|
||||
}
|
||||
|
||||
export interface CodexRawConfigResponse {
|
||||
path: string;
|
||||
resolvedPath: string;
|
||||
exists: boolean;
|
||||
mtime: number;
|
||||
rawText: string;
|
||||
config: Record<string, unknown> | null;
|
||||
parseError: string | null;
|
||||
readError: string | null;
|
||||
}
|
||||
|
||||
export interface CodexTopLevelSettingsPatch {
|
||||
model?: string | null;
|
||||
modelReasoningEffort?: string | null;
|
||||
modelProvider?: string | null;
|
||||
approvalPolicy?: string | null;
|
||||
sandboxMode?: string | null;
|
||||
webSearch?: string | null;
|
||||
toolOutputTokenLimit?: number | null;
|
||||
personality?: string | null;
|
||||
}
|
||||
|
||||
export interface CodexProfilePatchValues extends CodexTopLevelSettingsPatch {}
|
||||
|
||||
export interface CodexModelProviderPatchValues {
|
||||
displayName?: string | null;
|
||||
baseUrl?: string | null;
|
||||
envKey?: string | null;
|
||||
wireApi?: string | null;
|
||||
requiresOpenaiAuth?: boolean | null;
|
||||
supportsWebsockets?: boolean | null;
|
||||
}
|
||||
|
||||
export interface CodexMcpServerPatchValues {
|
||||
transport: 'stdio' | 'streamable-http';
|
||||
command?: string | null;
|
||||
args?: string[] | null;
|
||||
url?: string | null;
|
||||
enabled?: boolean | null;
|
||||
required?: boolean | null;
|
||||
startupTimeoutSec?: number | null;
|
||||
toolTimeoutSec?: number | null;
|
||||
enabledTools?: string[] | null;
|
||||
disabledTools?: string[] | null;
|
||||
}
|
||||
|
||||
export type CodexConfigPatchInput =
|
||||
| {
|
||||
kind: 'top-level';
|
||||
expectedMtime?: number;
|
||||
values: CodexTopLevelSettingsPatch;
|
||||
}
|
||||
| {
|
||||
kind: 'project-trust';
|
||||
expectedMtime?: number;
|
||||
path: string;
|
||||
trustLevel: string | null;
|
||||
}
|
||||
| {
|
||||
kind: 'feature';
|
||||
expectedMtime?: number;
|
||||
feature: string;
|
||||
enabled: boolean | null;
|
||||
}
|
||||
| {
|
||||
kind: 'profile';
|
||||
expectedMtime?: number;
|
||||
action: 'set-active' | 'upsert' | 'delete';
|
||||
name: string;
|
||||
values?: CodexProfilePatchValues;
|
||||
setAsActive?: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'model-provider';
|
||||
expectedMtime?: number;
|
||||
action: 'upsert' | 'delete';
|
||||
name: string;
|
||||
values?: CodexModelProviderPatchValues;
|
||||
}
|
||||
| {
|
||||
kind: 'mcp-server';
|
||||
expectedMtime?: number;
|
||||
action: 'upsert' | 'delete';
|
||||
name: string;
|
||||
values?: CodexMcpServerPatchValues;
|
||||
};
|
||||
|
||||
export interface CodexConfigPatchResult extends CodexRawConfigResponse {
|
||||
success: true;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { parse } from 'smol-toml';
|
||||
|
||||
export interface SafeTomlObjectParseResult {
|
||||
config: Record<string, unknown> | null;
|
||||
parseError: string | null;
|
||||
}
|
||||
|
||||
function isTomlObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function parseTomlObject(rawText: string): Record<string, unknown> {
|
||||
const trimmed = rawText.trim();
|
||||
if (!trimmed) return {};
|
||||
|
||||
const parsed = parse(rawText);
|
||||
if (!isTomlObject(parsed)) {
|
||||
throw new Error('TOML root must be a table.');
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function safeParseTomlObject(rawText: string): SafeTomlObjectParseResult {
|
||||
try {
|
||||
return {
|
||||
config: parseTomlObject(rawText),
|
||||
parseError: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
config: null,
|
||||
parseError: (error as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
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 buildConfigOverrideSupportError(binaryInfo?: TargetBinaryInfo): Error {
|
||||
const versionSummary = binaryInfo?.version ? ` (${binaryInfo.version})` : '';
|
||||
return new Error(
|
||||
`Codex CLI${versionSummary} does not advertise --config overrides. Upgrade Codex before using CCS-backed Codex profiles or runtime reasoning overrides.`
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
|
||||
throw buildConfigOverrideSupportError(options?.binaryInfo);
|
||||
}
|
||||
return [
|
||||
...buildConfigOverrideArgs([
|
||||
`model_reasoning_effort=${formatTomlString(reasoningOverride)}`,
|
||||
]),
|
||||
...userArgs,
|
||||
];
|
||||
}
|
||||
return userArgs;
|
||||
}
|
||||
|
||||
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
|
||||
throw buildConfigOverrideSupportError(options?.binaryInfo);
|
||||
}
|
||||
|
||||
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 {
|
||||
// Bridge-backed settings profiles need additional compatibility context that the
|
||||
// adapter contract does not receive, so keep the adapter-level claim conservative.
|
||||
return profileType === 'default' || 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,
|
||||
},
|
||||
} satisfies Record<TargetType, TargetMetadata>;
|
||||
|
||||
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.');
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
CodexRawConfigConflictError,
|
||||
CodexRawConfigValidationError,
|
||||
getCodexDashboardDiagnostics,
|
||||
getCodexRawConfig,
|
||||
patchCodexConfig,
|
||||
saveCodexRawConfig,
|
||||
} from '../services/codex-dashboard-service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/diagnostics', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
res.json(await getCodexDashboardDiagnostics());
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/config/raw', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
res.json(await getCodexRawConfig());
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/config/raw', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { rawText, expectedMtime } = req.body ?? {};
|
||||
|
||||
if (typeof rawText !== 'string') {
|
||||
res.status(400).json({ error: 'rawText must be a string.' });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
expectedMtime !== undefined &&
|
||||
(typeof expectedMtime !== 'number' || !Number.isFinite(expectedMtime))
|
||||
) {
|
||||
res.status(400).json({ error: 'expectedMtime must be a finite number when provided.' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(await saveCodexRawConfig({ rawText, expectedMtime }));
|
||||
} catch (error) {
|
||||
if (error instanceof CodexRawConfigValidationError) {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
if (error instanceof CodexRawConfigConflictError) {
|
||||
res.status(409).json({ error: error.message, mtime: error.mtime });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/config/patch', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const body = req.body ?? {};
|
||||
if (typeof body.kind !== 'string' || body.kind.trim().length === 0) {
|
||||
res.status(400).json({ error: 'kind is required.' });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
body.expectedMtime !== undefined &&
|
||||
(typeof body.expectedMtime !== 'number' || !Number.isFinite(body.expectedMtime))
|
||||
) {
|
||||
res.status(400).json({ error: 'expectedMtime must be a finite number when provided.' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(await patchCodexConfig(body));
|
||||
} catch (error) {
|
||||
if (error instanceof CodexRawConfigValidationError) {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
if (error instanceof CodexRawConfigConflictError) {
|
||||
res.status(409).json({ error: error.message, mtime: error.mtime });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -24,6 +24,7 @@ import aiProviderRoutes from './ai-provider-routes';
|
||||
import copilotRoutes from './copilot-routes';
|
||||
import cursorRoutes from './cursor-routes';
|
||||
import droidRoutes from './droid-routes';
|
||||
import codexRoutes from './codex-routes';
|
||||
import miscRoutes from './misc-routes';
|
||||
import cliproxyServerRoutes from './proxy-routes';
|
||||
import authRoutes from './auth-routes';
|
||||
@@ -77,6 +78,9 @@ apiRoutes.use('/cursor', cursorRoutes);
|
||||
// ==================== Droid ====================
|
||||
apiRoutes.use('/droid', droidRoutes);
|
||||
|
||||
// ==================== Codex ====================
|
||||
apiRoutes.use('/codex', codexRoutes);
|
||||
|
||||
// ==================== CLIProxy Server Settings ====================
|
||||
apiRoutes.use('/cliproxy-server', cliproxyServerRoutes);
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
validateApiName,
|
||||
} from '../../api/services';
|
||||
import { normalizeDroidProvider } from '../../targets/droid-provider';
|
||||
import { getPersistedTargetChoices } from '../../targets/target-metadata';
|
||||
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
|
||||
import { isAnthropicDirectProfile, updateSettingsFile, parseTarget } from './route-helpers';
|
||||
|
||||
@@ -100,7 +101,7 @@ router.post('/cliproxy-bridge', (req: Request, res: Response): void => {
|
||||
|
||||
const target = parseTarget(shape.payload.target);
|
||||
if (shape.payload.target !== undefined && target === null) {
|
||||
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
|
||||
res.status(400).json({ error: `Invalid target. Expected: ${getPersistedTargetChoices()}` });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,7 +156,7 @@ router.post('/', (req: Request, res: Response): void => {
|
||||
|
||||
const parsedTarget = parseTarget(target);
|
||||
if (target !== undefined && parsedTarget === null) {
|
||||
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
|
||||
res.status(400).json({ error: `Invalid target. Expected: ${getPersistedTargetChoices()}` });
|
||||
return;
|
||||
}
|
||||
if (providerHint !== undefined && parsedProvider === null) {
|
||||
@@ -265,7 +266,7 @@ router.post('/orphans/register', (req: Request, res: Response): void => {
|
||||
const force = payload.force === true;
|
||||
|
||||
if (payload.target !== undefined && target === null) {
|
||||
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
|
||||
res.status(400).json({ error: `Invalid target. Expected: ${getPersistedTargetChoices()}` });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -306,7 +307,7 @@ router.post('/:name/copy', (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
if (shape.payload.target !== undefined && target === null) {
|
||||
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
|
||||
res.status(400).json({ error: `Invalid target. Expected: ${getPersistedTargetChoices()}` });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -357,7 +358,7 @@ router.post('/import', (req: Request, res: Response): void => {
|
||||
|
||||
const target = parseTarget(shape.payload.target);
|
||||
if (shape.payload.target !== undefined && target === null) {
|
||||
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
|
||||
res.status(400).json({ error: `Invalid target. Expected: ${getPersistedTargetChoices()}` });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -368,7 +369,9 @@ router.post('/import', (req: Request, res: Response): void => {
|
||||
}
|
||||
const bundleTarget = (bundle as { profile?: { target?: unknown } }).profile?.target;
|
||||
if (bundleTarget !== undefined && parseTarget(bundleTarget) === null) {
|
||||
res.status(400).json({ error: 'Invalid bundle profile target. Expected: claude or droid' });
|
||||
res.status(400).json({
|
||||
error: `Invalid bundle profile target. Expected: ${getPersistedTargetChoices()}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -418,7 +421,7 @@ router.put('/:name', (req: Request, res: Response): void => {
|
||||
|
||||
const parsedTarget = parseTarget(target);
|
||||
if (target !== undefined && parsedTarget === null) {
|
||||
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
|
||||
res.status(400).json({ error: `Invalid target. Expected: ${getPersistedTargetChoices()}` });
|
||||
return;
|
||||
}
|
||||
if (providerHint !== undefined && parsedProvider === null) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import type { Config, Settings } from '../../types/config';
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
import { isPersistedTargetType } from '../../targets/target-metadata';
|
||||
import { ValidationError } from '../../errors/error-types';
|
||||
|
||||
/** Model mapping for API profiles */
|
||||
@@ -438,7 +439,7 @@ export function validateFilePath(filePath: string): {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a target param (claude/droid). Returns null if invalid/absent.
|
||||
* Parse and validate a persisted target param. Returns null if invalid/absent.
|
||||
* Shared by profile-routes and variant-routes.
|
||||
*/
|
||||
export function parseTarget(rawTarget: unknown): TargetType | null {
|
||||
@@ -451,7 +452,7 @@ export function parseTarget(rawTarget: unknown): TargetType | null {
|
||||
}
|
||||
|
||||
const normalized = rawTarget.trim().toLowerCase();
|
||||
if (normalized === 'claude' || normalized === 'droid') {
|
||||
if (isPersistedTargetType(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import { getPersistedTargetChoices } from '../../targets/target-metadata';
|
||||
import { parseTarget } from './route-helpers';
|
||||
import {
|
||||
createVariant,
|
||||
@@ -55,7 +56,7 @@ router.post('/', (req: Request, res: Response): void => {
|
||||
const parsedTarget = parseTarget(req.body.target);
|
||||
|
||||
if (req.body.target !== undefined && parsedTarget === null) {
|
||||
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
|
||||
res.status(400).json({ error: `Invalid target. Expected: ${getPersistedTargetChoices()}` });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -178,7 +179,7 @@ router.put('/:name', (req: Request, res: Response): void => {
|
||||
const parsedTarget = parseTarget(req.body.target);
|
||||
|
||||
if (req.body.target !== undefined && parsedTarget === null) {
|
||||
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
|
||||
res.status(400).json({ error: `Invalid target. Expected: ${getPersistedTargetChoices()}` });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,873 @@
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { expandPath } from '../../utils/helpers';
|
||||
import {
|
||||
codexBinarySupportsConfigOverrides,
|
||||
getCodexBinaryInfo,
|
||||
} from '../../targets/codex-detector';
|
||||
import type {
|
||||
CodexConfigPatchInput,
|
||||
CodexConfigPatchResult,
|
||||
CodexDashboardDiagnostics,
|
||||
CodexFeatureFlagDiagnostics,
|
||||
CodexMcpServerDiagnostics,
|
||||
CodexModelProviderDiagnostics,
|
||||
CodexProjectTrustDiagnostics,
|
||||
CodexRawConfigResponse,
|
||||
CodexSupportMatrixEntry,
|
||||
} from './compatible-cli-types';
|
||||
import {
|
||||
TomlFileConflictError,
|
||||
TomlFileValidationError,
|
||||
probeTomlObjectFile,
|
||||
stringifyTomlObject,
|
||||
writeTomlFileAtomic,
|
||||
} from './compatible-cli-toml-file-service';
|
||||
import { getCompatibleCliDocsReference } from './compatible-cli-docs-registry';
|
||||
|
||||
interface CodexConfigPaths {
|
||||
configPath: string;
|
||||
configDisplayPath: string;
|
||||
baseDir: string;
|
||||
baseDirDisplay: string;
|
||||
}
|
||||
|
||||
interface SaveCodexRawConfigInput {
|
||||
rawText: string;
|
||||
expectedMtime?: number;
|
||||
}
|
||||
|
||||
interface SaveCodexRawConfigResult {
|
||||
success: true;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
export {
|
||||
TomlFileConflictError as CodexRawConfigConflictError,
|
||||
TomlFileValidationError as CodexRawConfigValidationError,
|
||||
};
|
||||
|
||||
const KNOWN_CODEX_FEATURES = new Set([
|
||||
'apps',
|
||||
'apply_patch_freeform',
|
||||
'codex_hooks',
|
||||
'fast_mode',
|
||||
'js_repl',
|
||||
'multi_agent',
|
||||
'personality',
|
||||
'prevent_idle_sleep',
|
||||
'runtime_metrics',
|
||||
'shell_snapshot',
|
||||
'shell_tool',
|
||||
'smart_approvals',
|
||||
'unified_exec',
|
||||
'undo',
|
||||
'web_search',
|
||||
'web_search_cached',
|
||||
'web_search_request',
|
||||
]);
|
||||
const MODEL_REASONING_EFFORT_VALUES = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']);
|
||||
const APPROVAL_POLICY_VALUES = new Set(['on-request', 'never', 'untrusted']);
|
||||
const SANDBOX_MODE_VALUES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
|
||||
const WEB_SEARCH_VALUES = new Set(['cached', 'live', 'disabled']);
|
||||
const PERSONALITY_VALUES = new Set(['none', 'friendly', 'pragmatic']);
|
||||
const PROJECT_TRUST_LEVEL_VALUES = new Set(['trusted', 'untrusted']);
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | null {
|
||||
return isObject(value) ? value : null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function hasOwn(obj: object, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function ensureObject(target: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||
const existing = asObject(target[key]);
|
||||
if (existing) return existing;
|
||||
|
||||
const next: Record<string, unknown> = {};
|
||||
target[key] = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
function deleteIfEmpty(target: Record<string, unknown>, key: string) {
|
||||
const value = asObject(target[key]);
|
||||
if (value && Object.keys(value).length === 0) {
|
||||
delete target[key];
|
||||
}
|
||||
}
|
||||
|
||||
function shouldPreserveUnsupportedValue(value: unknown): boolean {
|
||||
return Array.isArray(value) || isObject(value);
|
||||
}
|
||||
|
||||
function deleteFieldUnlessUnsupported(target: Record<string, unknown>, key: string) {
|
||||
if (shouldPreserveUnsupportedValue(target[key])) {
|
||||
return;
|
||||
}
|
||||
delete target[key];
|
||||
}
|
||||
|
||||
function setStringField(target: Record<string, unknown>, key: string, value: unknown) {
|
||||
if (!isNonEmptyString(value)) {
|
||||
deleteFieldUnlessUnsupported(target, key);
|
||||
return;
|
||||
}
|
||||
target[key] = value.trim();
|
||||
}
|
||||
|
||||
function setEnumStringField(
|
||||
target: Record<string, unknown>,
|
||||
key: string,
|
||||
value: unknown,
|
||||
allowedValues: Set<string>,
|
||||
label: string
|
||||
) {
|
||||
if (!isNonEmptyString(value)) {
|
||||
deleteFieldUnlessUnsupported(target, key);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
if (!allowedValues.has(normalized)) {
|
||||
throw new TomlFileValidationError(
|
||||
`${label} must be one of: ${Array.from(allowedValues).join(', ')}.`
|
||||
);
|
||||
}
|
||||
|
||||
target[key] = normalized;
|
||||
}
|
||||
|
||||
function setBooleanField(target: Record<string, unknown>, key: string, value: unknown) {
|
||||
if (typeof value !== 'boolean') {
|
||||
deleteFieldUnlessUnsupported(target, key);
|
||||
return;
|
||||
}
|
||||
target[key] = value;
|
||||
}
|
||||
|
||||
function setNumberField(
|
||||
target: Record<string, unknown>,
|
||||
key: string,
|
||||
value: unknown,
|
||||
options: { integer?: boolean; min?: number } = {}
|
||||
) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
deleteFieldUnlessUnsupported(target, key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.integer && !Number.isInteger(value)) {
|
||||
throw new TomlFileValidationError(`${key} must be an integer.`);
|
||||
}
|
||||
if (typeof options.min === 'number' && value < options.min) {
|
||||
throw new TomlFileValidationError(`${key} must be >= ${options.min}.`);
|
||||
}
|
||||
|
||||
target[key] = value;
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown, label: string): string[] | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (!Array.isArray(value)) {
|
||||
throw new TomlFileValidationError(`${label} must be an array of strings.`);
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
|
||||
.filter((entry) => entry.length > 0);
|
||||
return normalized.length > 0 ? normalized : [];
|
||||
}
|
||||
|
||||
function assertPatchableToml(fileProbe: {
|
||||
diagnostics: { parseError: string | null; readError: string | null };
|
||||
config: Record<string, unknown> | null;
|
||||
}): Record<string, unknown> {
|
||||
if (fileProbe.diagnostics.readError) {
|
||||
throw new TomlFileValidationError(fileProbe.diagnostics.readError);
|
||||
}
|
||||
if (fileProbe.diagnostics.parseError) {
|
||||
throw new TomlFileValidationError(
|
||||
'config.toml contains invalid TOML. Fix the raw file before using guided controls.'
|
||||
);
|
||||
}
|
||||
return asObject(fileProbe.config) ?? {};
|
||||
}
|
||||
|
||||
function summarizeApprovalPolicy(value: unknown): string | null {
|
||||
const stringValue = asString(value);
|
||||
if (stringValue) {
|
||||
return stringValue;
|
||||
}
|
||||
|
||||
const objectValue = asObject(value);
|
||||
if (!objectValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasOwn(objectValue, 'granular')) {
|
||||
return 'granular (custom)';
|
||||
}
|
||||
|
||||
return 'custom object';
|
||||
}
|
||||
|
||||
function applyTopLevelSettingsPatch(
|
||||
target: Record<string, unknown>,
|
||||
values: Extract<CodexConfigPatchInput, { kind: 'top-level' }>['values']
|
||||
) {
|
||||
if (hasOwn(values, 'model')) setStringField(target, 'model', values.model);
|
||||
if (hasOwn(values, 'modelReasoningEffort')) {
|
||||
setEnumStringField(
|
||||
target,
|
||||
'model_reasoning_effort',
|
||||
values.modelReasoningEffort,
|
||||
MODEL_REASONING_EFFORT_VALUES,
|
||||
'model_reasoning_effort'
|
||||
);
|
||||
}
|
||||
if (hasOwn(values, 'modelProvider')) {
|
||||
setStringField(target, 'model_provider', values.modelProvider);
|
||||
}
|
||||
if (hasOwn(values, 'approvalPolicy')) {
|
||||
setEnumStringField(
|
||||
target,
|
||||
'approval_policy',
|
||||
values.approvalPolicy,
|
||||
APPROVAL_POLICY_VALUES,
|
||||
'approval_policy'
|
||||
);
|
||||
}
|
||||
if (hasOwn(values, 'sandboxMode')) {
|
||||
setEnumStringField(
|
||||
target,
|
||||
'sandbox_mode',
|
||||
values.sandboxMode,
|
||||
SANDBOX_MODE_VALUES,
|
||||
'sandbox_mode'
|
||||
);
|
||||
}
|
||||
if (hasOwn(values, 'webSearch')) {
|
||||
setEnumStringField(target, 'web_search', values.webSearch, WEB_SEARCH_VALUES, 'web_search');
|
||||
}
|
||||
if (hasOwn(values, 'toolOutputTokenLimit')) {
|
||||
setNumberField(target, 'tool_output_token_limit', values.toolOutputTokenLimit, {
|
||||
integer: true,
|
||||
min: 1,
|
||||
});
|
||||
}
|
||||
if (hasOwn(values, 'personality')) {
|
||||
setEnumStringField(
|
||||
target,
|
||||
'personality',
|
||||
values.personality,
|
||||
PERSONALITY_VALUES,
|
||||
'personality'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function applyProjectTrustPatch(
|
||||
target: Record<string, unknown>,
|
||||
input: Extract<CodexConfigPatchInput, { kind: 'project-trust' }>
|
||||
) {
|
||||
if (!isNonEmptyString(input.path)) {
|
||||
throw new TomlFileValidationError('Project path is required.');
|
||||
}
|
||||
|
||||
const expandedPath = expandPath(input.path.trim());
|
||||
if (!path.isAbsolute(expandedPath)) {
|
||||
throw new TomlFileValidationError('Project path must be absolute or use ~/... expansion.');
|
||||
}
|
||||
const canonicalPath = path.resolve(expandedPath);
|
||||
const projects = ensureObject(target, 'projects');
|
||||
|
||||
if (!isNonEmptyString(input.trustLevel)) {
|
||||
delete projects[canonicalPath];
|
||||
deleteIfEmpty(target, 'projects');
|
||||
return;
|
||||
}
|
||||
|
||||
const trustLevel = input.trustLevel.trim();
|
||||
if (!PROJECT_TRUST_LEVEL_VALUES.has(trustLevel)) {
|
||||
throw new TomlFileValidationError(
|
||||
`trust_level must be one of: ${Array.from(PROJECT_TRUST_LEVEL_VALUES).join(', ')}.`
|
||||
);
|
||||
}
|
||||
|
||||
projects[canonicalPath] = {
|
||||
...(asObject(projects[canonicalPath]) ?? {}),
|
||||
trust_level: trustLevel,
|
||||
};
|
||||
}
|
||||
|
||||
function applyFeaturePatch(
|
||||
target: Record<string, unknown>,
|
||||
input: Extract<CodexConfigPatchInput, { kind: 'feature' }>
|
||||
) {
|
||||
const feature = input.feature.trim();
|
||||
const currentFeatures = asObject(target.features);
|
||||
if (
|
||||
!feature ||
|
||||
(!KNOWN_CODEX_FEATURES.has(feature) && !(currentFeatures && hasOwn(currentFeatures, feature)))
|
||||
) {
|
||||
throw new TomlFileValidationError(`Unsupported feature key "${input.feature}".`);
|
||||
}
|
||||
if (input.enabled !== null && typeof input.enabled !== 'boolean') {
|
||||
throw new TomlFileValidationError('Feature enabled must be boolean or null.');
|
||||
}
|
||||
|
||||
const features = ensureObject(target, 'features');
|
||||
if (input.enabled === null) {
|
||||
delete features[feature];
|
||||
} else {
|
||||
features[feature] = input.enabled;
|
||||
}
|
||||
deleteIfEmpty(target, 'features');
|
||||
}
|
||||
|
||||
function applyProfilePatch(
|
||||
target: Record<string, unknown>,
|
||||
input: Extract<CodexConfigPatchInput, { kind: 'profile' }>
|
||||
) {
|
||||
if (!isNonEmptyString(input.name)) {
|
||||
throw new TomlFileValidationError('Profile name is required.');
|
||||
}
|
||||
|
||||
const profileName = input.name.trim();
|
||||
|
||||
if (!['set-active', 'upsert', 'delete'].includes(input.action)) {
|
||||
throw new TomlFileValidationError('Unsupported profile action.');
|
||||
}
|
||||
|
||||
if (input.action === 'set-active') {
|
||||
setStringField(target, 'profile', profileName);
|
||||
return;
|
||||
}
|
||||
|
||||
const profiles = ensureObject(target, 'profiles');
|
||||
if (input.action === 'delete') {
|
||||
delete profiles[profileName];
|
||||
if (asString(target.profile) === profileName) {
|
||||
delete target.profile;
|
||||
}
|
||||
deleteIfEmpty(target, 'profiles');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextProfile = { ...(asObject(profiles[profileName]) ?? {}) };
|
||||
applyTopLevelSettingsPatch(nextProfile, input.values ?? {});
|
||||
if (Object.keys(nextProfile).length === 0) {
|
||||
throw new TomlFileValidationError('Profile patch must include at least one saved field.');
|
||||
}
|
||||
profiles[profileName] = nextProfile;
|
||||
if (input.setAsActive === true) {
|
||||
target.profile = profileName;
|
||||
}
|
||||
}
|
||||
|
||||
function applyModelProviderPatch(
|
||||
target: Record<string, unknown>,
|
||||
input: Extract<CodexConfigPatchInput, { kind: 'model-provider' }>
|
||||
) {
|
||||
if (!isNonEmptyString(input.name)) {
|
||||
throw new TomlFileValidationError('Model provider name is required.');
|
||||
}
|
||||
const providerName = input.name.trim();
|
||||
const providers = ensureObject(target, 'model_providers');
|
||||
|
||||
if (!['upsert', 'delete'].includes(input.action)) {
|
||||
throw new TomlFileValidationError('Unsupported model provider action.');
|
||||
}
|
||||
|
||||
if (input.action === 'delete') {
|
||||
delete providers[providerName];
|
||||
if (asString(target.model_provider) === providerName) {
|
||||
delete target.model_provider;
|
||||
}
|
||||
deleteIfEmpty(target, 'model_providers');
|
||||
return;
|
||||
}
|
||||
|
||||
const values = input.values;
|
||||
if (!values) {
|
||||
throw new TomlFileValidationError('Model provider values are required.');
|
||||
}
|
||||
|
||||
const nextProvider = { ...(asObject(providers[providerName]) ?? {}) };
|
||||
if (hasOwn(values, 'displayName')) setStringField(nextProvider, 'name', values.displayName);
|
||||
if (hasOwn(values, 'baseUrl')) setStringField(nextProvider, 'base_url', values.baseUrl);
|
||||
if (hasOwn(values, 'envKey')) setStringField(nextProvider, 'env_key', values.envKey);
|
||||
if (hasOwn(values, 'wireApi')) {
|
||||
if (values.wireApi !== null && values.wireApi !== undefined && values.wireApi !== 'responses') {
|
||||
throw new TomlFileValidationError('wire_api must be "responses" for Codex model providers.');
|
||||
}
|
||||
setStringField(nextProvider, 'wire_api', values.wireApi);
|
||||
}
|
||||
if (hasOwn(values, 'requiresOpenaiAuth')) {
|
||||
setBooleanField(nextProvider, 'requires_openai_auth', values.requiresOpenaiAuth);
|
||||
}
|
||||
if (hasOwn(values, 'supportsWebsockets')) {
|
||||
setBooleanField(nextProvider, 'supports_websockets', values.supportsWebsockets);
|
||||
}
|
||||
|
||||
if (Object.keys(nextProvider).length === 0) {
|
||||
throw new TomlFileValidationError(
|
||||
'Model provider patch must include at least one saved field.'
|
||||
);
|
||||
}
|
||||
providers[providerName] = nextProvider;
|
||||
}
|
||||
|
||||
function applyMcpServerPatch(
|
||||
target: Record<string, unknown>,
|
||||
input: Extract<CodexConfigPatchInput, { kind: 'mcp-server' }>
|
||||
) {
|
||||
if (!isNonEmptyString(input.name)) {
|
||||
throw new TomlFileValidationError('MCP server name is required.');
|
||||
}
|
||||
|
||||
const serverName = input.name.trim();
|
||||
const servers = ensureObject(target, 'mcp_servers');
|
||||
|
||||
if (!['upsert', 'delete'].includes(input.action)) {
|
||||
throw new TomlFileValidationError('Unsupported MCP server action.');
|
||||
}
|
||||
|
||||
if (input.action === 'delete') {
|
||||
delete servers[serverName];
|
||||
deleteIfEmpty(target, 'mcp_servers');
|
||||
return;
|
||||
}
|
||||
|
||||
const values = input.values;
|
||||
if (!values) {
|
||||
throw new TomlFileValidationError('MCP server values are required.');
|
||||
}
|
||||
if (values.transport !== 'stdio' && values.transport !== 'streamable-http') {
|
||||
throw new TomlFileValidationError('MCP transport must be "stdio" or "streamable-http".');
|
||||
}
|
||||
|
||||
const nextServer = { ...(asObject(servers[serverName]) ?? {}) };
|
||||
if (values.transport === 'stdio') {
|
||||
if (!isNonEmptyString(values.command)) {
|
||||
throw new TomlFileValidationError('Stdio MCP servers require a command.');
|
||||
}
|
||||
nextServer.command = values.command.trim();
|
||||
const nextArgs = normalizeStringArray(values.args, 'args');
|
||||
if (nextArgs === null) {
|
||||
delete nextServer.args;
|
||||
} else {
|
||||
nextServer.args = nextArgs;
|
||||
}
|
||||
delete nextServer.url;
|
||||
} else {
|
||||
if (!isNonEmptyString(values.url)) {
|
||||
throw new TomlFileValidationError('HTTP MCP servers require a URL.');
|
||||
}
|
||||
nextServer.url = values.url.trim();
|
||||
delete nextServer.command;
|
||||
delete nextServer.args;
|
||||
}
|
||||
|
||||
if (hasOwn(values, 'enabled')) setBooleanField(nextServer, 'enabled', values.enabled);
|
||||
if (hasOwn(values, 'required')) setBooleanField(nextServer, 'required', values.required);
|
||||
if (hasOwn(values, 'startupTimeoutSec')) {
|
||||
delete nextServer.startup_timeout_ms;
|
||||
setNumberField(nextServer, 'startup_timeout_sec', values.startupTimeoutSec, { min: 1 });
|
||||
}
|
||||
if (hasOwn(values, 'toolTimeoutSec')) {
|
||||
setNumberField(nextServer, 'tool_timeout_sec', values.toolTimeoutSec, { min: 1 });
|
||||
}
|
||||
if (hasOwn(values, 'enabledTools')) {
|
||||
const nextEnabledTools = normalizeStringArray(values.enabledTools, 'enabledTools');
|
||||
if (nextEnabledTools === null) {
|
||||
delete nextServer.enabled_tools;
|
||||
} else {
|
||||
nextServer.enabled_tools = nextEnabledTools;
|
||||
}
|
||||
}
|
||||
if (hasOwn(values, 'disabledTools')) {
|
||||
const nextDisabledTools = normalizeStringArray(values.disabledTools, 'disabledTools');
|
||||
if (nextDisabledTools === null) {
|
||||
delete nextServer.disabled_tools;
|
||||
} else {
|
||||
nextServer.disabled_tools = nextDisabledTools;
|
||||
}
|
||||
}
|
||||
|
||||
servers[serverName] = nextServer;
|
||||
}
|
||||
|
||||
function parseTransport(server: Record<string, unknown>): CodexMcpServerDiagnostics['transport'] {
|
||||
if (asString(server.command)) return 'stdio';
|
||||
if (asString(server.url)) return 'streamable-http';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function resolveCodexConfigPaths(
|
||||
options: {
|
||||
platform?: NodeJS.Platform;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homeDir?: string;
|
||||
} = {}
|
||||
): CodexConfigPaths {
|
||||
const env = options.env ?? process.env;
|
||||
const homeDir = options.homeDir ?? os.homedir();
|
||||
const baseDir = path.resolve(
|
||||
env.CODEX_HOME ? expandPath(env.CODEX_HOME) : path.join(homeDir, '.codex')
|
||||
);
|
||||
const baseDirDisplay = env.CODEX_HOME ? '$CODEX_HOME' : '~/.codex';
|
||||
|
||||
return {
|
||||
baseDir,
|
||||
baseDirDisplay,
|
||||
configPath: path.join(baseDir, 'config.toml'),
|
||||
configDisplayPath: `${baseDirDisplay}/config.toml`,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeCodexModelProviders(value: unknown): CodexModelProviderDiagnostics[] {
|
||||
const providers = asObject(value);
|
||||
if (!providers) return [];
|
||||
|
||||
return Object.entries(providers)
|
||||
.map(([name, providerValue]) => {
|
||||
const provider = asObject(providerValue);
|
||||
if (!provider) return null;
|
||||
|
||||
return {
|
||||
name,
|
||||
baseUrl: asString(provider.base_url),
|
||||
envKey: asString(provider.env_key),
|
||||
wireApi: asString(provider.wire_api),
|
||||
requiresOpenaiAuth: provider.requires_openai_auth === true,
|
||||
supportsWebsockets: provider.supports_websockets === true,
|
||||
hasQueryParams:
|
||||
isObject(provider.query_params) && Object.keys(provider.query_params).length > 0,
|
||||
hasHttpHeaders:
|
||||
(isObject(provider.http_headers) && Object.keys(provider.http_headers).length > 0) ||
|
||||
(isObject(provider.env_http_headers) &&
|
||||
Object.keys(provider.env_http_headers).length > 0),
|
||||
usesExperimentalBearerToken: asString(provider.experimental_bearer_token) !== null,
|
||||
} satisfies CodexModelProviderDiagnostics;
|
||||
})
|
||||
.filter((provider): provider is CodexModelProviderDiagnostics => provider !== null)
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
export function summarizeCodexFeatureFlags(value: unknown): {
|
||||
all: CodexFeatureFlagDiagnostics[];
|
||||
enabled: CodexFeatureFlagDiagnostics[];
|
||||
disabled: CodexFeatureFlagDiagnostics[];
|
||||
} {
|
||||
const features = asObject(value);
|
||||
if (!features) {
|
||||
return { all: [], enabled: [], disabled: [] };
|
||||
}
|
||||
|
||||
const all = Object.entries(features)
|
||||
.map(([name, rawValue]) => {
|
||||
const state = rawValue === true ? 'enabled' : rawValue === false ? 'disabled' : 'custom';
|
||||
return { name, state } satisfies CodexFeatureFlagDiagnostics;
|
||||
})
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
return {
|
||||
all,
|
||||
enabled: all.filter((feature) => feature.state === 'enabled'),
|
||||
disabled: all.filter((feature) => feature.state === 'disabled'),
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeCodexProjectTrust(value: unknown): CodexProjectTrustDiagnostics[] {
|
||||
const projects = asObject(value);
|
||||
if (!projects) return [];
|
||||
|
||||
return Object.entries(projects)
|
||||
.map(([projectPath, projectValue]) => {
|
||||
const project = asObject(projectValue);
|
||||
const trustLevel = project ? asString(project.trust_level) : null;
|
||||
if (!trustLevel) return null;
|
||||
return { path: projectPath, trustLevel } satisfies CodexProjectTrustDiagnostics;
|
||||
})
|
||||
.filter((project): project is CodexProjectTrustDiagnostics => project !== null)
|
||||
.sort((left, right) => left.path.localeCompare(right.path));
|
||||
}
|
||||
|
||||
export function summarizeCodexMcpServers(value: unknown): CodexMcpServerDiagnostics[] {
|
||||
const servers = asObject(value);
|
||||
if (!servers) return [];
|
||||
|
||||
return Object.entries(servers)
|
||||
.map(([name, serverValue]) => {
|
||||
const server = asObject(serverValue);
|
||||
if (!server) return null;
|
||||
|
||||
const startupTimeoutMs = asNumber(server.startup_timeout_ms);
|
||||
const startupTimeoutSec =
|
||||
asNumber(server.startup_timeout_sec) ??
|
||||
(startupTimeoutMs !== null ? startupTimeoutMs / 1000 : null);
|
||||
|
||||
return {
|
||||
name,
|
||||
transport: parseTransport(server),
|
||||
enabled: server.enabled !== false,
|
||||
required: server.required === true,
|
||||
startupTimeoutSec,
|
||||
toolTimeoutSec: asNumber(server.tool_timeout_sec),
|
||||
enabledToolsCount: Array.isArray(server.enabled_tools) ? server.enabled_tools.length : 0,
|
||||
disabledToolsCount: Array.isArray(server.disabled_tools) ? server.disabled_tools.length : 0,
|
||||
usesInlineBearerToken: hasOwn(server, 'bearer_token'),
|
||||
} satisfies CodexMcpServerDiagnostics;
|
||||
})
|
||||
.filter((server): server is CodexMcpServerDiagnostics => server !== null)
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
function getCodexSupportMatrix(): CodexSupportMatrixEntry[] {
|
||||
return [
|
||||
{
|
||||
id: 'default',
|
||||
label: 'default',
|
||||
supported: true,
|
||||
notes: 'Uses the local Codex CLI with existing native auth and config.',
|
||||
},
|
||||
{
|
||||
id: 'cliproxy-provider-codex',
|
||||
label: 'cliproxy provider=codex',
|
||||
supported: true,
|
||||
notes: 'Routed through the CLIProxy Codex Responses bridge.',
|
||||
},
|
||||
{
|
||||
id: 'settings-with-bridge',
|
||||
label: 'settings with bridge metadata',
|
||||
supported: true,
|
||||
notes: 'Supported when the resolved API profile points at a Codex CLIProxy bridge.',
|
||||
},
|
||||
{
|
||||
id: 'cliproxy-composite',
|
||||
label: 'cliproxy composite',
|
||||
supported: false,
|
||||
notes: 'Not yet proven safe for native Codex routing in CCS v1.',
|
||||
},
|
||||
{
|
||||
id: 'settings-generic-api',
|
||||
label: 'settings generic API profile',
|
||||
supported: false,
|
||||
notes: 'Generic API profiles stay on Claude or Droid targets.',
|
||||
},
|
||||
{
|
||||
id: 'account',
|
||||
label: 'account',
|
||||
supported: false,
|
||||
notes: 'Account isolation remains a Claude-oriented concept.',
|
||||
},
|
||||
{
|
||||
id: 'copilot',
|
||||
label: 'copilot',
|
||||
supported: false,
|
||||
notes: 'GitHub Copilot flow is not a native Codex target path.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function getCodexDashboardDiagnostics(): Promise<CodexDashboardDiagnostics> {
|
||||
const paths = resolveCodexConfigPaths();
|
||||
const binaryInfo = getCodexBinaryInfo();
|
||||
const docsReference = getCompatibleCliDocsReference('codex');
|
||||
const fileProbe = await probeTomlObjectFile(
|
||||
paths.configPath,
|
||||
'Codex user config',
|
||||
paths.configDisplayPath
|
||||
);
|
||||
const config = asObject(fileProbe.config);
|
||||
const topLevelKeys = config
|
||||
? Object.keys(config).sort((left, right) => left.localeCompare(right))
|
||||
: [];
|
||||
const activeProfile = asString(config?.profile);
|
||||
const profileNames = Object.keys(asObject(config?.profiles) ?? {}).sort((left, right) =>
|
||||
left.localeCompare(right)
|
||||
);
|
||||
const modelProviders = summarizeCodexModelProviders(config?.model_providers);
|
||||
const features = summarizeCodexFeatureFlags(config?.features);
|
||||
const projectTrust = summarizeCodexProjectTrust(config?.projects);
|
||||
const mcpServers = summarizeCodexMcpServers(config?.mcp_servers);
|
||||
const supportMatrix = getCodexSupportMatrix();
|
||||
|
||||
const warnings: string[] = [];
|
||||
if (!binaryInfo) {
|
||||
warnings.push('Codex binary is not detected in PATH or CCS_CODEX_PATH.');
|
||||
} else if (!codexBinarySupportsConfigOverrides(binaryInfo)) {
|
||||
warnings.push(
|
||||
'This Codex build does not expose --config overrides required for CCS-backed Codex routing.'
|
||||
);
|
||||
}
|
||||
|
||||
if (fileProbe.diagnostics.parseError) {
|
||||
warnings.push(`${paths.configDisplayPath} contains invalid TOML.`);
|
||||
}
|
||||
if (activeProfile && !profileNames.includes(activeProfile)) {
|
||||
warnings.push(`Active profile "${activeProfile}" is selected but missing from [profiles].`);
|
||||
}
|
||||
if (modelProviders.some((provider) => provider.usesExperimentalBearerToken)) {
|
||||
warnings.push(
|
||||
'One or more model_providers entries use experimental_bearer_token; prefer env_key-backed auth.'
|
||||
);
|
||||
}
|
||||
if (mcpServers.some((server) => server.usesInlineBearerToken)) {
|
||||
warnings.push(
|
||||
'One or more mcp_servers entries include inline bearer_token; prefer bearer_token_env_var.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
binary: {
|
||||
installed: !!binaryInfo,
|
||||
path: binaryInfo?.path ?? null,
|
||||
installDir: binaryInfo?.path ? path.dirname(binaryInfo.path) : null,
|
||||
source: process.env.CCS_CODEX_PATH ? 'CCS_CODEX_PATH' : binaryInfo ? 'PATH' : 'missing',
|
||||
version: binaryInfo?.version ?? null,
|
||||
overridePath: process.env.CCS_CODEX_PATH || null,
|
||||
supportsConfigOverrides: codexBinarySupportsConfigOverrides(binaryInfo),
|
||||
},
|
||||
file: fileProbe.diagnostics,
|
||||
workspacePath: process.cwd(),
|
||||
config: {
|
||||
model: asString(config?.model),
|
||||
modelReasoningEffort: asString(config?.model_reasoning_effort),
|
||||
modelProvider: asString(config?.model_provider),
|
||||
activeProfile,
|
||||
approvalPolicy: summarizeApprovalPolicy(config?.approval_policy),
|
||||
sandboxMode: asString(config?.sandbox_mode),
|
||||
webSearch: asString(config?.web_search),
|
||||
toolOutputTokenLimit: asNumber(config?.tool_output_token_limit),
|
||||
personality: asString(config?.personality),
|
||||
topLevelKeys,
|
||||
profileCount: profileNames.length,
|
||||
profileNames,
|
||||
modelProviderCount: modelProviders.length,
|
||||
modelProviders,
|
||||
featureCount: features.all.length,
|
||||
enabledFeatures: features.enabled,
|
||||
disabledFeatures: features.disabled,
|
||||
trustedProjectCount: projectTrust.filter((entry) => entry.trustLevel === 'trusted').length,
|
||||
untrustedProjectCount: projectTrust.filter((entry) => entry.trustLevel !== 'trusted').length,
|
||||
projectTrust,
|
||||
mcpServerCount: mcpServers.length,
|
||||
mcpServers,
|
||||
},
|
||||
supportMatrix,
|
||||
warnings,
|
||||
docsReference,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCodexRawConfig(): Promise<CodexRawConfigResponse> {
|
||||
const paths = resolveCodexConfigPaths();
|
||||
const fileProbe = await probeTomlObjectFile(
|
||||
paths.configPath,
|
||||
'Codex user config',
|
||||
paths.configDisplayPath
|
||||
);
|
||||
|
||||
return {
|
||||
path: paths.configDisplayPath,
|
||||
resolvedPath: paths.configPath,
|
||||
exists: fileProbe.diagnostics.exists,
|
||||
mtime: fileProbe.diagnostics.mtimeMs ?? Date.now(),
|
||||
rawText: fileProbe.rawText,
|
||||
config: fileProbe.config,
|
||||
parseError: fileProbe.diagnostics.parseError,
|
||||
readError: fileProbe.diagnostics.readError,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveCodexRawConfig(
|
||||
input: SaveCodexRawConfigInput
|
||||
): Promise<SaveCodexRawConfigResult> {
|
||||
const paths = resolveCodexConfigPaths();
|
||||
if (typeof input.rawText !== 'string') {
|
||||
throw new TomlFileValidationError('rawText must be a string.');
|
||||
}
|
||||
|
||||
const saved = await writeTomlFileAtomic({
|
||||
filePath: paths.configPath,
|
||||
rawText: input.rawText,
|
||||
expectedMtime: input.expectedMtime,
|
||||
fileLabel: 'config.toml',
|
||||
});
|
||||
|
||||
return { success: true, mtime: saved.mtime };
|
||||
}
|
||||
|
||||
export async function patchCodexConfig(
|
||||
input: CodexConfigPatchInput
|
||||
): Promise<CodexConfigPatchResult> {
|
||||
const paths = resolveCodexConfigPaths();
|
||||
const fileProbe = await probeTomlObjectFile(
|
||||
paths.configPath,
|
||||
'Codex user config',
|
||||
paths.configDisplayPath
|
||||
);
|
||||
const nextConfig = { ...assertPatchableToml(fileProbe) };
|
||||
|
||||
switch (input.kind) {
|
||||
case 'top-level':
|
||||
applyTopLevelSettingsPatch(nextConfig, input.values);
|
||||
break;
|
||||
case 'project-trust':
|
||||
applyProjectTrustPatch(nextConfig, input);
|
||||
break;
|
||||
case 'feature':
|
||||
applyFeaturePatch(nextConfig, input);
|
||||
break;
|
||||
case 'profile':
|
||||
applyProfilePatch(nextConfig, input);
|
||||
break;
|
||||
case 'model-provider':
|
||||
applyModelProviderPatch(nextConfig, input);
|
||||
break;
|
||||
case 'mcp-server':
|
||||
applyMcpServerPatch(nextConfig, input);
|
||||
break;
|
||||
default:
|
||||
throw new TomlFileValidationError('Unsupported Codex config patch.');
|
||||
}
|
||||
|
||||
const rawText = stringifyTomlObject(nextConfig);
|
||||
const saved = await writeTomlFileAtomic({
|
||||
filePath: paths.configPath,
|
||||
rawText,
|
||||
expectedMtime: input.expectedMtime ?? fileProbe.diagnostics.mtimeMs ?? undefined,
|
||||
fileLabel: 'config.toml',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: paths.configDisplayPath,
|
||||
resolvedPath: paths.configPath,
|
||||
exists: true,
|
||||
mtime: saved.mtime,
|
||||
rawText,
|
||||
config: nextConfig,
|
||||
parseError: null,
|
||||
readError: null,
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ export interface CompatibleCliDocLink {
|
||||
label: string;
|
||||
url: string;
|
||||
category: 'overview' | 'configuration' | 'byok' | 'reference';
|
||||
source: 'factory' | 'provider';
|
||||
source: 'factory' | 'provider' | 'openai' | 'github';
|
||||
description: string;
|
||||
}
|
||||
|
||||
@@ -96,6 +96,72 @@ const COMPATIBLE_CLI_DOCS_REGISTRY: Record<string, CompatibleCliDocsRegistryEntr
|
||||
],
|
||||
},
|
||||
},
|
||||
codex: {
|
||||
cliId: 'codex',
|
||||
displayName: 'Codex CLI',
|
||||
docsReference: {
|
||||
providerValues: ['openai', 'oss', 'custom model_providers'],
|
||||
settingsHierarchy: [
|
||||
'system managed config',
|
||||
'user config ($CODEX_HOME/config.toml)',
|
||||
'cwd config',
|
||||
'tree/repo config',
|
||||
'CLI -c overrides and environment variables',
|
||||
],
|
||||
notes: [
|
||||
'User config lives at ~/.codex/config.toml unless CODEX_HOME overrides the base directory',
|
||||
'Codex merges multiple config layers; this dashboard edits only the user layer',
|
||||
'CLI --profile selects a named [profiles.<name>] overlay on top of base config',
|
||||
'CCS-backed Codex launches may apply transient -c overrides and CCS_CODEX_API_KEY',
|
||||
'Official docs treat model_providers, mcp_servers, features, and project trust as schema-backed config surfaces',
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 'codex-config-basic',
|
||||
label: 'Codex Config Basics',
|
||||
url: 'https://developers.openai.com/codex/config-basic',
|
||||
category: 'overview',
|
||||
source: 'openai',
|
||||
description:
|
||||
'Official user-layer setup, config location, and basic configuration guidance.',
|
||||
},
|
||||
{
|
||||
id: 'codex-config-advanced',
|
||||
label: 'Codex Config Advanced',
|
||||
url: 'https://developers.openai.com/codex/config-advanced',
|
||||
category: 'configuration',
|
||||
source: 'openai',
|
||||
description: 'Advanced layering, project trust, profiles, and stricter config behaviors.',
|
||||
},
|
||||
{
|
||||
id: 'codex-config-reference',
|
||||
label: 'Codex Config Reference',
|
||||
url: 'https://developers.openai.com/codex/config-reference',
|
||||
category: 'reference',
|
||||
source: 'openai',
|
||||
description:
|
||||
'Canonical upstream config schema surface for model providers, features, MCP, and more.',
|
||||
},
|
||||
{
|
||||
id: 'codex-releases',
|
||||
label: 'Codex GitHub Releases',
|
||||
url: 'https://github.com/openai/codex/releases',
|
||||
category: 'reference',
|
||||
source: 'github',
|
||||
description:
|
||||
'Track CLI release notes and upstream behavior changes across stable and prerelease builds.',
|
||||
},
|
||||
],
|
||||
providerDocs: [
|
||||
{
|
||||
provider: 'openai',
|
||||
label: 'OpenAI Responses API',
|
||||
apiFormat: 'Responses API',
|
||||
url: 'https://platform.openai.com/docs/api-reference/responses',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function getCompatibleCliDocsReference(cliId: string): CompatibleCliDocsReference {
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
import { stringify } from 'smol-toml';
|
||||
import { parseTomlObject } from '../../shared/toml-object';
|
||||
|
||||
export interface TomlFileDiagnostics {
|
||||
label: string;
|
||||
path: string;
|
||||
resolvedPath: string;
|
||||
exists: boolean;
|
||||
isSymlink: boolean;
|
||||
isRegularFile: boolean;
|
||||
sizeBytes: number | null;
|
||||
mtimeMs: number | null;
|
||||
parseError: string | null;
|
||||
readError: string | null;
|
||||
}
|
||||
|
||||
export interface TomlFileProbe {
|
||||
diagnostics: TomlFileDiagnostics;
|
||||
config: Record<string, unknown> | null;
|
||||
rawText: string;
|
||||
}
|
||||
|
||||
interface WriteTomlFileInput {
|
||||
filePath: string;
|
||||
rawText: string;
|
||||
expectedMtime?: number;
|
||||
fileLabel?: string;
|
||||
dirMode?: number;
|
||||
fileMode?: number;
|
||||
}
|
||||
|
||||
interface WriteTomlFileResult {
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
export class TomlFileValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TomlFileValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TomlFileConflictError extends Error {
|
||||
readonly code = 'CONFLICT';
|
||||
readonly mtime: number;
|
||||
|
||||
constructor(message: string, mtime: number) {
|
||||
super(message);
|
||||
this.name = 'TomlFileConflictError';
|
||||
this.mtime = mtime;
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function statPath(filePath: string): Promise<import('fs').Stats | null> {
|
||||
try {
|
||||
return await fs.lstat(filePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveConflictMtime(filePath: string): Promise<number> {
|
||||
const stat = await statPath(filePath);
|
||||
return stat?.mtimeMs ?? Date.now();
|
||||
}
|
||||
|
||||
async function acquireWriteLock(
|
||||
lockPath: string,
|
||||
targetPath: string,
|
||||
fileLabel: string
|
||||
): Promise<() => Promise<void>> {
|
||||
let handle: Awaited<ReturnType<typeof fs.open>> | null = null;
|
||||
try {
|
||||
handle = await fs.open(lockPath, 'wx', 0o600);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'EEXIST') {
|
||||
const existingLock = await statPath(lockPath);
|
||||
if (existingLock?.isSymbolicLink()) {
|
||||
throw new Error(`Refusing to write: ${fileLabel}.lock is a symlink.`);
|
||||
}
|
||||
if (existingLock && !existingLock.isFile()) {
|
||||
throw new Error(`Refusing to write: ${fileLabel}.lock is not a regular file.`);
|
||||
}
|
||||
throw new TomlFileConflictError(
|
||||
'File is currently being written by another request. Refresh and retry.',
|
||||
await resolveConflictMtime(targetPath)
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return async () => {
|
||||
if (!handle) return;
|
||||
try {
|
||||
await handle.close();
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(lockPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function ensureWritableTarget(
|
||||
targetStat: import('fs').Stats | null,
|
||||
fileLabel: string
|
||||
): import('fs').Stats | null {
|
||||
if (!targetStat) return null;
|
||||
if (targetStat.isSymbolicLink()) {
|
||||
throw new Error(`Refusing to write: ${fileLabel} is a symlink.`);
|
||||
}
|
||||
if (!targetStat.isFile()) {
|
||||
throw new Error(`Refusing to write: ${fileLabel} is not a regular file.`);
|
||||
}
|
||||
return targetStat;
|
||||
}
|
||||
|
||||
function assertExpectedMtime(
|
||||
targetStat: import('fs').Stats | null,
|
||||
expectedMtime: number | undefined
|
||||
): void {
|
||||
if (!targetStat) {
|
||||
if (expectedMtime !== undefined) {
|
||||
throw new TomlFileConflictError('File modified externally.', Date.now());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof expectedMtime !== 'number' || !Number.isFinite(expectedMtime)) {
|
||||
throw new TomlFileConflictError(
|
||||
'File metadata not loaded. Refresh and retry.',
|
||||
targetStat.mtimeMs
|
||||
);
|
||||
}
|
||||
if (targetStat.mtimeMs !== expectedMtime) {
|
||||
throw new TomlFileConflictError('File modified externally.', targetStat.mtimeMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyTargetUnchanged(
|
||||
targetPath: string,
|
||||
initialTargetStat: import('fs').Stats | null,
|
||||
fileLabel: string
|
||||
): Promise<void> {
|
||||
const currentTargetStat = ensureWritableTarget(await statPath(targetPath), fileLabel);
|
||||
|
||||
if (!initialTargetStat) {
|
||||
if (currentTargetStat) {
|
||||
throw new TomlFileConflictError('File modified externally.', currentTargetStat.mtimeMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentTargetStat || currentTargetStat.mtimeMs !== initialTargetStat.mtimeMs) {
|
||||
throw new TomlFileConflictError(
|
||||
'File modified externally.',
|
||||
currentTargetStat?.mtimeMs ?? Date.now()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTomlObjectText(
|
||||
rawText: string,
|
||||
fieldName = 'rawText'
|
||||
): Record<string, unknown> {
|
||||
if (typeof rawText !== 'string') {
|
||||
throw new TomlFileValidationError(`${fieldName} must be a string.`);
|
||||
}
|
||||
|
||||
try {
|
||||
return parseTomlObject(rawText);
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
if (message === 'TOML root must be a table.') {
|
||||
throw new TomlFileValidationError(`${fieldName} TOML root must be a table.`);
|
||||
}
|
||||
throw new TomlFileValidationError(`Invalid TOML in ${fieldName}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function stringifyTomlObject(config: Record<string, unknown>): string {
|
||||
if (!isObject(config)) {
|
||||
throw new TomlFileValidationError('config TOML root must be a table.');
|
||||
}
|
||||
|
||||
const text = stringify(config).trimEnd();
|
||||
return text ? `${text}\n` : '';
|
||||
}
|
||||
|
||||
export async function probeTomlObjectFile(
|
||||
filePath: string,
|
||||
label: string,
|
||||
displayPath: string
|
||||
): Promise<TomlFileProbe> {
|
||||
const stat = await statPath(filePath);
|
||||
if (!stat) {
|
||||
return {
|
||||
diagnostics: {
|
||||
label,
|
||||
path: displayPath,
|
||||
resolvedPath: filePath,
|
||||
exists: false,
|
||||
isSymlink: false,
|
||||
isRegularFile: false,
|
||||
sizeBytes: null,
|
||||
mtimeMs: null,
|
||||
parseError: null,
|
||||
readError: null,
|
||||
},
|
||||
config: null,
|
||||
rawText: '',
|
||||
};
|
||||
}
|
||||
|
||||
const diagnostics: TomlFileDiagnostics = {
|
||||
label,
|
||||
path: displayPath,
|
||||
resolvedPath: filePath,
|
||||
exists: true,
|
||||
isSymlink: stat.isSymbolicLink(),
|
||||
isRegularFile: stat.isFile(),
|
||||
sizeBytes: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
parseError: null,
|
||||
readError: null,
|
||||
};
|
||||
|
||||
if (diagnostics.isSymlink) {
|
||||
diagnostics.readError = 'Refusing symlink file for safety.';
|
||||
return { diagnostics, config: null, rawText: '' };
|
||||
}
|
||||
|
||||
if (!diagnostics.isRegularFile) {
|
||||
diagnostics.readError = 'Target is not a regular file.';
|
||||
return { diagnostics, config: null, rawText: '' };
|
||||
}
|
||||
|
||||
try {
|
||||
const rawText = await fs.readFile(filePath, 'utf8');
|
||||
try {
|
||||
const config = parseTomlObjectText(rawText, displayPath);
|
||||
return { diagnostics, config, rawText };
|
||||
} catch (error) {
|
||||
diagnostics.parseError = (error as Error).message;
|
||||
return { diagnostics, config: null, rawText };
|
||||
}
|
||||
} catch (error) {
|
||||
diagnostics.readError = (error as Error).message;
|
||||
return { diagnostics, config: null, rawText: '' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeTomlFileAtomic(input: WriteTomlFileInput): Promise<WriteTomlFileResult> {
|
||||
const fileLabel = input.fileLabel || path.basename(input.filePath);
|
||||
parseTomlObjectText(input.rawText, fileLabel);
|
||||
|
||||
const targetPath = input.filePath;
|
||||
const targetDir = path.dirname(targetPath);
|
||||
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
|
||||
const lockPath = `${targetPath}.lock`;
|
||||
const dirMode = input.dirMode ?? 0o700;
|
||||
const fileMode = input.fileMode ?? 0o600;
|
||||
|
||||
await fs.mkdir(targetDir, { recursive: true, mode: dirMode });
|
||||
|
||||
const releaseLock = await acquireWriteLock(lockPath, targetPath, fileLabel);
|
||||
|
||||
let wroteTemp = false;
|
||||
try {
|
||||
const targetStat = ensureWritableTarget(await statPath(targetPath), fileLabel);
|
||||
assertExpectedMtime(targetStat, input.expectedMtime);
|
||||
|
||||
await fs.writeFile(tempPath, input.rawText, { mode: fileMode, flag: 'wx' });
|
||||
wroteTemp = true;
|
||||
|
||||
const tempStat = await fs.lstat(tempPath);
|
||||
if (tempStat.isSymbolicLink()) {
|
||||
throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`);
|
||||
}
|
||||
if (!tempStat.isFile()) {
|
||||
throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`);
|
||||
}
|
||||
|
||||
await verifyTargetUnchanged(targetPath, targetStat, fileLabel);
|
||||
await fs.rename(tempPath, targetPath);
|
||||
wroteTemp = false;
|
||||
|
||||
try {
|
||||
await fs.chmod(targetPath, fileMode);
|
||||
} catch {
|
||||
// Best-effort permission hardening.
|
||||
}
|
||||
|
||||
const stat = await fs.stat(targetPath);
|
||||
return { mtime: stat.mtimeMs };
|
||||
} finally {
|
||||
if (wroteTemp) {
|
||||
try {
|
||||
await fs.unlink(tempPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,28 @@
|
||||
import type { CompatibleCliDocsReference } from '../../shared/compatible-cli-contracts';
|
||||
|
||||
export type {
|
||||
CompatibleCliDocLink,
|
||||
CompatibleCliDocsReference,
|
||||
CompatibleCliProviderDocLink,
|
||||
CodexBinaryDiagnostics,
|
||||
CodexBinarySource,
|
||||
CodexConfigFileDiagnostics,
|
||||
CodexConfigPatchInput,
|
||||
CodexConfigPatchResult,
|
||||
CodexDashboardDiagnostics,
|
||||
CodexFeatureFlagDiagnostics,
|
||||
CodexMcpServerDiagnostics,
|
||||
CodexMcpServerPatchValues,
|
||||
CodexModelProviderDiagnostics,
|
||||
CodexModelProviderPatchValues,
|
||||
CodexProfilePatchValues,
|
||||
CodexProjectTrustDiagnostics,
|
||||
CodexRawConfigResponse,
|
||||
CodexSupportMatrixEntry,
|
||||
CodexTopLevelSettingsPatch,
|
||||
CodexUserConfigDiagnostics,
|
||||
} from '../../shared/compatible-cli-contracts';
|
||||
|
||||
export type DroidBinarySource = 'CCS_DROID_PATH' | 'PATH' | 'missing';
|
||||
|
||||
export interface DroidBinaryDiagnostics {
|
||||
@@ -44,30 +69,6 @@ export interface DroidByokDiagnostics {
|
||||
customModels: DroidCustomModelDiagnostics[];
|
||||
}
|
||||
|
||||
export interface CompatibleCliDocLink {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
category: 'overview' | 'configuration' | 'byok' | 'reference';
|
||||
source: 'factory' | 'provider';
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CompatibleCliProviderDocLink {
|
||||
provider: string;
|
||||
label: string;
|
||||
apiFormat: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface CompatibleCliDocsReference {
|
||||
providerValues: string[];
|
||||
settingsHierarchy: string[];
|
||||
notes: string[];
|
||||
links: CompatibleCliDocLink[];
|
||||
providerDocs: CompatibleCliProviderDocLink[];
|
||||
}
|
||||
|
||||
export interface DroidDashboardDiagnostics {
|
||||
binary: DroidBinaryDiagnostics;
|
||||
files: {
|
||||
|
||||
Reference in new issue
Block a user