From f9c1238483b0c91d701547c9ad330261a2b51fbb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 28 Mar 2026 19:03:22 -0400 Subject: [PATCH] feat(profiles): expose codex runtime across surfaces - update CLI, API, route, and dashboard surfaces to recognize the Codex runtime target - normalize persisted codex targets back to claude so runtime-only behavior stays truthful - add regression coverage for help text, route parsing, and profile storage normalization Refs #773 --- src/api/services/profile-lifecycle-service.ts | 5 +- src/api/services/profile-reader.ts | 5 +- src/channels/official-channels-runtime.ts | 2 +- src/commands/api-command/create-command.ts | 11 ++ src/commands/api-command/help.ts | 4 + src/commands/api-command/shared.ts | 9 +- src/commands/cliproxy/variant-subcommand.ts | 11 +- src/commands/help-command.ts | 13 +- src/web-server/routes/profile-routes.ts | 17 ++- src/web-server/routes/route-helpers.ts | 5 +- src/web-server/routes/variant-routes.ts | 5 +- tests/unit/api/profile-reader.test.ts | 138 ++++++++++++++++++ tests/unit/commands/api-command-args.test.ts | 7 + .../commands/cliproxy-variant-args.test.ts | 7 + .../unit/commands/help-command-parity.test.ts | 22 +++ .../web-server/target-parse-routes.test.ts | 2 + .../provider-editor/provider-info-tab.tsx | 17 +++ ui/src/lib/model-catalogs.ts | 5 +- ui/src/lib/support-updates-catalog.ts | 73 ++++++++- 19 files changed, 332 insertions(+), 26 deletions(-) create mode 100644 tests/unit/api/profile-reader.test.ts diff --git a/src/api/services/profile-lifecycle-service.ts b/src/api/services/profile-lifecycle-service.ts index 815d253e..e54a3e92 100644 --- a/src/api/services/profile-lifecycle-service.ts +++ b/src/api/services/profile-lifecycle-service.ts @@ -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()}.`, }; } diff --git a/src/api/services/profile-reader.ts b/src/api/services/profile-reader.ts index 4bb99b31..a18f04c2 100644 --- a/src/api/services/profile-reader.ts +++ b/src/api/services/profile-reader.ts @@ -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 = new Set(['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'; diff --git a/src/channels/official-channels-runtime.ts b/src/channels/official-channels-runtime.ts index f708f66e..df5c97b1 100644 --- a/src/channels/official-channels-runtime.ts +++ b/src/channels/official-channels-runtime.ts @@ -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 { diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index 9583b8f8..ccdbe5e6 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -417,6 +417,17 @@ export async function handleApiCreateCommand(args: string[]): Promise { ` ${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; diff --git a/src/commands/api-command/help.ts b/src/commands/api-command/help.ts index 37ddfccf..78c6dcc4 100644 --- a/src/commands/api-command/help.ts +++ b/src/commands/api-command/help.ts @@ -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')}`); diff --git a/src/commands/api-command/shared.ts b/src/commands/api-command/shared.ts index 06ae02c1..37dfe35f 100644 --- a/src/commands/api-command/shared.ts +++ b/src/commands/api-command/shared.ts @@ -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; diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index 44d69d2b..99dcef7f 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -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; } diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 6eca9227..11d7dad5 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -391,7 +391,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); 'Flags', [ ['--config-dir ', 'Use custom CCS config directory'], - ['--target ', 'Target CLI: claude (default), droid'], + ['--target ', '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 [args]', 'Explicit Droid runtime alias'], ['ccsd [args]', 'Legacy shortcut for: ccs-droid [args]'], + ['ccs-codex [args]', 'Explicit Codex runtime alias'], + ['ccsx [args]', 'Short alias for: ccs-codex [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'], [ diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index 800ca5c1..d640d04e 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -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) { diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index 7b8f785e..b45f762a 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -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; } diff --git a/src/web-server/routes/variant-routes.ts b/src/web-server/routes/variant-routes.ts index 483bf11d..c67d3858 100644 --- a/src/web-server/routes/variant-routes.ts +++ b/src/web-server/routes/variant-routes.ts @@ -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; } diff --git a/tests/unit/api/profile-reader.test.ts b/tests/unit/api/profile-reader.test.ts new file mode 100644 index 00000000..f677b2a1 --- /dev/null +++ b/tests/unit/api/profile-reader.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { listApiProfiles } from '../../../src/api/services/profile-reader'; +import { runWithScopedConfigDir, setGlobalConfigDir } from '../../../src/utils/config-manager'; + +describe('profile reader target sanitization', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; + let originalUnifiedMode: string | undefined; + + function getScopedCcsDir(): string { + return path.join(tempHome, '.ccs'); + } + + async function runInScopedCcsDir(fn: () => T): Promise { + return await runWithScopedConfigDir(getScopedCcsDir(), fn); + } + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-reader-')); + originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; + originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG; + process.env.CCS_HOME = tempHome; + delete process.env.CCS_DIR; + delete process.env.CCS_UNIFIED_CONFIG; + setGlobalConfigDir(undefined); + }); + + afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + + if (originalCcsDir === undefined) { + delete process.env.CCS_DIR; + } else { + process.env.CCS_DIR = originalCcsDir; + } + + if (originalUnifiedMode === undefined) { + delete process.env.CCS_UNIFIED_CONFIG; + } else { + process.env.CCS_UNIFIED_CONFIG = originalUnifiedMode; + } + + setGlobalConfigDir(undefined); + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('normalizes legacy stored codex targets back to claude for profiles and variants', async () => { + const ccsDir = getScopedCcsDir(); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify( + { + profiles: { demo: '~/.ccs/demo.settings.json' }, + profile_targets: { demo: 'codex' }, + cliproxy: { + routed: { + provider: 'codex', + settings: '~/.ccs/routed.settings.json', + target: 'codex', + }, + }, + }, + null, + 2 + ) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'demo.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' + ); + + const result = await runInScopedCcsDir(() => listApiProfiles()); + + expect(result.profiles).toHaveLength(1); + expect(result.profiles[0]?.target).toBe('claude'); + expect(result.variants).toHaveLength(1); + expect(result.variants[0]?.target).toBe('claude'); + }); + + it('normalizes unified stored codex targets back to claude for profiles and variants', async () => { + const ccsDir = getScopedCcsDir(); + fs.mkdirSync(ccsDir, { recursive: true }); + process.env.CCS_UNIFIED_CONFIG = '1'; + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'profiles:', + ' demo:', + ' type: api', + ' settings: ~/.ccs/demo.settings.json', + ' target: codex', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: []', + ' variants:', + ' routed:', + ' provider: codex', + ' settings: ~/.ccs/routed.settings.json', + ' target: codex', + '', + ].join('\n'), + 'utf8' + ); + fs.writeFileSync( + path.join(ccsDir, 'demo.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' + ); + + const result = await runInScopedCcsDir(() => listApiProfiles()); + + expect(result.profiles).toHaveLength(1); + expect(result.profiles[0]?.target).toBe('claude'); + expect(result.variants).toHaveLength(1); + expect(result.variants[0]?.target).toBe('claude'); + }); +}); diff --git a/tests/unit/commands/api-command-args.test.ts b/tests/unit/commands/api-command-args.test.ts index 19727ec2..1f5dee30 100644 --- a/tests/unit/commands/api-command-args.test.ts +++ b/tests/unit/commands/api-command-args.test.ts @@ -75,6 +75,13 @@ describe('api-command arg parser', () => { ]); }); + test('rejects runtime-only codex as a persisted API target value', () => { + const parsed = parseApiCommandArgs(['my-api', '--target', 'codex']); + + expect(parsed.target).toBeUndefined(); + expect(parsed.errors).toEqual(['Invalid --target value "codex". Use: claude or droid']); + }); + test('collects missing-value error for --target with no value', () => { const parsed = parseApiCommandArgs(['my-api', '--target']); diff --git a/tests/unit/commands/cliproxy-variant-args.test.ts b/tests/unit/commands/cliproxy-variant-args.test.ts index 1d3ad82e..a810bceb 100644 --- a/tests/unit/commands/cliproxy-variant-args.test.ts +++ b/tests/unit/commands/cliproxy-variant-args.test.ts @@ -26,6 +26,13 @@ describe('cliproxy variant arg parser', () => { expect(parsed.errors).toEqual(['Missing value for --target']); }); + test('rejects runtime-only codex as a persisted variant target value', () => { + const parsed = parseProfileArgs(['variant-a', '--target', 'codex']); + + expect(parsed.target).toBeUndefined(); + expect(parsed.errors).toEqual(['Invalid --target value "codex". Use: claude or droid']); + }); + test('uses last --target value when repeated', () => { const parsed = parseProfileArgs(['variant-a', '--target', 'claude', '--target=droid']); diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index 04efd788..41619e6b 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -99,6 +99,18 @@ describe('help command parity', () => { expect(rendered.includes('return 429 extra-usage errors for long-context requests')).toBe(true); }); + test('root help documents native Codex runtime alias and runtime-only scope', async () => { + const lines: string[] = []; + await handleHelpCommand((line) => lines.push(line)); + + const rendered = stripAnsi(lines.join('\n')); + expect(rendered.includes('ccs-codex [args]')).toBe(true); + expect(rendered.includes('ccsx [args]')).toBe(true); + expect(rendered.includes('ccs --target codex')).toBe(true); + expect(rendered.includes('ccs codex-api --target codex')).toBe(true); + expect(rendered.includes('codex (runtime-only)')).toBe(true); + }); + test('api help documents create-time Claude [1m] flags and entitlement warning', async () => { const lines: string[] = []; await showApiCommandHelp((line) => lines.push(line)); @@ -113,4 +125,14 @@ describe('help command parity', () => { true ); }); + + test('api help documents Codex bridge runtime launch separately from persisted targets', async () => { + const lines: string[] = []; + await showApiCommandHelp((line) => lines.push(line)); + + const rendered = stripAnsi(lines.join('\n')); + expect(rendered.includes('ccs api create codex-api --cliproxy-provider codex')).toBe(true); + expect(rendered.includes('ccs codex-api --target codex')).toBe(true); + expect(rendered.includes('Default target: claude or droid (create)')).toBe(true); + }); }); diff --git a/tests/unit/web-server/target-parse-routes.test.ts b/tests/unit/web-server/target-parse-routes.test.ts index 2852a749..b0446dab 100644 --- a/tests/unit/web-server/target-parse-routes.test.ts +++ b/tests/unit/web-server/target-parse-routes.test.ts @@ -13,8 +13,10 @@ describe('route target parsing', () => { it('returns null for invalid target values', () => { expect(parseProfileTarget('glm')).toBeNull(); + expect(parseProfileTarget('codex')).toBeNull(); expect(parseProfileTarget('')).toBeNull(); expect(parseVariantTarget('factory')).toBeNull(); + expect(parseVariantTarget('codex')).toBeNull(); expect(parseVariantTarget(' ')).toBeNull(); }); diff --git a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx index bc6c8f5f..8cde0f8c 100644 --- a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx @@ -28,6 +28,7 @@ export function ProviderInfoTab({ }: ProviderInfoTabProps) { const resolvedTarget = defaultTarget || 'claude'; const isDroidTarget = resolvedTarget === 'droid'; + const isCodexProvider = provider === 'codex'; return ( @@ -88,6 +89,22 @@ export function ProviderInfoTab({

Quick Usage

+ {isCodexProvider && ( + <> + + + + + )} compareGeminiVersions(right.info.version, left.info.version))[0]?.model; } -export function resolveCatalogModelId(modelId: string, availableModels: CatalogAvailableModel[] = []): string { +export function resolveCatalogModelId( + modelId: string, + availableModels: CatalogAvailableModel[] = [] +): string { const normalizedModelId = normalizeModelId(modelId); const liveGeminiModelId = resolveGeminiPreviewModelId(normalizedModelId, availableModels); if (liveGeminiModelId) return liveGeminiModelId; diff --git a/ui/src/lib/support-updates-catalog.ts b/ui/src/lib/support-updates-catalog.ts index 7a7097c1..bd3c8066 100644 --- a/ui/src/lib/support-updates-catalog.ts +++ b/ui/src/lib/support-updates-catalog.ts @@ -55,6 +55,51 @@ export const SUPPORT_SCOPE_LABELS: Record = { }; export const SUPPORT_NOTICES: SupportNotice[] = [ + { + id: 'codex-target-runtime-support', + title: 'Native Codex runtime support is live', + summary: + 'Codex now participates as a first-class runtime target through ccs-codex, ccsx, or --target codex.', + primaryAction: + 'Use Codex as a runtime target for native Codex sessions and Codex-routed CLIProxy flows.', + publishedAt: '2026-03-28', + status: 'new', + scopes: ['target', 'cliproxy', 'api-profiles'], + entryIds: ['codex-target', 'codex-cliproxy'], + highlights: [ + 'Use ccs-codex or ccsx for native Codex runs.', + 'Built-in Codex and Codex bridge profiles can run on native Codex with --target codex.', + 'Saved default targets for API profiles and variants remain claude or droid.', + ], + actions: [ + { + id: 'copy-codex-alias-command', + label: 'Open native Codex', + description: 'Launch Codex through the explicit CCS runtime alias.', + type: 'command', + command: 'ccs-codex', + }, + { + id: 'copy-codex-provider-command', + label: 'Run built-in Codex on Codex', + description: 'Use the built-in Codex provider with native Codex runtime.', + type: 'command', + command: 'ccs codex --target codex "your prompt"', + }, + { + id: 'open-cliproxy-codex', + label: 'Open Codex provider settings', + description: 'Review Codex provider and bridge flows in the dashboard.', + type: 'route', + path: '/cliproxy', + }, + ], + routes: [ + { label: 'CLIProxy', path: '/cliproxy' }, + { label: 'API Profiles', path: '/providers' }, + ], + commands: ['ccs-codex', 'ccsx', 'ccs codex --target codex "your prompt"'], + }, { id: 'droid-target-support', title: 'Factory Droid support is live', @@ -185,6 +230,27 @@ export const CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [ commands: ['ccs-droid glm', 'ccs km --target droid', 'ccs codex --target droid'], notes: 'Use ccs-droid as the explicit runtime alias. Legacy ccsd still works.', }, + { + id: 'codex-target', + name: 'Codex CLI', + scope: 'target', + status: 'new', + summary: + 'First-class runtime target for native Codex sessions and Codex-routed CLIProxy flows.', + pillars: { + baseUrl: + 'Native ~/.codex config for default mode, transient -c overrides for CCS-backed routes', + auth: 'Native Codex auth for default mode, env_key injection for CCS-backed routes', + model: 'Native Codex config or routed Codex model mapping from CLIProxy', + }, + routes: [ + { label: 'CLIProxy', path: '/cliproxy' }, + { label: 'API Profiles', path: '/providers' }, + ], + commands: ['ccs-codex', 'ccsx', 'ccs codex --target codex', 'ccs codex-api --target codex'], + notes: + 'Runtime-only in v1. Saved default targets for API profiles and CLIProxy variants remain claude or droid.', + }, { id: 'codex-cliproxy', name: 'Codex via CLIProxy', @@ -200,7 +266,12 @@ export const CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [ { label: 'CLIProxy', path: '/cliproxy' }, { label: 'Control Panel', path: '/cliproxy/control-panel' }, ], - commands: ['ccs codex', 'ccs cliproxy create mycodex --provider codex'], + commands: [ + 'ccs codex', + 'ccs codex --target codex', + 'ccs cliproxy create mycodex --provider codex', + 'ccs api create codex-api --cliproxy-provider codex', + ], }, { id: 'gemini-cliproxy',