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:
Kai (Tam Nhu) Tran
2026-03-29 14:43:09 -04:00
committed by GitHub
85 changed files with 7867 additions and 208 deletions
+50 -2
View File
@@ -89,6 +89,7 @@ The dashboard provides visual management for all account types:
- **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot
- **AI Providers**: Configure Gemini, Codex, Claude, Vertex, and OpenAI-compatible API keys under `CLIProxy -> AI Providers`
- **API Profiles**: Configure GLM, Kimi, OpenRouter, and other Anthropic-compatible APIs as CCS-native profiles
- **Codex CLI**: Dedicated dashboard page for native runtime diagnostics and guarded `config.toml` editing
- **Factory Droid**: Track Droid install location and BYOK settings health
- **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations)
- **Health Monitor**: Real-time status across all profiles
@@ -201,17 +202,21 @@ Built-in Droid runtime aliases are installed with the package:
```bash
ccs-droid glm # explicit alias
ccsd glm # legacy shortcut
ccs-codex # explicit Codex alias
ccsx # short Codex alias
```
Need additional alias names? First create the matching symlink or another launcher that
preserves the invoked basename, then map that name with `CCS_TARGET_ALIASES` (preferred) or legacy
`CCS_DROID_ALIASES`:
target-specific env vars:
```bash
ln -s "$(command -v ccs)" /usr/local/bin/mydroid
CCS_TARGET_ALIASES='droid=mydroid'
ln -s "$(command -v ccs)" /usr/local/bin/mycodex
CCS_TARGET_ALIASES='droid=mydroid;codex=mycodex'
# Legacy fallback still supported:
CCS_DROID_ALIASES='mydroid'
CCS_CODEX_ALIASES='mycodex'
```
For Factory BYOK compatibility, CCS also stores a per-profile Droid provider hint
@@ -239,6 +244,49 @@ flag and warns about duplicates.
Dashboard parity: `ccs config` -> `Factory Droid`
### Native Codex Runtime (runtime-only in v1)
CCS can launch native Codex as a first-class runtime target without rewriting your
`~/.codex/config.toml` on every run. CCS uses transient `codex -c key=value` overrides for
Codex-routed sessions and leaves your existing Codex home/config in place.
Supported in v1:
```bash
ccs --target codex # native Codex default session
ccs-codex # explicit Codex alias
ccsx # short alias
ccs codex --target codex # built-in CLIProxy Codex on native Codex
ccs api create codex-api --cliproxy-provider codex
ccs codex-api --target codex # Codex bridge profile on native Codex
```
Not supported in v1:
- Claude account profiles on Codex target
- Copilot profiles on Codex target
- Generic API profiles that are not Codex-routed CLIProxy bridges
- Non-Codex CLIProxy providers on Codex target
- Composite CLIProxy variants on Codex target
Dashboard parity: `ccs config` -> `Compatible` -> `Codex CLI`
The dedicated Codex dashboard reads and writes the user layer only: `~/.codex/config.toml`
(or `$CODEX_HOME/config.toml`). It now ships as a split-view control center:
- left pane: guided controls for top-level runtime defaults, project trust, profiles,
model providers, MCP servers, and supported feature toggles
- right pane: raw `config.toml` editor for unsupported or exact-fidelity edits
- overview/docs tabs: binary detection, user-layer summary, support matrix guidance, and
upstream OpenAI references
Structured saves intentionally normalize TOML formatting and drop comments. Use the raw editor
when exact layout matters. Structured edits also refresh the raw snapshot immediately. Guided
controls stay disabled while the raw editor has unsaved or invalid TOML, project trust paths must
be absolute or start with `~/`, and supported feature flags can be cleared back to Codex defaults
with `Use default`. CCS also keeps warning that transient runtime overrides such as
`codex -c key=value` and `CCS_CODEX_API_KEY` can change the effective runtime without persisting
back into the user config file.
### Per-Profile Target Defaults
You can pin a default target (`claude` or `droid`) per profile:
+3
View File
@@ -22,6 +22,7 @@
"open": "^8.4.2",
"ora": "^5.4.1",
"proper-lockfile": "^4.1.2",
"smol-toml": "^1.6.1",
"undici": "^5.29.0",
"ws": "^8.16.0",
},
@@ -1186,6 +1187,8 @@
"slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
"smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+15 -12
View File
@@ -162,7 +162,7 @@ Allowed when:
## Target Adapter Pattern
The target adapter pattern enables pluggable support for multiple CLI implementations (Claude Code, Factory Droid, etc.) while preserving a unified profile system.
The target adapter pattern enables pluggable support for multiple CLI implementations (Claude Code, Factory Droid, Codex CLI, etc.) while preserving a unified profile system.
### Pattern Overview
@@ -170,7 +170,7 @@ The target adapter pattern enables pluggable support for multiple CLI implementa
```typescript
interface TargetAdapter {
readonly type: TargetType; // 'claude' | 'droid'
readonly type: TargetType; // 'claude' | 'droid' | 'codex'
readonly displayName: string; // Human-readable name
detectBinary(): TargetBinaryInfo | null; // Find CLI on system
@@ -184,12 +184,12 @@ interface TargetAdapter {
### Key Differences Per Target
| Aspect | Claude | Droid |
|--------|--------|-------|
| **Credential delivery** | Environment variables | Config file (~/.factory/settings.json) |
| **Spawn args** | `claude <args>` | `droid -m custom:ccs-<profile> <args>` |
| **Config write** | None (uses env) | `upsertCcsModel()` writes to settings |
| **Binary detection** | `detectClaudeCli()` | `detectDroidCli()` with version check |
| Aspect | Claude | Droid | Codex |
|--------|--------|-------|-------|
| **Credential delivery** | Environment variables | Config file (~/.factory/settings.json) | Transient `-c` overrides + `CCS_CODEX_API_KEY` |
| **Spawn args** | `claude <args>` | `droid -m custom:ccs-<profile> <args>` | `codex <args>` or `codex -c ... <args>` |
| **Config write** | None (uses env) | `upsertCcsModel()` writes to settings | None at runtime; dashboard edits user-owned `~/.codex/config.toml` only |
| **Binary detection** | `detectClaudeCli()` | `detectDroidCli()` with version check | `detectCodexCli()` plus `--config` capability probe |
### Target Resolution Priority
@@ -198,13 +198,15 @@ Resolves which adapter to use via `resolveTargetType()`:
```
1. --target <name> flag (highest priority)
2. Profile config: profileConfig.target field
3. argv[0] detection (runtime alias pattern):
2. argv[0] detection (runtime alias pattern):
- ccs-droid → droid
- ccsd → droid
- ccs-codex → codex
- ccsx → codex
- ccs → default
3. Profile config: profileConfig.target field
4. Fallback: 'claude' (lowest priority)
```
@@ -216,6 +218,7 @@ At startup, adapters self-register into the runtime registry:
// In ccs.ts or initialization
registerTarget(new ClaudeAdapter());
registerTarget(new DroidAdapter());
registerTarget(new CodexAdapter());
// Later, when executing
const targetType = resolveTargetType(args, profileConfig);
@@ -716,5 +719,5 @@ This pattern is used in:
## Related Documentation
- [Codebase Summary](./codebase-summary.md) - Full directory structure
- [System Architecture](./system-architecture.md) - Architecture diagrams
- [System Architecture](./system-architecture/index.md) - Architecture diagrams
- [CLAUDE.md](../CLAUDE.md) - AI-facing development guidance
+32 -12
View File
@@ -1,8 +1,8 @@
# CCS Codebase Summary
Last Updated: 2026-03-24
Last Updated: 2026-03-28
Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, account-context validation hardening, and Official Claude Channels runtime support.
Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, account-context validation hardening, Official Claude Channels runtime support, and native Codex runtime target support.
## Repository Structure
@@ -35,6 +35,9 @@ The main CLI is organized into domain-specific modules with barrel exports.
```
src/
├── ccs.ts # Main entry point & profile execution flow
├── bin/ # Dedicated runtime entrypoints
│ ├── droid-runtime.ts # argv[0] shim for ccs-droid / ccsd
│ └── codex-runtime.ts # argv[0] shim for ccs-codex / ccsx
├── types/ # TypeScript type definitions
│ ├── index.ts # Barrel export (aggregates all types)
│ ├── cli.ts # CLI types (ParsedArgs, ExitCode)
@@ -66,9 +69,13 @@ src/
│ ├── index.ts # Barrel export
│ ├── target-adapter.ts # TargetAdapter interface contract
│ ├── target-registry.ts # Registry for runtime adapter lookup
│ ├── target-resolver.ts # Resolution logic (flag > config > argv[0])
│ ├── target-resolver.ts # Resolution logic (flag > argv[0] > config)
│ ├── target-metadata.ts # Runtime vs persisted target metadata and alias lists
│ ├── target-runtime-compatibility.ts # Guardrails for target/profile combinations
│ ├── claude-adapter.ts # Claude Code CLI implementation
│ ├── droid-adapter.ts # Factory Droid CLI implementation
│ ├── codex-adapter.ts # Native Codex CLI implementation
│ ├── codex-detector.ts # Codex binary detection and capability probing
│ ├── droid-detector.ts # Droid binary detection & version checks
│ └── droid-config-manager.ts # ~/.factory/settings.json management
@@ -203,7 +210,7 @@ src/
| Category | Directories | Purpose |
|----------|-------------|---------|
| Core | `commands/`, `errors/` | CLI commands, error handling |
| Targets | `targets/` | Multi-CLI adapter pattern (Claude Code, Factory Droid, extensible) |
| Targets | `bin/`, `targets/` | Multi-CLI adapter pattern (Claude Code, Factory Droid, Codex CLI, extensible) |
| Auth | `auth/`, `cliproxy/auth/` | Authentication across providers |
| Config | `config/`, `types/` | Configuration & type definitions |
| Providers | `cliproxy/`, `copilot/`, `glmt/` | Provider integrations plus retained legacy transformer internals |
@@ -238,6 +245,20 @@ src/
- Runtime contract lives in `src/channels/official-channels-runtime.ts` and is consumed from `src/ccs.ts`, `src/commands/config-channels-command.ts`, and `src/web-server/routes/channels-routes.ts`.
- Canonical config lives under `channels.*` in `~/.ccs/config.yaml`; legacy `discord_channels.*` remains read-compatible only when canonical fields are absent.
### Native Codex Runtime Target
- Runtime aliases: `ccs-codex` and `ccsx` resolve through `src/bin/codex-runtime.ts` and `src/targets/target-resolver.ts`.
- Metadata boundary: `src/targets/target-metadata.ts` keeps Codex runtime-only in v1, so persisted default targets remain `claude | droid`.
- Compatibility guardrails: `src/targets/target-runtime-compatibility.ts` centralizes which profile types can execute on Codex.
- Adapter behavior: `src/targets/codex-adapter.ts` and `src/targets/codex-detector.ts` launch native Codex without rewriting `~/.codex/config.toml`; CCS-backed routes use transient `codex -c key=value` overrides and env-key injection.
- Dashboard control center: `src/web-server/services/codex-dashboard-service.ts`, `src/web-server/routes/codex-routes.ts`, `ui/src/pages/codex.tsx`, and `ui/src/components/compatible-cli/codex-*.tsx` expose a split-view Codex dashboard with guided editors for top-level settings, trust, profiles, providers, MCP servers, and feature flags plus a raw TOML fallback.
- Structured-edit boundary: guided Codex saves intentionally reserialize the whole TOML document, so comments/formatting are normalized and the raw editor remains the fidelity-preserving escape hatch.
- Follow-up behavior: structured saves refresh the raw snapshot immediately, refresh discards stale raw drafts, structured controls stay disabled while raw TOML is dirty/invalid/unreadable, project trust paths must be absolute or `~/...`, unsupported upstream top-level shapes are preserved instead of deleted, and feature flags can be reset to default.
- Supported Codex flows in v1:
- `default`
- CLIProxy provider `codex`
- settings/API profiles only when they resolve to a Codex CLIProxy bridge
- Telegram and Discord bot tokens are intentionally written into Claude-managed machine state under `~/.claude/channels/<channel>/.env`, unless the official `*_STATE_DIR` environment override redirects that channel elsewhere.
- iMessage is tokenless, macOS-only, and still depends on Claude-side plugin install plus OS permissions.
- Auto-enable is gated on Bun availability, verified Claude Code v2.1.80+, verified `claude.ai` auth, native Claude `default/account` sessions, and per-channel setup readiness.
@@ -250,17 +271,16 @@ The targets module provides an extensible interface for dispatching profiles to
**Key components:**
1. **TargetAdapter Interface** - Contract that each CLI implementation must fulfill:
- `detectBinary()` - Find CLI binary on system (platform-specific)
- `prepareCredentials()` - Deliver credentials (env vars vs config file writes)
- `buildArgs()` - Construct target-specific argument list
- `buildEnv()` - Construct environment for target CLI
- `exec()` - Spawn target process (cross-platform)
- `supportsProfileType()` - Verify profile compatibility
- binary detection
- credential preparation
- target-specific args/env construction
- process execution
- profile compatibility checks
2. **Target Resolution** - Priority order:
- `--target <cli>` flag (CLI argument)
- Per-profile `target` field (from config.yaml)
- `argv[0]` detection (runtime alias pattern: `ccs-droid` / `ccsd` → droid)
- Per-profile `target` field (from config.yaml)
- Default: `claude`
3. **Implementations:**
@@ -609,7 +629,7 @@ tests/
## Related Documentation
- [Code Standards](./code-standards.md) - Modularization patterns, file size rules
- [System Architecture](./system-architecture.md) - High-level architecture diagrams
- [System Architecture](./system-architecture/index.md) - High-level architecture diagrams
- [Project Roadmap](./project-roadmap.md) - Modularization phases and future work
- [WebSearch](./websearch.md) - WebSearch feature documentation
- [CLAUDE.md](../CLAUDE.md) - AI-facing development guidance
+2 -1
View File
@@ -1,6 +1,6 @@
# CCS Project Roadmap
Last Updated: 2026-03-27
Last Updated: 2026-03-28
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
@@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
### Recent Fixes
- **2026-03-28**: **#773** CCS now ships a dedicated `Compatible -> Codex CLI` dashboard route with a real split-view control center. The page detects the local Codex binary, keeps overview/docs guidance, and adds guided editors for the user-owned `~/.codex/config.toml` layer: top-level runtime defaults, project trust, profiles, model providers, MCP servers, and supported feature flags. Structured saves intentionally normalize TOML formatting and drop comments, so the raw editor remains the fidelity escape hatch. Follow-up fixes added immediate raw snapshot refresh, refresh/discard recovery for stale raw drafts, dirty raw-editor guarding for structured controls, project-trust path validation, read-only handling for unreadable config files, preservation of unsupported upstream values such as granular `approval_policy`, and feature reset-to-default support. CCS still warns that transient runtime overrides such as `codex -c key=value` and `CCS_CODEX_API_KEY` may change effective behavior without persisting into the file.
- **2026-03-27**: WebSearch dashboard cards now manage Exa, Tavily, and Brave API keys inline instead of relying on a separate manual env step. CCS stores those secrets through `global_env`, reflects masked key state in `/api/websearch`, and counts dashboard-managed keys as ready in the WebSearch status flow.
- **2026-03-27**: **#812** CCS now includes a first-class `ccs docker` command suite for self-hosting the integrated Dashboard + CLIProxy stack. The CLI can stage bundled Docker assets locally or to a remote `--host` over SSH, report compose/supervisor status, stream CCS or CLIProxy logs, and run in-container update flows without relying on ad-hoc deployment scripts.
- **2026-03-24**: Official Claude Channels now follow Anthropic's actual runtime contract. CCS blocks auto-enable unless Bun is available, Claude Code is verified at v2.1.80+, and `claude.ai` auth is verified; treats `--allow-dangerously-skip-permissions` as an explicit override; keeps Telegram/Discord bot tokens in Claude's shared `~/.claude/channels/` state (or official `*_STATE_DIR` overrides); and upgrades the dashboard/CLI status flow with Bun/version/auth/state-scope guidance, safer token draft retention on refresh failures, and a non-macOS iMessage toggle that can still be turned off when already selected.
+13 -6
View File
@@ -1,6 +1,6 @@
# CCS System Architecture
Last Updated: 2026-03-18
Last Updated: 2026-03-28
High-level architecture overview for the CCS (Claude Code Switch) system.
@@ -8,7 +8,7 @@ High-level architecture overview for the CCS (Claude Code Switch) system.
## System Overview
CCS is a CLI wrapper that enables seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, Kiro, GitHub Copilot, OpenRouter, Qwen, Kimi, DeepSeek). It now supports multiple CLI targets (Claude Code, Factory Droid) for credential delivery.
CCS is a CLI wrapper that enables seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, Kiro, GitHub Copilot, OpenRouter, Qwen, Kimi, DeepSeek). It now supports multiple CLI targets (Claude Code, Factory Droid, Codex CLI) for credential delivery.
The system consists of two main components:
@@ -25,8 +25,8 @@ CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy wi
+===========================================================================+
| |
| +------------------+ +-----------------+ +----------------+ |
| | User Terminal | ---> | CCS CLI | ---> | Target CLI | |
| | (ccs command) | | (src/ccs.ts) | | (claude/droid) | |
| | User Terminal | ---> | CCS CLI | ---> | Target CLI | |
| | (ccs command) | | (src/ccs.ts) | | (claude/droid/codex) | |
| +------------------+ +-----------------+ +----------------+ |
| | | |
| v v |
@@ -58,10 +58,10 @@ CCS v7.45 introduces the Target Adapter pattern, enabling seamless integration w
Profile Resolution (CLIProxy, Settings/API, Account-based)
|
v
Target Resolution (--target flag > config > argv[0] > default)
Target Resolution (--target flag > argv[0] > config > default)
|
v
Get Target Adapter (Claude or Droid)
Get Target Adapter (Claude, Droid, or Codex)
|
+---> detectBinary() (find CLI on system)
|
@@ -86,12 +86,19 @@ Spawn Target Process
- Spawns: `droid -m custom:ccs-<profile> <args>`
- Model config includes baseUrl, apiKey, provider
- **Codex Adapter**: Transient runtime overrides plus user-layer dashboard inspection
- Uses `codex -c key=value` only for CCS-routed launches
- Preserves native `~/.codex/config.toml` ownership
- Dashboard page reads/writes only the user config layer with explicit runtime-vs-provider warnings
**Runtime alias pattern (built-in bins / argv[0]-style):**
```
ccs → Target: claude (default)
ccs-droid → Target: droid (explicit alias)
ccsd → Target: droid (legacy shortcut)
ccs-codex → Target: codex (explicit alias)
ccsx → Target: codex (short alias)
```
For details on the adapter architecture, see [Target Adapters](./target-adapters.md).
+156 -18
View File
@@ -1,6 +1,6 @@
# Target Adapters
Last Updated: 2026-02-16
Last Updated: 2026-03-28
Detailed documentation of the target adapter pattern and implementations.
@@ -20,8 +20,8 @@ Each CLI target implements the `TargetAdapter` contract:
```typescript
export interface TargetAdapter {
readonly type: TargetType; // 'claude' | 'droid'
readonly displayName: string; // "Claude Code" | "Factory Droid"
readonly type: TargetType; // 'claude' | 'droid' | 'codex'
readonly displayName: string; // "Claude Code" | "Factory Droid" | "Codex CLI"
/** Detect if the target CLI binary exists on system */
detectBinary(): TargetBinaryInfo | null;
@@ -30,7 +30,15 @@ export interface TargetAdapter {
prepareCredentials(creds: TargetCredentials): Promise<void>;
/** Build spawn arguments for the target CLI */
buildArgs(profile: string, userArgs: string[]): string[];
buildArgs(
profile: string,
userArgs: string[],
options?: {
creds?: TargetCredentials;
profileType?: ProfileType;
binaryInfo?: TargetBinaryInfo;
}
): string[];
/** Build environment variables for the target CLI */
buildEnv(creds: TargetCredentials, profileType: string): NodeJS.ProcessEnv;
@@ -46,7 +54,7 @@ export interface TargetAdapter {
### Type Definitions
```typescript
export type TargetType = 'claude' | 'droid';
export type TargetType = 'claude' | 'droid' | 'codex';
export interface TargetCredentials {
baseUrl: string; // API endpoint
@@ -59,6 +67,8 @@ export interface TargetCredentials {
export interface TargetBinaryInfo {
path: string; // Full path to binary
needsShell: boolean; // Windows .cmd/.bat/.ps1?
version?: string; // Optional version string
features?: readonly string[]; // Capability probes
}
```
@@ -73,17 +83,21 @@ CCS resolves which adapter to use via priority-ordered checks:
```
1. --target flag (CLI argument) — highest priority
└─ ccs --target droid glm
└─ ccs --target codex
2. Per-profile config (from ~/.ccs/config.yaml or settings.json)
2. argv[0] detection (runtime alias pattern) — binary name mapping
└─ ccs-droid (explicit alias) → droid
└─ ccsd (legacy shortcut) → droid
└─ ccs-codex (explicit alias) → codex
└─ ccsx (short alias) → codex
└─ ccs (regular command) → default
3. Per-profile config (from ~/.ccs/config.yaml or settings.json)
└─ persisted targets are currently only `claude` and `droid`
└─ profiles:
glm:
target: droid
3. argv[0] detection (runtime alias pattern) — binary name mapping
└─ ccs-droid (explicit alias) → droid
└─ ccsd (legacy shortcut) → droid
└─ ccs (regular command) → default
4. Fallback: 'claude' — lowest priority
```
@@ -103,17 +117,18 @@ export function resolveTargetType(
return parsed.targetOverride;
}
// 2. Check profile config
if (profileConfig?.target) {
return profileConfig.target;
}
// 3. Check argv[0] (binary name)
// 2. Check argv[0] (binary name)
const binName = path.basename(process.argv[1] || process.argv0 || '').replace(/\.(cmd|bat|ps1|exe)$/i, '');
if (ARGV0_TARGET_MAP[binName]) {
return ARGV0_TARGET_MAP[binName];
}
// 3. Check profile config
if (profileConfig?.target) {
// Persisted targets intentionally exclude runtime-only codex.
return profileConfig.target;
}
// 4. Default to claude
return 'claude';
}
@@ -380,6 +395,123 @@ CCS_TARGET_ALIASES=droid=mydroid
---
## Codex Adapter
### Implementation
The Codex adapter keeps CCS-backed Codex launches transient. It does not rewrite
`~/.codex/config.toml`. Instead it:
- passes through native default Codex sessions unchanged
- probes the installed Codex binary for `--config <key=value>` support
- injects CCS-backed provider credentials through temporary `-c` overrides
- stores the routed API key only in process env via `CCS_CODEX_API_KEY`
```typescript
// src/targets/codex-adapter.ts
export class CodexAdapter implements TargetAdapter {
readonly type: TargetType = 'codex';
readonly displayName = 'Codex CLI';
detectBinary(): TargetBinaryInfo | null {
return getCodexBinaryInfo();
}
async prepareCredentials(_creds: TargetCredentials): Promise<void> {
// No file writes. Codex uses transient -c overrides plus env_key injection.
}
buildArgs(profile: string, userArgs: string[], options?: BuildOptions): string[] {
if ((options?.profileType || 'default') === 'default') {
return userArgs;
}
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
throw new Error('Upgrade Codex before using CCS-backed Codex profiles.');
}
return [
'-c',
'model_provider=\"ccs_runtime\"',
'-c',
'model_providers.ccs_runtime.base_url=\"http://127.0.0.1:8317/api/provider/codex\"',
'-c',
'model_providers.ccs_runtime.env_key=\"CCS_CODEX_API_KEY\"',
'-c',
'model_providers.ccs_runtime.wire_api=\"responses\"',
...userArgs,
];
}
buildEnv(creds: TargetCredentials, profileType: string): NodeJS.ProcessEnv {
const env = { ...stripAnthropicEnv(process.env) };
if (profileType !== 'default') {
env['CCS_CODEX_API_KEY'] = creds.apiKey;
}
return env;
}
}
```
### Support Matrix
Codex is a real runtime target, but it is intentionally narrower than Claude or Droid in v1:
| Profile Type | Codex Target | Notes |
|--------------|--------------|-------|
| `default` | Yes | Uses existing native Codex auth/config |
| `cliproxy` provider=`codex` | Yes | Routed through CLIProxy Codex Responses bridge |
| `cliproxy` composite | No | Not proven native-Codex-safe |
| `settings` with Codex bridge metadata | Yes | Only when the API profile resolves to a Codex CLIProxy bridge |
| `settings` generic API profile | No | Claude/Droid only |
| `account` | No | Claude-only account isolation concept |
| `copilot` | No | Not a native Codex provider path |
### Codex Dashboard Surface
CCS also exposes a dedicated dashboard route at `ccs config` -> `Compatible` -> `Codex CLI`.
That page is intentionally narrower than the Droid dashboard in overall scope, but it is no
longer read-mostly:
- reads and writes only the user config layer: `~/.codex/config.toml` or `$CODEX_HOME/config.toml`
- provides guided controls for top-level settings, project trust, profiles, model providers,
MCP servers, and supported feature flags
- keeps a raw `config.toml` editor as the escape hatch for unsupported or fidelity-sensitive edits
- shows binary detection, user-layer config summaries, support-matrix guidance, and upstream docs
- normalizes TOML formatting and drops comments on structured saves
- keeps structured controls disabled while raw TOML is dirty or invalid, validates project trust
paths as absolute or `~/...`, and lets feature flags reset back to Codex defaults
- warns that transient CCS runtime overrides such as `codex -c key=value` and
`CCS_CODEX_API_KEY` can change the effective runtime without persisting into the file editor
This keeps the dashboard honest about Codex's merged configuration model while still giving users
one place to inspect and manage the user-owned layer safely.
### Runtime Alias Pattern
```bash
# Built-in package bin aliases
ccs-codex
→ Target: codex (forced by runtime alias)
ccsx codex
→ Target: codex (forced by runtime alias)
→ codex ...args
```
Runtime aliases can also be extended with `CCS_TARGET_ALIASES` or legacy
`CCS_CODEX_ALIASES` after creating a matching launcher:
```bash
ln -s /path/to/ccs /path/to/mycodex
CCS_TARGET_ALIASES='codex=mycodex'
# Legacy fallback:
CCS_CODEX_ALIASES='mycodex'
```
---
## Registry and Lookup
The target registry is a simple map-based store for adapters:
@@ -415,6 +547,7 @@ At startup, adapters self-register:
registerTarget(new ClaudeAdapter());
registerTarget(new DroidAdapter());
registerTarget(new CodexAdapter());
```
---
@@ -522,7 +655,7 @@ export class MyAiAdapter implements TargetAdapter {
```typescript
// src/targets/target-adapter.ts
export type TargetType = 'claude' | 'droid' | 'myai';
export type TargetType = 'claude' | 'droid' | 'codex' | 'myai';
```
### 3. Register in ccs.ts
@@ -621,8 +754,13 @@ ccs --target claude help
# Test Droid adapter (if installed)
ccs --target droid help
# Test Codex adapter (if installed)
ccs --target codex
ccs-codex
# Test argv[0] detection
ccs-droid help
ccsx
```
---
+4 -1
View File
@@ -28,7 +28,9 @@
"bin": {
"ccs": "dist/ccs.js",
"ccs-droid": "dist/bin/droid-runtime.js",
"ccsd": "dist/bin/droid-runtime.js"
"ccsd": "dist/bin/droid-runtime.js",
"ccs-codex": "dist/bin/codex-runtime.js",
"ccsx": "dist/bin/codex-runtime.js"
},
"files": [
"dist/",
@@ -105,6 +107,7 @@
"open": "^8.4.2",
"ora": "^5.4.1",
"proper-lockfile": "^4.1.2",
"smol-toml": "^1.6.1",
"undici": "^5.29.0",
"ws": "^8.16.0"
},
@@ -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()}.`,
};
}
+2 -3
View File
@@ -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';
+2
View File
@@ -0,0 +1,2 @@
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
require('../ccs');
+185 -42
View File
@@ -56,6 +56,8 @@ import {
getTarget,
ClaudeAdapter,
DroidAdapter,
CodexAdapter,
evaluateTargetRuntimeCompatibility,
pruneOrphanedModels,
resolveDroidProvider,
type TargetCredentials,
@@ -66,6 +68,7 @@ import {
resolveDroidReasoningRuntime,
} from './targets/droid-reasoning-runtime';
import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router';
import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge';
// Version and Update check utilities
import { getVersion } from './utils/version';
@@ -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;
+1 -1
View File
@@ -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;
+4
View File
@@ -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')}`);
+6 -3
View File
@@ -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;
+8 -3
View File
@@ -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;
}
+14 -3
View File
@@ -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>'],
+215
View File
@@ -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;
}
+36
View File
@@ -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,
};
}
}
+243
View File
@@ -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';
}
}
+138
View File
@@ -0,0 +1,138 @@
import * as fs from 'fs';
import * as childProcess from 'child_process';
import { expandPath } from '../utils/helpers';
import { escapeShellArg } from '../utils/shell-executor';
import type { TargetBinaryInfo } from './target-adapter';
const CODEX_CONFIG_OVERRIDE_FEATURE = 'config-overrides';
function runCodexProbe(codexPath: string, args: string[]): string | undefined {
const isWindows = process.platform === 'win32';
const isPowerShellScript = isWindows && /\.ps1$/i.test(codexPath);
const needsShell = isWindows && /\.(cmd|bat)$/i.test(codexPath);
try {
if (isPowerShellScript) {
return childProcess.execFileSync(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', codexPath, ...args],
{
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
windowsHide: true,
}
);
}
if (needsShell) {
const cmdString = [codexPath, ...args].map(escapeShellArg).join(' ');
return childProcess.execFileSync('cmd.exe', ['/d', '/s', '/c', cmdString], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
windowsHide: true,
});
}
return childProcess.execFileSync(codexPath, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
});
} catch {
return undefined;
}
}
function readCodexVersion(codexPath: string): string | undefined {
return runCodexProbe(codexPath, ['--version'])?.trim();
}
function detectCodexFeatures(codexPath: string): readonly string[] {
const helpText = runCodexProbe(codexPath, ['--help']);
return helpText?.includes('--config <key=value>') ? [CODEX_CONFIG_OVERRIDE_FEATURE] : [];
}
export function detectCodexCli(): string | null {
if (process.env.CCS_CODEX_PATH) {
const customPath = expandPath(process.env.CCS_CODEX_PATH);
try {
if (fs.statSync(customPath).isFile()) {
return customPath;
}
console.warn('[!] CCS_CODEX_PATH points to a directory, not a file:', customPath);
console.warn(' Refusing PATH fallback while CCS_CODEX_PATH is explicitly set.');
return null;
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code === 'ENOENT') {
console.warn('[!] Warning: CCS_CODEX_PATH is set but file not found:', customPath);
} else {
console.warn(
`[!] Warning: CCS_CODEX_PATH is not accessible (${error.code || 'unknown error'}):`,
customPath
);
}
console.warn(' Refusing PATH fallback while CCS_CODEX_PATH is explicitly set.');
return null;
}
}
const isWindows = process.platform === 'win32';
try {
const cmd = isWindows ? 'where.exe codex' : 'which codex';
const result = childProcess
.execSync(cmd, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
})
.trim();
const matches = result
.split('\n')
.map((entry) => entry.trim())
.filter(Boolean);
const candidates = isWindows
? [
...matches.filter((entry) => /\.(exe|cmd|bat|ps1)$/i.test(entry)),
...matches.filter((entry) => !/\.(exe|cmd|bat|ps1)$/i.test(entry)),
]
: matches;
for (const candidate of candidates) {
try {
if (fs.statSync(candidate).isFile()) {
return candidate;
}
} catch {
// Ignore disappearing PATH candidates.
}
}
} catch {
// codex not in PATH
}
return null;
}
export function getCodexBinaryInfo(): TargetBinaryInfo | null {
const codexPath = detectCodexCli();
if (!codexPath) return null;
const isWindows = process.platform === 'win32';
return {
path: codexPath,
needsShell: isWindows && /\.(cmd|bat|ps1)$/i.test(codexPath),
version: readCodexVersion(codexPath),
features: detectCodexFeatures(codexPath),
};
}
export function codexBinarySupportsConfigOverrides(
binaryInfo: TargetBinaryInfo | null | undefined
): boolean {
return Boolean(binaryInfo?.features?.includes(CODEX_CONFIG_OVERRIDE_FEATURE));
}
+16
View File
@@ -19,7 +19,13 @@ export {
} from './target-registry';
export { ClaudeAdapter } from './claude-adapter';
export { DroidAdapter } from './droid-adapter';
export { CodexAdapter } from './codex-adapter';
export { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector';
export {
codexBinarySupportsConfigOverrides,
getCodexBinaryInfo,
detectCodexCli,
} from './codex-detector';
export {
upsertCcsModel,
removeCcsModel,
@@ -30,3 +36,13 @@ export type { DroidCustomModel } from './droid-config-manager';
export { resolveDroidProvider, normalizeDroidProvider } from './droid-provider';
export type { DroidProvider } from './droid-provider';
export { resolveTargetType, stripTargetFlag } from './target-resolver';
export {
TARGET_METADATA,
RUNTIME_TARGET_TYPES,
PERSISTED_TARGET_TYPES,
getPersistedTargetChoices,
getRuntimeTargetChoices,
isPersistedTargetType,
isRuntimeTargetType,
} from './target-metadata';
export { evaluateTargetRuntimeCompatibility } from './target-runtime-compatibility';
+12 -2
View File
@@ -11,7 +11,7 @@
*/
import type { ProfileType } from '../types/profile';
export type TargetType = 'claude' | 'droid';
export type TargetType = 'claude' | 'droid' | 'codex';
/**
* Credentials resolved by CCS profile system, ready for delivery to target CLI.
@@ -39,6 +39,8 @@ export interface TargetCredentials {
export interface TargetBinaryInfo {
path: string;
needsShell: boolean; // Windows .cmd/.bat/.ps1
version?: string;
features?: readonly string[];
}
/**
@@ -72,7 +74,15 @@ export interface TargetAdapter {
* Build target-specific argument vector.
* `userArgs` are the arguments after CCS profile/flag parsing.
*/
buildArgs(profile: string, userArgs: string[]): string[];
buildArgs(
profile: string,
userArgs: string[],
options?: {
creds?: TargetCredentials;
profileType?: ProfileType;
binaryInfo?: TargetBinaryInfo;
}
): string[];
/**
* Build environment variables for process spawn.
+87
View File
@@ -0,0 +1,87 @@
import type { TargetType } from './target-adapter';
export interface TargetMetadata {
displayName: string;
runtimeAliases: readonly string[];
legacyAliasEnvVar?: string;
persistedTarget: boolean;
}
export const TARGET_METADATA: Record<TargetType, TargetMetadata> = {
claude: {
displayName: 'Claude Code',
runtimeAliases: [],
persistedTarget: true,
},
droid: {
displayName: 'Factory Droid',
runtimeAliases: ['ccs-droid', 'ccsd'],
legacyAliasEnvVar: 'CCS_DROID_ALIASES',
persistedTarget: true,
},
codex: {
displayName: 'Codex CLI',
runtimeAliases: ['ccs-codex', 'ccsx'],
legacyAliasEnvVar: 'CCS_CODEX_ALIASES',
persistedTarget: false,
},
} 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;
}
+18 -24
View File
@@ -10,21 +10,24 @@
import * as path from 'path';
import { TargetType } from './target-adapter';
import {
getBuiltinArgv0TargetMap,
getLegacyTargetAliasEnvVars,
getRuntimeTargetChoices,
isPersistedTargetType,
isRuntimeTargetType,
} from './target-metadata';
/**
* Built-in argv[0] aliases for explicit runtime entrypoints.
* `ccs-droid` is the transparent alias; `ccsd` remains as a legacy shortcut.
* Droid and Codex install dedicated runtime aliases alongside the base `ccs` bin.
*/
const BUILTIN_ARGV0_TARGET_MAP: Record<string, TargetType> = {
'ccs-droid': 'droid',
ccsd: 'droid',
};
const BUILTIN_ARGV0_TARGET_MAP: Record<string, TargetType> = getBuiltinArgv0TargetMap();
const ALIAS_NAME_REGEX = /^[a-z0-9._-]+$/;
const INTERNAL_ENTRY_TARGET_ENV_VAR = 'CCS_INTERNAL_ENTRY_TARGET';
const GENERIC_TARGET_ALIAS_ENV_VAR = 'CCS_TARGET_ALIASES';
const LEGACY_TARGET_ALIAS_ENV_VARS: Partial<Record<TargetType, string>> = {
droid: 'CCS_DROID_ALIASES',
};
const LEGACY_TARGET_ALIAS_ENV_VARS: Partial<Record<TargetType, string>> =
getLegacyTargetAliasEnvVars();
const RESERVED_BIN_NAMES = new Set<string>(['ccs', ...Object.keys(BUILTIN_ARGV0_TARGET_MAP)]);
function addAliasToMap(map: Record<string, TargetType>, alias: string, target: TargetType): void {
@@ -64,7 +67,7 @@ function parseGenericTargetAliasConfig(map: Record<string, TargetType>, rawConfi
const rawTarget = entry.slice(0, separatorIndex).trim().toLowerCase();
const rawAliases = entry.slice(separatorIndex + 1).trim();
if (!rawAliases || !isValidTarget(rawTarget)) {
if (!rawAliases || !isRuntimeTargetType(rawTarget)) {
continue;
}
@@ -100,30 +103,21 @@ function resolveEntrypointTarget(): TargetType | null {
}
const normalizedTarget = rawTarget.trim().toLowerCase();
return isValidTarget(normalizedTarget) ? normalizedTarget : null;
return isRuntimeTargetType(normalizedTarget) ? normalizedTarget : null;
}
/**
* Valid target types for --target flag validation.
*/
const VALID_TARGETS: ReadonlySet<string> = new Set<TargetType>(['claude', 'droid']);
interface ParsedTargetFlags {
targetOverride?: TargetType;
cleanedArgs: string[];
}
function isValidTarget(target: unknown): target is TargetType {
return typeof target === 'string' && VALID_TARGETS.has(target as TargetType);
}
function normalizeTargetValue(value: string): TargetType {
const normalized = value.toLowerCase();
if (isValidTarget(normalized)) {
if (isRuntimeTargetType(normalized)) {
return normalized as TargetType;
}
const available = Array.from(VALID_TARGETS).join(', ');
const available = getRuntimeTargetChoices();
throw new Error(`Unknown target "${value}". Available: ${available}`);
}
@@ -148,7 +142,7 @@ function parseTargetFlags(args: string[]): ParsedTargetFlags {
if (arg === '--target') {
const value = args[i + 1];
if (!value || value.startsWith('-')) {
throw new Error('--target requires a value (claude or droid)');
throw new Error(`--target requires a value (${getRuntimeTargetChoices()})`);
}
targetOverride = normalizeTargetValue(value);
i += 1; // Skip value
@@ -158,7 +152,7 @@ function parseTargetFlags(args: string[]): ParsedTargetFlags {
if (arg.startsWith('--target=')) {
const value = arg.slice('--target='.length).trim();
if (!value) {
throw new Error('--target requires a value (claude or droid)');
throw new Error(`--target requires a value (${getRuntimeTargetChoices()})`);
}
targetOverride = normalizeTargetValue(value);
continue;
@@ -204,7 +198,7 @@ export function resolveTargetType(
// 3. Check per-profile config
if (profileConfig?.target !== undefined) {
return isValidTarget(profileConfig.target) ? profileConfig.target : 'claude';
return isPersistedTargetType(profileConfig.target) ? profileConfig.target : 'claude';
}
// 4. Default
@@ -0,0 +1,91 @@
import type { CLIProxyProvider } from '../cliproxy/types';
import type { ProfileType } from '../types/profile';
import type { TargetType } from './target-adapter';
export interface TargetRuntimeCompatibilityInput {
target: TargetType;
profileType: ProfileType;
cliproxyProvider?: CLIProxyProvider;
cliproxyBridgeProvider?: CLIProxyProvider | null;
isComposite?: boolean;
}
export interface TargetRuntimeCompatibilityResult {
supported: boolean;
reason?: string;
suggestion?: string;
}
function unsupported(reason: string, suggestion?: string): TargetRuntimeCompatibilityResult {
return { supported: false, reason, suggestion };
}
export function evaluateTargetRuntimeCompatibility(
input: TargetRuntimeCompatibilityInput
): TargetRuntimeCompatibilityResult {
if (input.target === 'claude') {
return { supported: true };
}
if (input.target === 'droid') {
if (input.profileType === 'account') {
return unsupported(
'Factory Droid does not support account-based Claude profiles.',
'Use a settings-based profile with --target droid instead.'
);
}
if (input.profileType === 'copilot') {
return unsupported('Factory Droid does not support Copilot profiles.');
}
return { supported: true };
}
if (input.profileType === 'account') {
return unsupported(
'Codex CLI does not support Claude account-based profiles.',
'Use native Codex auth with: ccs --target codex'
);
}
if (input.profileType === 'copilot') {
return unsupported('Codex CLI does not support Copilot profiles.');
}
if (input.profileType === 'default') {
return { supported: true };
}
if (input.profileType === 'cliproxy') {
if (input.isComposite) {
return unsupported(
'Codex CLI currently does not support composite CLIProxy variants.',
'Use a Codex-only CLIProxy profile or stay on Claude/Droid for composite variants.'
);
}
if (input.cliproxyProvider !== 'codex') {
return unsupported(
`Codex CLI only supports CLIProxy provider "codex". This profile routes to "${input.cliproxyProvider || 'unknown'}".`,
'Use: ccs codex --target codex, ccs-codex codex, or stay on Claude/Droid for other providers.'
);
}
return { supported: true };
}
if (input.profileType === 'settings') {
if (input.cliproxyBridgeProvider === 'codex') {
return { supported: true };
}
if (input.cliproxyBridgeProvider) {
return unsupported(
`Codex CLI only supports CLIProxy Codex bridge profiles. This API profile bridges "${input.cliproxyBridgeProvider}".`,
'Create a Codex bridge with: ccs api create --cliproxy-provider codex'
);
}
return unsupported(
'Codex CLI currently supports native default sessions and Codex-routed CLIProxy sessions only.',
'Use Claude/Droid for generic API profiles, or create a Codex bridge with: ccs api create --cliproxy-provider codex'
);
}
return unsupported('Unsupported Codex runtime combination.');
}
+89
View File
@@ -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;
+4
View File
@@ -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);
+10 -7
View File
@@ -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) {
+3 -2
View File
@@ -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;
}
+3 -2
View File
@@ -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();
}
}
+25 -24
View File
@@ -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: {
+16
View File
@@ -131,6 +131,8 @@ describe('cross-platform', () => {
assert(packageJson.bin.ccs, 'bin field should specify ccs command');
assert(packageJson.bin['ccs-droid'], 'bin field should specify ccs-droid command');
assert(packageJson.bin.ccsd, 'bin field should specify ccsd command');
assert(packageJson.bin['ccs-codex'], 'bin field should specify ccs-codex command');
assert(packageJson.bin.ccsx, 'bin field should specify ccsx command');
assert.notStrictEqual(
packageJson.bin['ccs-droid'],
packageJson.bin.ccs,
@@ -141,10 +143,24 @@ describe('cross-platform', () => {
packageJson.bin.ccsd,
'legacy ccsd alias should share the dedicated droid runtime entrypoint'
);
assert.notStrictEqual(
packageJson.bin['ccs-codex'],
packageJson.bin.ccs,
'ccs-codex should use a dedicated runtime entrypoint'
);
assert.strictEqual(
packageJson.bin['ccs-codex'],
packageJson.bin.ccsx,
'ccsx should share the dedicated codex runtime entrypoint'
);
assert(
fs.existsSync(path.join(__dirname, '..', '..', packageJson.bin['ccs-droid'])),
'dedicated droid runtime entrypoint should exist'
);
assert(
fs.existsSync(path.join(__dirname, '..', '..', packageJson.bin['ccs-codex'])),
'dedicated codex runtime entrypoint should exist'
);
assert(packageJson.scripts, 'package.json should have scripts field');
});
});
+138
View File
@@ -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<T>(fn: () => T): Promise<T> {
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');
});
});
@@ -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']);
@@ -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']);
@@ -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 <profile> [args]')).toBe(true);
expect(rendered.includes('ccsx <profile> [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);
});
});
@@ -71,13 +71,35 @@ describe('ccsd alias integration', () => {
expect(path.basename(argvPath)).toBe('ccs-droid');
});
it('should preserve ccs-codex symlink basename in argv[1] under node', () => {
if (process.platform === 'win32') {
return;
}
const argvPath = probeArgvPath('ccs-codex');
expect(path.basename(argvPath)).toBe('ccs-codex');
});
it('should preserve ccsx symlink basename in argv[1] under node', () => {
if (process.platform === 'win32') {
return;
}
const argvPath = probeArgvPath('ccsx');
expect(path.basename(argvPath)).toBe('ccsx');
});
it('should preserve extension-style alias basenames for wrapper compatibility', () => {
const cmdArgvPath = probeArgvPathDirect('ccsd.cmd');
const ps1ArgvPath = probeArgvPathDirect('ccsd.ps1');
const explicitCmdArgvPath = probeArgvPathDirect('ccs-droid.cmd');
const codexCmdArgvPath = probeArgvPathDirect('ccs-codex.cmd');
const codexShortCmdArgvPath = probeArgvPathDirect('ccsx.cmd');
expect(path.basename(cmdArgvPath)).toBe('ccsd.cmd');
expect(path.basename(ps1ArgvPath)).toBe('ccsd.ps1');
expect(path.basename(explicitCmdArgvPath)).toBe('ccs-droid.cmd');
expect(path.basename(codexCmdArgvPath)).toBe('ccs-codex.cmd');
expect(path.basename(codexShortCmdArgvPath)).toBe('ccsx.cmd');
});
});
+188
View File
@@ -0,0 +1,188 @@
import { describe, expect, test } from 'bun:test';
import { CodexAdapter } from '../../../src/targets/codex-adapter';
describe('CodexAdapter', () => {
const adapter = new CodexAdapter();
test('supports only adapter-level default and cliproxy profile types', () => {
expect(adapter.supportsProfileType('default')).toBe(true);
expect(adapter.supportsProfileType('cliproxy')).toBe(true);
expect(adapter.supportsProfileType('settings')).toBe(false);
expect(adapter.supportsProfileType('account')).toBe(false);
expect(adapter.supportsProfileType('copilot')).toBe(false);
});
test('passes default-mode args through unchanged', () => {
expect(
adapter.buildArgs('default', ['--search'], {
profileType: 'default',
})
).toEqual(['--search']);
});
test('translates default-mode reasoning overrides into transient codex config', () => {
const args = adapter.buildArgs('default', ['--search'], {
profileType: 'default',
creds: {
profile: 'default',
baseUrl: '',
apiKey: '',
reasoningOverride: 'medium',
},
binaryInfo: {
path: '/tmp/codex',
needsShell: false,
features: ['config-overrides'],
},
});
expect(args).toEqual(['-c', 'model_reasoning_effort="medium"', '--search']);
});
test('rejects default-mode reasoning overrides when codex lacks config override support', () => {
expect(() =>
adapter.buildArgs('default', ['--search'], {
profileType: 'default',
creds: {
profile: 'default',
baseUrl: '',
apiKey: '',
reasoningOverride: 'high',
},
binaryInfo: {
path: '/tmp/codex',
needsShell: false,
version: 'codex-cli 0.1.0',
features: [],
},
})
).toThrow(/does not advertise --config overrides/);
});
test('injects transient config overrides for CCS-backed launches', () => {
const args = adapter.buildArgs('codex', ['--search'], {
profileType: 'cliproxy',
creds: {
profile: 'codex',
baseUrl: 'http://127.0.0.1:8317/api/provider/codex',
apiKey: 'cliproxy-token',
model: 'gpt-5.4',
reasoningOverride: 'high',
},
binaryInfo: {
path: '/tmp/codex',
needsShell: false,
features: ['config-overrides'],
},
});
expect(args).toContain('-c');
expect(args).toContain('model_provider="ccs_runtime"');
expect(args).toContain('model_providers.ccs_runtime.env_key="CCS_CODEX_API_KEY"');
expect(args).toContain('model="gpt-5.4"');
expect(args).toContain('model_reasoning_effort="high"');
expect(args.at(-1)).toBe('--search');
});
test('fails fast when Codex binary lacks config override support', () => {
expect(() =>
adapter.buildArgs('codex', [], {
profileType: 'cliproxy',
creds: {
profile: 'codex',
baseUrl: 'http://127.0.0.1:8317/api/provider/codex',
apiKey: 'cliproxy-token',
},
binaryInfo: {
path: '/tmp/codex',
needsShell: false,
version: 'codex-cli 0.1.0',
features: [],
},
})
).toThrow(/does not advertise --config overrides/);
});
test('rejects native Codex provider-selection flags for CCS-backed launches', () => {
expect(() =>
adapter.buildArgs('codex', ['--profile', 'other', '--search'], {
profileType: 'cliproxy',
creds: {
profile: 'codex',
baseUrl: 'http://127.0.0.1:8317/api/provider/codex',
apiKey: 'cliproxy-token',
},
binaryInfo: {
path: '/tmp/codex',
needsShell: false,
features: ['config-overrides'],
},
})
).toThrow(/does not allow --profile\/-p/);
});
test('rejects user-supplied --config overrides for CCS-backed launches', () => {
const options = {
profileType: 'cliproxy' as const,
creds: {
profile: 'codex',
baseUrl: 'http://127.0.0.1:8317/api/provider/codex',
apiKey: 'cliproxy-token',
},
binaryInfo: {
path: '/tmp/codex',
needsShell: false,
features: ['config-overrides'],
},
};
expect(() => adapter.buildArgs('codex', ['-c', 'model="other"', '--search'], options)).toThrow(
/does not allow --config\/-c/
);
expect(() =>
adapter.buildArgs('codex', ['--config=model="other"', '--search'], options)
).toThrow(/does not allow --config\/-c/);
});
test('rejects unsupported reasoning override values for CCS-backed launches', () => {
expect(() =>
adapter.buildArgs('codex', ['--search'], {
profileType: 'cliproxy',
creds: {
profile: 'codex',
baseUrl: 'http://127.0.0.1:8317/api/provider/codex',
apiKey: 'cliproxy-token',
reasoningOverride: 8192,
},
binaryInfo: {
path: '/tmp/codex',
needsShell: false,
features: ['config-overrides'],
},
})
).toThrow(/supports reasoning levels only/);
});
test('injects CCS_CODEX_API_KEY for CCS-backed launches only', () => {
const settingsEnv = adapter.buildEnv(
{
profile: 'codex',
baseUrl: 'http://127.0.0.1:8317/api/provider/codex',
apiKey: 'cliproxy-token',
},
'cliproxy'
);
expect(settingsEnv.CCS_CODEX_API_KEY).toBe('cliproxy-token');
const defaultEnv = adapter.buildEnv(
{
profile: 'default',
baseUrl: '',
apiKey: '',
},
'default'
);
expect(defaultEnv.CCS_CODEX_API_KEY).toBeUndefined();
});
});
+75
View File
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import * as childProcess from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { detectCodexCli, getCodexBinaryInfo } from '../../../src/targets/codex-detector';
describe('codex-detector', () => {
let tmpDir: string;
let originalPath: string | undefined;
let originalCodexPath: string | undefined;
const originalPlatform = process.platform;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-detector-test-'));
originalPath = process.env.PATH;
originalCodexPath = process.env.CCS_CODEX_PATH;
process.env.PATH = '';
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
if (originalPath !== undefined) process.env.PATH = originalPath;
else delete process.env.PATH;
if (originalCodexPath !== undefined) process.env.CCS_CODEX_PATH = originalCodexPath;
else delete process.env.CCS_CODEX_PATH;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('should prefer CCS_CODEX_PATH when it points to a file', () => {
const fakeCodex = path.join(tmpDir, 'codex');
fs.writeFileSync(fakeCodex, '#!/bin/sh\necho codex\n');
process.env.CCS_CODEX_PATH = fakeCodex;
expect(detectCodexCli()).toBe(fakeCodex);
});
it('should return null when CCS_CODEX_PATH points to a directory', () => {
process.env.CCS_CODEX_PATH = tmpDir;
expect(detectCodexCli()).toBeNull();
});
it('should return binary info without throwing when help probing fails', () => {
const fakeCodex = path.join(tmpDir, 'codex');
fs.writeFileSync(fakeCodex, '');
process.env.CCS_CODEX_PATH = fakeCodex;
expect(() => getCodexBinaryInfo()).not.toThrow();
});
it('probes Windows cmd wrappers through the shell so config override support is detected', () => {
const fakeCodex = path.join(tmpDir, 'codex.cmd');
fs.writeFileSync(fakeCodex, '');
process.env.CCS_CODEX_PATH = fakeCodex;
Object.defineProperty(process, 'platform', { value: 'win32' });
const execFileSyncSpy = spyOn(childProcess, 'execFileSync').mockImplementation((command, args) => {
return String(command).includes('cmd.exe') && Array.isArray(args) && args.join(' ').includes('--help')
? 'Codex CLI\n -c, --config <key=value>\n'
: 'codex-cli 0.118.0-alpha.3';
});
const info = getCodexBinaryInfo();
expect(execFileSyncSpy).toHaveBeenCalled();
expect(info?.needsShell).toBe(true);
expect(info?.features).toContain('config-overrides');
execFileSyncSpy.mockRestore();
});
});
@@ -0,0 +1,197 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
interface RunResult {
status: number | null;
stdout: string;
stderr: string;
}
function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult {
const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts');
const result = spawnSync(process.execPath, [ccsEntry, ...args], {
encoding: 'utf8',
env,
timeout: 20000,
});
return {
status: result.status,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
function readLoggedCodexCalls(logPath: string): string[][] {
if (!fs.existsSync(logPath)) {
return [];
}
return fs
.readFileSync(logPath, 'utf8')
.trim()
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line) as string[]);
}
describe('codex runtime integration', () => {
let tmpHome: string;
let ccsDir: string;
let fakeCodexPath: string;
let codexArgsLogPath: string;
let emptyPathDir: string;
beforeEach(() => {
if (process.platform === 'win32') {
return;
}
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-route-it-'));
ccsDir = path.join(tmpHome, '.ccs');
fakeCodexPath = path.join(tmpHome, 'fake-codex.js');
codexArgsLogPath = path.join(tmpHome, 'codex-args.log');
emptyPathDir = path.join(tmpHome, 'empty-bin');
fs.mkdirSync(ccsDir, { recursive: true });
fs.mkdirSync(emptyPathDir, { recursive: true });
fs.writeFileSync(
fakeCodexPath,
`#!/usr/bin/env node
const fs = require('fs');
const out = process.env.CCS_TEST_CODEX_ARGS_OUT;
if (out) {
fs.appendFileSync(out, JSON.stringify(process.argv.slice(2)) + '\\n');
}
if (process.argv[2] === '--version') {
process.stdout.write(process.env.CCS_TEST_CODEX_VERSION || 'codex-cli 0.118.0-alpha.3');
process.exit(0);
}
if (process.argv[2] === '--help') {
process.stdout.write(
process.env.CCS_TEST_CODEX_HELP ||
' -c, --config <key=value>\\n -p, --profile <CONFIG_PROFILE>\\n'
);
process.exit(0);
}
process.exit(0);
`,
{ encoding: 'utf8', mode: 0o755 }
);
fs.chmodSync(fakeCodexPath, 0o755);
});
afterEach(() => {
if (process.platform === 'win32') {
return;
}
fs.rmSync(tmpHome, { recursive: true, force: true });
});
it('ignores numeric CCS_THINKING env overrides for native Codex default mode', () => {
if (process.platform === 'win32') return;
const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
CCS_THINKING: '8192',
});
expect(result.status).toBe(0);
const calls = readLoggedCodexCalls(codexArgsLogPath);
expect(calls.at(-1)).toEqual(['fix failing tests']);
});
it('ignores off-style CCS_THINKING env overrides for native Codex default mode', () => {
if (process.platform === 'win32') return;
const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
CCS_THINKING: 'off',
});
expect(result.status).toBe(0);
const calls = readLoggedCodexCalls(codexArgsLogPath);
expect(calls.at(-1)).toEqual(['fix failing tests']);
});
it('fails fast when native Codex reasoning overrides need unsupported --config support', () => {
if (process.platform === 'win32') return;
const result = runCcs(['default', '--target', 'codex', '--effort', 'high', 'fix failing tests'], {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
CCS_TEST_CODEX_HELP: ' -p, --profile <CONFIG_PROFILE>\\n',
});
expect(result.status).toBe(1);
expect(result.stderr).toContain('does not advertise --config overrides');
const calls = readLoggedCodexCalls(codexArgsLogPath);
expect(calls).toEqual([['--version'], ['--help']]);
});
it('reports unsupported generic settings profiles before Codex install guidance', () => {
if (process.platform === 'win32') return;
const settingsPath = path.join(ccsDir, 'myglm.settings.json');
const configPath = path.join(ccsDir, 'config.json');
fs.writeFileSync(
settingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://example.invalid/anthropic',
ANTHROPIC_AUTH_TOKEN: 'test-token',
ANTHROPIC_MODEL: 'gpt-5.4',
},
},
null,
2
)
);
fs.writeFileSync(
configPath,
JSON.stringify(
{
profiles: {
myglm: settingsPath,
},
},
null,
2
)
);
const result = runCcs(['myglm', '--target', 'codex', 'fix failing tests'], {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
PATH: emptyPathDir,
});
expect(result.status).toBe(1);
expect(result.stderr).toContain(
'Codex CLI currently supports native default sessions and Codex-routed CLIProxy sessions only.'
);
expect(result.stderr).not.toContain('Install a recent @openai/codex build');
});
});
@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
interface RunResult {
status: number | null;
stdout: string;
stderr: string;
}
function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult {
const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts');
const result = spawnSync(process.execPath, [ccsEntry, ...args], {
encoding: 'utf8',
env,
timeout: 20000,
});
return {
status: result.status,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
describe('Codex settings bridge launch', () => {
let tmpHome = '';
let ccsDir = '';
let settingsPath = '';
let fakeCodexPath = '';
let codexArgsLogPath = '';
let codexEnvLogPath = '';
let baseEnv: NodeJS.ProcessEnv;
beforeEach(() => {
if (process.platform === 'win32') {
return;
}
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-bridge-launch-'));
ccsDir = path.join(tmpHome, '.ccs');
settingsPath = path.join(ccsDir, 'codex-api.settings.json');
fakeCodexPath = path.join(tmpHome, 'fake-codex.sh');
codexArgsLogPath = path.join(tmpHome, 'codex-args.txt');
codexEnvLogPath = path.join(tmpHome, 'codex-env.txt');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: { 'codex-api': settingsPath } }, null, 2) + '\n'
);
fs.writeFileSync(
settingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
ANTHROPIC_AUTH_TOKEN: 'bridge-token',
ANTHROPIC_MODEL: 'gpt-5.3-codex',
},
},
null,
2
) + '\n'
);
fs.writeFileSync(
fakeCodexPath,
`#!/bin/sh
if [ "$1" = "--version" ]; then
echo "codex-cli 0.118.0-alpha.3"
exit 0
fi
if [ "$1" = "--help" ]; then
cat <<'EOF'
Codex CLI
-c, --config <key=value>
EOF
exit 0
fi
printf "%s\\n" "$@" > "${codexArgsLogPath}"
printf "%s" "$CCS_CODEX_API_KEY" > "${codexEnvLogPath}"
exit 0
`,
{ encoding: 'utf8', mode: 0o755 }
);
fs.chmodSync(fakeCodexPath, 0o755);
baseEnv = {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_DEBUG: '1',
};
});
afterEach(() => {
if (process.platform === 'win32') {
return;
}
fs.rmSync(tmpHome, { recursive: true, force: true });
});
it('launches Codex bridge settings profiles and injects runtime overrides', () => {
if (process.platform === 'win32') return;
const result = runCcs(['codex-api', '--target', 'codex', '--effort', 'high', 'smoke'], baseEnv);
expect(result.status).toBe(0);
expect(result.stderr).not.toContain('does not support this profile');
const argsLog = fs.readFileSync(codexArgsLogPath, 'utf8');
expect(argsLog).toContain('model_provider="ccs_runtime"');
expect(argsLog).toContain('model_providers.ccs_runtime.base_url="http://127.0.0.1:8317/api/provider/codex"');
expect(argsLog).toContain('model_reasoning_effort="high"');
expect(argsLog).toContain('smoke');
expect(fs.readFileSync(codexEnvLogPath, 'utf8')).toBe('bridge-token');
});
it('rejects native Codex profile flags when CCS manages the bridge runtime', () => {
if (process.platform === 'win32') return;
const result = runCcs(['codex-api', '--target', 'codex', '--profile', 'other', 'smoke'], baseEnv);
expect(result.status).toBe(1);
expect(result.stderr).toContain('does not allow --profile/-p');
expect(fs.existsSync(codexArgsLogPath)).toBe(false);
});
});
@@ -13,6 +13,7 @@ import {
getRegisteredTargets,
ClaudeAdapter,
DroidAdapter,
CodexAdapter,
} from '../../../src/targets';
describe('target-registry', () => {
@@ -20,6 +21,7 @@ describe('target-registry', () => {
// Re-register adapters (registry is module-scoped singleton)
registerTarget(new ClaudeAdapter());
registerTarget(new DroidAdapter());
registerTarget(new CodexAdapter());
});
it('should register and retrieve claude adapter', () => {
@@ -39,6 +41,12 @@ describe('target-registry', () => {
expect(adapter.type).toBe('claude');
});
it('should register and retrieve codex adapter', () => {
const adapter = getTarget('codex');
expect(adapter.type).toBe('codex');
expect(adapter.displayName).toBe('Codex CLI');
});
it('should throw for unknown target', () => {
expect(() => getTarget('unknown' as never)).toThrow(/Unknown target "unknown"/);
});
@@ -46,6 +54,7 @@ describe('target-registry', () => {
it('should check target existence', () => {
expect(hasTarget('claude')).toBe(true);
expect(hasTarget('droid')).toBe(true);
expect(hasTarget('codex')).toBe(true);
expect(hasTarget('unknown' as never)).toBe(false);
});
@@ -53,6 +62,7 @@ describe('target-registry', () => {
const targets = getRegisteredTargets();
expect(targets).toContain('claude');
expect(targets).toContain('droid');
expect(targets).toContain('codex');
});
});
+88 -3
View File
@@ -7,6 +7,7 @@ import { resolveTargetType, stripTargetFlag } from '../../../src/targets/target-
describe('resolveTargetType', () => {
const originalArgv = process.argv;
const originalDroidAliases = process.env.CCS_DROID_ALIASES;
const originalCodexAliases = process.env.CCS_CODEX_ALIASES;
const originalTargetAliases = process.env.CCS_TARGET_ALIASES;
const originalInternalEntryTarget = process.env.CCS_INTERNAL_ENTRY_TARGET;
@@ -18,6 +19,12 @@ describe('resolveTargetType', () => {
process.env.CCS_DROID_ALIASES = originalDroidAliases;
}
if (originalCodexAliases === undefined) {
delete process.env.CCS_CODEX_ALIASES;
} else {
process.env.CCS_CODEX_ALIASES = originalCodexAliases;
}
if (originalTargetAliases === undefined) {
delete process.env.CCS_TARGET_ALIASES;
} else {
@@ -41,6 +48,11 @@ describe('resolveTargetType', () => {
expect(resolveTargetType(['--target', 'droid'])).toBe('droid');
});
it('should detect --target codex', () => {
process.argv = ['node', 'ccs'];
expect(resolveTargetType(['--target', 'codex'])).toBe('codex');
});
it('should detect --target claude', () => {
process.argv = ['node', 'ccs'];
expect(resolveTargetType(['--target', 'claude'])).toBe('claude');
@@ -56,6 +68,11 @@ describe('resolveTargetType', () => {
expect(resolveTargetType([], { target: 'invalid-target' as never })).toBe('claude');
});
it('should ignore runtime-only codex target when it appears in persisted profile config', () => {
process.argv = ['node', 'ccs'];
expect(resolveTargetType([], { target: 'codex' })).toBe('claude');
});
it('should prioritize --target flag over profile config', () => {
process.argv = ['node', 'ccs'];
expect(resolveTargetType(['--target', 'claude'], { target: 'droid' })).toBe('claude');
@@ -71,15 +88,31 @@ describe('resolveTargetType', () => {
expect(resolveTargetType([])).toBe('droid');
});
it('should detect built-in ccs-codex argv[0] alias', () => {
process.argv = ['node', 'ccs-codex'];
expect(resolveTargetType([])).toBe('codex');
});
it('should detect built-in ccsx argv[0] alias', () => {
process.argv = ['node', 'ccsx'];
expect(resolveTargetType([])).toBe('codex');
});
it('should detect custom target aliases from CCS_TARGET_ALIASES', () => {
process.env.CCS_TARGET_ALIASES = 'droid=droidx,my-droid';
process.argv = ['node', 'my-droid'];
expect(resolveTargetType([])).toBe('droid');
});
it('should detect codex aliases from CCS_TARGET_ALIASES', () => {
process.env.CCS_TARGET_ALIASES = 'codex=codexx,team-codex';
process.argv = ['node', 'team-codex'];
expect(resolveTargetType([])).toBe('codex');
});
it('should ignore unsupported targets in CCS_TARGET_ALIASES', () => {
process.env.CCS_TARGET_ALIASES = 'codex=ccsx;droid=ccs-droid-custom';
process.argv = ['node', 'ccsx'];
process.env.CCS_TARGET_ALIASES = 'not-a-target=mystery-codex;droid=ccs-droid-custom';
process.argv = ['node', 'mystery-codex'];
expect(resolveTargetType([])).toBe('claude');
});
@@ -89,6 +122,12 @@ describe('resolveTargetType', () => {
expect(resolveTargetType([])).toBe('droid');
});
it('should detect custom argv[0] aliases from CCS_CODEX_ALIASES', () => {
process.env.CCS_CODEX_ALIASES = 'codexx,my-codex';
process.argv = ['node', 'my-codex'];
expect(resolveTargetType([])).toBe('codex');
});
it('should merge CCS_TARGET_ALIASES and CCS_DROID_ALIASES', () => {
process.env.CCS_TARGET_ALIASES = 'droid=team-droid';
process.env.CCS_DROID_ALIASES = 'legacy-droid';
@@ -100,6 +139,17 @@ describe('resolveTargetType', () => {
expect(resolveTargetType([])).toBe('droid');
});
it('should merge CCS_TARGET_ALIASES and CCS_CODEX_ALIASES', () => {
process.env.CCS_TARGET_ALIASES = 'codex=team-codex';
process.env.CCS_CODEX_ALIASES = 'legacy-codex';
process.argv = ['node', 'team-codex'];
expect(resolveTargetType([])).toBe('codex');
process.argv = ['node', 'legacy-codex'];
expect(resolveTargetType([])).toBe('codex');
});
it('should ignore invalid custom alias entries', () => {
process.env.CCS_DROID_ALIASES = 'valid_alias,../bad,';
process.argv = ['node', '../bad'];
@@ -112,6 +162,12 @@ describe('resolveTargetType', () => {
expect(resolveTargetType([])).toBe('droid');
});
it('should detect internal entry target for codex runtime bins', () => {
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
process.argv = ['node', 'ccs'];
expect(resolveTargetType([])).toBe('codex');
});
it('should normalize argv[0] and custom aliases case-insensitively', () => {
process.env.CCS_DROID_ALIASES = 'DroidCaps';
process.argv = ['node', 'DROIDCAPS'];
@@ -128,11 +184,21 @@ describe('resolveTargetType', () => {
expect(resolveTargetType([])).toBe('droid');
});
it('should strip .cmd extension on built-in codex alias', () => {
process.argv = ['node', 'ccs-codex.cmd'];
expect(resolveTargetType([])).toBe('codex');
});
it('should strip .bat extension on Windows argv[0]', () => {
process.argv = ['node', 'ccsd.bat'];
expect(resolveTargetType([])).toBe('droid');
});
it('should strip .bat extension on codex shortcut alias', () => {
process.argv = ['node', 'ccsx.bat'];
expect(resolveTargetType([])).toBe('codex');
});
it('should strip .ps1 extension on Windows argv[0]', () => {
process.argv = ['node', 'ccsd.ps1'];
expect(resolveTargetType([])).toBe('droid');
@@ -153,6 +219,11 @@ describe('resolveTargetType', () => {
expect(resolveTargetType([])).toBe('droid');
});
it('should handle full path argv[0] for ccs-codex', () => {
process.argv = ['node', '/usr/local/bin/ccs-codex'];
expect(resolveTargetType([])).toBe('codex');
});
it('should prioritize --target over argv[0]', () => {
process.argv = ['node', 'ccsd'];
expect(resolveTargetType(['--target', 'claude'])).toBe('claude');
@@ -176,8 +247,10 @@ describe('resolveTargetType', () => {
});
it('should keep reserved command names authoritative', () => {
process.env.CCS_TARGET_ALIASES = 'claude=ccs,ccs-droid,ccsd;droid=mydroid';
process.env.CCS_TARGET_ALIASES =
'claude=ccs,ccs-droid,ccsd,ccs-codex,ccsx;droid=mydroid;codex=mycodex';
process.env.CCS_DROID_ALIASES = 'ccs,ccs-droid,ccsd,legacy-droid';
process.env.CCS_CODEX_ALIASES = 'ccs,ccs-codex,ccsx,legacy-codex';
process.argv = ['node', 'ccs'];
expect(resolveTargetType([])).toBe('claude');
@@ -188,11 +261,23 @@ describe('resolveTargetType', () => {
process.argv = ['node', 'ccsd'];
expect(resolveTargetType([])).toBe('droid');
process.argv = ['node', 'ccs-codex'];
expect(resolveTargetType([])).toBe('codex');
process.argv = ['node', 'ccsx'];
expect(resolveTargetType([])).toBe('codex');
process.argv = ['node', 'mydroid'];
expect(resolveTargetType([])).toBe('droid');
process.argv = ['node', 'legacy-droid'];
expect(resolveTargetType([])).toBe('droid');
process.argv = ['node', 'mycodex'];
expect(resolveTargetType([])).toBe('codex');
process.argv = ['node', 'legacy-codex'];
expect(resolveTargetType([])).toBe('codex');
});
it('should throw for invalid --target value', () => {
@@ -0,0 +1,86 @@
import { describe, expect, test } from 'bun:test';
import { evaluateTargetRuntimeCompatibility } from '../../../src/targets/target-runtime-compatibility';
describe('evaluateTargetRuntimeCompatibility', () => {
test('supports native Codex default sessions', () => {
expect(
evaluateTargetRuntimeCompatibility({
target: 'codex',
profileType: 'default',
}).supported
).toBe(true);
});
test('supports Codex CLIProxy provider sessions only for provider codex', () => {
expect(
evaluateTargetRuntimeCompatibility({
target: 'codex',
profileType: 'cliproxy',
cliproxyProvider: 'codex',
isComposite: false,
}).supported
).toBe(true);
const unsupported = evaluateTargetRuntimeCompatibility({
target: 'codex',
profileType: 'cliproxy',
cliproxyProvider: 'gemini',
isComposite: false,
});
expect(unsupported.supported).toBe(false);
expect(unsupported.reason).toMatch(/only supports CLIProxy provider "codex"/);
});
test('rejects composite CLIProxy variants on Codex target', () => {
const compatibility = evaluateTargetRuntimeCompatibility({
target: 'codex',
profileType: 'cliproxy',
cliproxyProvider: 'codex',
isComposite: true,
});
expect(compatibility.supported).toBe(false);
expect(compatibility.reason).toMatch(/does not support composite CLIProxy variants/);
});
test('supports only Codex bridge API profiles on Codex target', () => {
expect(
evaluateTargetRuntimeCompatibility({
target: 'codex',
profileType: 'settings',
cliproxyBridgeProvider: 'codex',
}).supported
).toBe(true);
const compatibility = evaluateTargetRuntimeCompatibility({
target: 'codex',
profileType: 'settings',
cliproxyBridgeProvider: 'gemini',
});
expect(compatibility.supported).toBe(false);
expect(compatibility.reason).toMatch(/only supports CLIProxy Codex bridge profiles/);
const genericSettingsCompatibility = evaluateTargetRuntimeCompatibility({
target: 'codex',
profileType: 'settings',
});
expect(genericSettingsCompatibility.supported).toBe(false);
expect(genericSettingsCompatibility.reason).toMatch(/currently supports native default sessions/);
});
test('rejects account and copilot profiles on Codex target', () => {
expect(
evaluateTargetRuntimeCompatibility({
target: 'codex',
profileType: 'account',
}).supported
).toBe(false);
expect(
evaluateTargetRuntimeCompatibility({
target: 'codex',
profileType: 'copilot',
}).supported
).toBe(false);
});
});
@@ -0,0 +1,602 @@
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 {
CodexRawConfigConflictError,
CodexRawConfigValidationError,
getCodexDashboardDiagnostics,
getCodexRawConfig,
patchCodexConfig,
resolveCodexConfigPaths,
saveCodexRawConfig,
summarizeCodexFeatureFlags,
summarizeCodexMcpServers,
summarizeCodexModelProviders,
summarizeCodexProjectTrust,
} from '../../../src/web-server/services/codex-dashboard-service';
const testRoot = path.join(os.tmpdir(), `ccs-codex-dashboard-test-${Date.now()}`);
const codexHome = path.join(testRoot, '.codex-home');
const codexStubPath = path.join(testRoot, 'codex');
function writeCodexStub(options?: { helpText?: string; version?: string }) {
const helpText =
options?.helpText ?? ' -c, --config <key=value>\n -p, --profile <CONFIG_PROFILE>\n';
const version = options?.version ?? 'codex-cli 0.118.0-alpha.3';
fs.writeFileSync(
codexStubPath,
`#!/bin/sh
if [ "$1" = "--version" ]; then
printf '%s\\n' "${version}"
exit 0
fi
if [ "$1" = "--help" ]; then
printf '%s' "${helpText}"
exit 0
fi
exit 0
`
);
fs.chmodSync(codexStubPath, 0o755);
}
beforeEach(() => {
fs.mkdirSync(testRoot, { recursive: true });
fs.mkdirSync(codexHome, { recursive: true });
writeCodexStub();
process.env.CODEX_HOME = codexHome;
process.env.CCS_CODEX_PATH = codexStubPath;
});
afterEach(() => {
delete process.env.CODEX_HOME;
delete process.env.CCS_CODEX_PATH;
if (fs.existsSync(testRoot)) {
fs.rmSync(testRoot, { recursive: true, force: true });
}
});
describe('codex-dashboard-service', () => {
it('resolves codex config paths with CODEX_HOME override', () => {
const resolved = resolveCodexConfigPaths({
env: {
CODEX_HOME: './custom-codex-home',
} as NodeJS.ProcessEnv,
homeDir: '/Users/tester',
});
expect(resolved.baseDir).toBe(path.resolve('./custom-codex-home'));
expect(resolved.baseDirDisplay).toBe('$CODEX_HOME');
expect(resolved.configPath).toBe(path.join(path.resolve('./custom-codex-home'), 'config.toml'));
expect(resolved.configDisplayPath).toBe('$CODEX_HOME/config.toml');
});
it('summarizes model providers with auth and header metadata', () => {
const summary = summarizeCodexModelProviders({
cliproxy: {
base_url: 'http://127.0.0.1:8317/api/provider/codex',
env_key: 'CLIPROXY_API_KEY',
wire_api: 'responses',
http_headers: { 'x-test': '1' },
},
local: {
base_url: 'http://localhost:11434/v1',
experimental_bearer_token: 'secret',
supports_websockets: true,
},
});
expect(summary.length).toBe(2);
expect(summary[0].name).toBe('cliproxy');
expect(summary[0].envKey).toBe('CLIPROXY_API_KEY');
expect(summary[0].hasHttpHeaders).toBe(true);
expect(summary[1].usesExperimentalBearerToken).toBe(true);
});
it('summarizes feature flags, project trust, and mcp servers', () => {
const features = summarizeCodexFeatureFlags({
multi_agent: true,
shell_snapshot: false,
custom_mode: 'beta',
});
const projects = summarizeCodexProjectTrust({
'/tmp/a': { trust_level: 'trusted' },
'/tmp/b': { trust_level: 'untrusted' },
});
const servers = summarizeCodexMcpServers({
stdio: {
command: 'npx',
enabled_tools: ['browser_snapshot'],
},
remote: {
url: 'https://example.test/mcp',
bearer_token: 'not-allowed-inline',
required: true,
},
});
expect(features.enabled.map((feature) => feature.name)).toEqual(['multi_agent']);
expect(features.disabled.map((feature) => feature.name)).toEqual(['shell_snapshot']);
expect(features.all.find((feature) => feature.name === 'custom_mode')?.state).toBe('custom');
expect(projects.length).toBe(2);
expect(projects[0].trustLevel).toBe('trusted');
expect(servers[0].transport).toBe('streamable-http');
expect(servers[0].usesInlineBearerToken).toBe(true);
});
it('returns raw config payload for missing config.toml', async () => {
const raw = await getCodexRawConfig();
expect(raw.exists).toBe(false);
expect(raw.path).toBe('$CODEX_HOME/config.toml');
expect(raw.rawText).toBe('');
expect(raw.config).toBeNull();
expect(raw.readError).toBeNull();
});
it('returns parseError when config.toml is invalid TOML', async () => {
fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n[features\n');
const raw = await getCodexRawConfig();
expect(raw.exists).toBe(true);
expect(raw.parseError).toBeString();
expect(raw.config).toBeNull();
});
it('returns readError when config.toml is a symlink', async () => {
const configPath = path.join(codexHome, 'config.toml');
const targetPath = path.join(testRoot, 'linked.toml');
fs.writeFileSync(targetPath, 'model = "gpt-5.4"\n');
fs.symlinkSync(targetPath, configPath);
const raw = await getCodexRawConfig();
expect(raw.exists).toBe(true);
expect(raw.readError).toContain('Refusing symlink file');
expect(raw.config).toBeNull();
});
it('includes docs links, support matrix, and config summaries in diagnostics', async () => {
fs.writeFileSync(
path.join(codexHome, 'config.toml'),
`model = "gpt-5.4"
profile = "work"
model_provider = "cliproxy"
approval_policy = "never"
sandbox_mode = "danger-full-access"
web_search = "live"
[features]
multi_agent = true
shell_snapshot = false
runtime_metrics = true
[model_providers.cliproxy]
name = "CLIProxyAPI"
base_url = "http://127.0.0.1:8317/api/provider/codex"
env_key = "CLIPROXY_API_KEY"
wire_api = "responses"
[projects."/tmp/project-a"]
trust_level = "trusted"
[projects."/tmp/project-b"]
trust_level = "untrusted"
[mcp_servers.playwright]
command = "npx"
args = ["@playwright/mcp@latest"]
enabled_tools = ["browser_snapshot"]
tool_timeout_sec = 30
[profiles.work]
model = "gpt-5.4"
`
);
const diagnostics = await getCodexDashboardDiagnostics();
expect(diagnostics.binary.installed).toBe(true);
expect(diagnostics.binary.supportsConfigOverrides).toBe(true);
expect(diagnostics.config.model).toBe('gpt-5.4');
expect(diagnostics.config.activeProfile).toBe('work');
expect(diagnostics.config.modelProvider).toBe('cliproxy');
expect(diagnostics.config.profileCount).toBe(1);
expect(diagnostics.config.modelProviderCount).toBe(1);
expect(diagnostics.config.featureCount).toBe(3);
expect(diagnostics.config.enabledFeatures.map((feature) => feature.name)).toEqual([
'multi_agent',
'runtime_metrics',
]);
expect(diagnostics.config.trustedProjectCount).toBe(1);
expect(diagnostics.config.untrustedProjectCount).toBe(1);
expect(diagnostics.config.mcpServerCount).toBe(1);
expect(diagnostics.docsReference.links.length).toBeGreaterThan(0);
expect(diagnostics.supportMatrix.some((entry) => entry.id === 'default')).toBe(true);
});
it('summarizes granular approval policies without flattening them to null', async () => {
fs.writeFileSync(
path.join(codexHome, 'config.toml'),
'approval_policy = { granular = { edit = "on-request" } }\n'
);
const diagnostics = await getCodexDashboardDiagnostics();
expect(diagnostics.config.approvalPolicy).toBe('granular (custom)');
});
it('warns when active profile is missing, config overrides are unavailable, or risky fields exist', async () => {
writeCodexStub({ helpText: ' -p, --profile <CONFIG_PROFILE>\n' });
fs.writeFileSync(
path.join(codexHome, 'config.toml'),
`profile = "missing-profile"
[model_providers.local]
experimental_bearer_token = "secret"
[mcp_servers.remote]
url = "https://example.test/mcp"
bearer_token = "secret"
`
);
const diagnostics = await getCodexDashboardDiagnostics();
expect(
diagnostics.warnings.some((warning) => warning.includes('does not expose --config overrides'))
).toBe(true);
expect(
diagnostics.warnings.some((warning) => warning.includes('missing from [profiles]'))
).toBe(true);
expect(
diagnostics.warnings.some((warning) => warning.includes('experimental_bearer_token'))
).toBe(true);
expect(diagnostics.warnings.some((warning) => warning.includes('inline bearer_token'))).toBe(
true
);
});
it('saves valid raw config content', async () => {
const result = await saveCodexRawConfig({
rawText: 'model = "gpt-5.4"\n[features]\nmulti_agent = true\n',
});
const written = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
expect(result.success).toBe(true);
expect(result.mtime).toBeGreaterThan(0);
expect(written).toContain('model = "gpt-5.4"');
});
it('rejects invalid TOML while saving raw config', async () => {
await expect(saveCodexRawConfig({ rawText: 'model = "gpt-5.4"\n[features\n' })).rejects.toThrow(
CodexRawConfigValidationError
);
});
it('rejects stale writes with conflict error', async () => {
const configPath = path.join(codexHome, 'config.toml');
fs.writeFileSync(configPath, 'model = "gpt-5.4"\n');
await expect(
saveCodexRawConfig({
rawText: 'model = "gpt-5.3-codex"\n',
expectedMtime: 1,
})
).rejects.toThrow(CodexRawConfigConflictError);
});
it('rejects writes when expectedMtime differs by even 1ms', async () => {
const configPath = path.join(codexHome, 'config.toml');
fs.writeFileSync(configPath, 'model = "gpt-5.4"\n');
const current = await getCodexRawConfig();
await expect(
saveCodexRawConfig({
rawText: 'model = "gpt-5.4"\nprofile = "work"\n',
expectedMtime: current.mtime + 1,
})
).rejects.toThrow(CodexRawConfigConflictError);
});
it('patches top-level settings and project trust through structured controls', async () => {
const result = await patchCodexConfig({
kind: 'top-level',
values: {
model: 'gpt-5.4',
modelReasoningEffort: 'high',
approvalPolicy: 'never',
sandboxMode: 'workspace-write',
webSearch: 'cached',
toolOutputTokenLimit: 12000,
personality: 'friendly',
},
});
await patchCodexConfig({
kind: 'project-trust',
path: '/tmp/workspace-a',
trustLevel: 'trusted',
expectedMtime: result.mtime,
});
const diagnostics = await getCodexDashboardDiagnostics();
expect(diagnostics.config.model).toBe('gpt-5.4');
expect(diagnostics.config.modelReasoningEffort).toBe('high');
expect(diagnostics.config.toolOutputTokenLimit).toBe(12000);
expect(diagnostics.config.personality).toBe('friendly');
expect(diagnostics.config.projectTrust[0]?.path).toBe('/tmp/workspace-a');
expect(result.rawText).toContain('model = "gpt-5.4"');
expect(result.config?.model).toBe('gpt-5.4');
});
it('allows structured patches on existing config.toml even when expectedMtime is omitted', async () => {
fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n');
const result = await patchCodexConfig({
kind: 'feature',
feature: 'multi_agent',
enabled: true,
});
expect(result.rawText).toContain('model = "gpt-5.4"');
expect(result.rawText).toContain('[features]');
expect(result.rawText).toContain('multi_agent = true');
expect(result.config?.features).toEqual({ multi_agent: true });
});
it('preserves unsupported approval_policy objects when structured saves touch other fields', async () => {
fs.writeFileSync(
path.join(codexHome, 'config.toml'),
'model = "gpt-5.4"\napproval_policy = { granular = { edit = "on-request" } }\n'
);
const current = await getCodexRawConfig();
const result = await patchCodexConfig({
kind: 'top-level',
expectedMtime: current.mtime,
values: {
model: 'gpt-5.4-mini',
approvalPolicy: null,
},
});
expect(result.rawText).toContain('model = "gpt-5.4-mini"');
expect(result.rawText).toContain('[approval_policy.granular]');
expect(result.rawText).toContain('edit = "on-request"');
expect(result.config?.approval_policy).toEqual({
granular: { edit: 'on-request' },
});
});
it('expands home paths for project trust and rejects relative paths', async () => {
const homeWorkspacePath = path.join(os.homedir(), 'codex-workspace');
const expanded = await patchCodexConfig({
kind: 'project-trust',
path: '~/codex-workspace',
trustLevel: 'trusted',
});
expect(expanded.rawText).toContain(`[projects."${homeWorkspacePath}"]`);
await expect(
patchCodexConfig({
kind: 'project-trust',
path: './relative-workspace',
trustLevel: 'trusted',
})
).rejects.toThrow(CodexRawConfigValidationError);
});
it('patches profiles, providers, and mcp servers through structured controls', async () => {
const providerResult = await patchCodexConfig({
kind: 'model-provider',
action: 'upsert',
name: 'cliproxy',
values: {
displayName: 'CLIProxy',
baseUrl: 'http://127.0.0.1:8317/api/provider/codex',
envKey: 'CLIPROXY_API_KEY',
wireApi: 'responses',
},
});
const profileResult = await patchCodexConfig({
kind: 'profile',
action: 'upsert',
name: 'deep-review',
values: {
model: 'gpt-5.4',
modelProvider: 'cliproxy',
modelReasoningEffort: 'xhigh',
},
setAsActive: true,
expectedMtime: providerResult.mtime,
});
await patchCodexConfig({
kind: 'mcp-server',
action: 'upsert',
name: 'playwright',
values: {
transport: 'stdio',
command: 'npx',
args: ['@playwright/mcp@latest'],
enabled: true,
required: false,
startupTimeoutSec: 15,
toolTimeoutSec: 30,
},
expectedMtime: profileResult.mtime,
});
const diagnostics = await getCodexDashboardDiagnostics();
expect(diagnostics.config.activeProfile).toBe('deep-review');
expect(diagnostics.config.modelProviderCount).toBe(1);
expect(diagnostics.config.mcpServerCount).toBe(1);
const raw = await getCodexRawConfig();
expect(raw.rawText).toContain('[profiles.deep-review]');
expect(raw.rawText).toContain('[model_providers.cliproxy]');
expect(raw.rawText).toContain('[mcp_servers.playwright]');
expect(profileResult.rawText).toContain('[profiles.deep-review]');
expect(profileResult.config?.profile).toBe('deep-review');
});
it('accepts non-integer MCP timeout values documented by upstream Codex', async () => {
const result = await patchCodexConfig({
kind: 'mcp-server',
action: 'upsert',
name: 'streaming',
values: {
transport: 'stdio',
command: 'npx',
startupTimeoutSec: 1.5,
toolTimeoutSec: 2.25,
},
});
expect(result.rawText).toContain('startup_timeout_sec = 1.5');
expect(result.rawText).toContain('tool_timeout_sec = 2.25');
});
it('rewrites legacy startup_timeout_ms keys when editing MCP server timeouts', async () => {
fs.writeFileSync(
path.join(codexHome, 'config.toml'),
['[mcp_servers.streaming]', 'command = "npx"', 'startup_timeout_ms = 1500', ''].join('\n')
);
const raw = await getCodexRawConfig();
const result = await patchCodexConfig({
kind: 'mcp-server',
action: 'upsert',
name: 'streaming',
expectedMtime: raw.mtime,
values: {
transport: 'stdio',
command: 'npx',
startupTimeoutSec: 2.5,
},
});
expect(result.rawText).toContain('startup_timeout_sec = 2.5');
expect(result.rawText).not.toContain('startup_timeout_ms');
});
it('patches streamable-http mcp servers through structured controls', async () => {
const result = await patchCodexConfig({
kind: 'mcp-server',
action: 'upsert',
name: 'remote',
values: {
transport: 'streamable-http',
url: 'https://example.test/mcp',
enabled: true,
required: true,
toolTimeoutSec: 45,
enabledTools: ['browser_snapshot'],
disabledTools: ['slow_tool'],
},
});
expect(result.rawText).toContain('[mcp_servers.remote]');
expect(result.rawText).toContain('url = "https://example.test/mcp"');
expect(result.rawText).toContain('required = true');
const diagnostics = await getCodexDashboardDiagnostics();
expect(diagnostics.config.mcpServers).toEqual([
expect.objectContaining({
name: 'remote',
transport: 'streamable-http',
required: true,
toolTimeoutSec: 45,
enabledToolsCount: 1,
disabledToolsCount: 1,
}),
]);
});
it('rejects structured patches when config.toml is invalid', async () => {
fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n[features\n');
await expect(
patchCodexConfig({
kind: 'feature',
feature: 'multi_agent',
enabled: true,
})
).rejects.toThrow(CodexRawConfigValidationError);
});
it('removes feature overrides when a feature is reset to inherited state', async () => {
const enabled = await patchCodexConfig({
kind: 'feature',
feature: 'multi_agent',
enabled: true,
});
expect(enabled.rawText).toContain('[features]');
expect(enabled.rawText).toContain('multi_agent = true');
const reset = await patchCodexConfig({
kind: 'feature',
feature: 'multi_agent',
enabled: null,
expectedMtime: enabled.mtime,
});
expect(reset.rawText).not.toContain('[features]');
expect(reset.config?.features).toBeUndefined();
});
it('rejects malformed structured patch payloads at runtime', async () => {
await expect(
patchCodexConfig({
kind: 'feature',
feature: 'multi_agent',
enabled: 'true' as unknown as boolean | null,
})
).rejects.toThrow(CodexRawConfigValidationError);
await expect(
patchCodexConfig({
kind: 'project-trust',
path: '~/codex-workspace',
trustLevel: 'always',
})
).rejects.toThrow(CodexRawConfigValidationError);
await expect(
patchCodexConfig({
kind: 'mcp-server',
action: 'upsert',
name: 'remote',
values: {
transport: 'http' as 'stdio' | 'streamable-http',
url: 'https://example.test/mcp',
},
})
).rejects.toThrow(CodexRawConfigValidationError);
});
it('rejects invalid enum values even when they already exist in config.toml', async () => {
fs.writeFileSync(
path.join(codexHome, 'config.toml'),
'model = "gpt-5.4"\napproval_policy = "legacy"\n'
);
await expect(
patchCodexConfig({
kind: 'top-level',
values: {
approvalPolicy: 'legacy' as unknown as 'on-request' | 'never' | 'untrusted' | null,
},
})
).rejects.toThrow(CodexRawConfigValidationError);
});
});
+170
View File
@@ -0,0 +1,170 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import express from 'express';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { Server } from 'http';
let server: Server;
let baseUrl = '';
let tempDir = '';
let codexHome = '';
let originalCodexHome: string | undefined;
beforeAll(async () => {
originalCodexHome = process.env.CODEX_HOME;
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-routes-test-'));
codexHome = path.join(tempDir, '.codex-home');
process.env.CODEX_HOME = codexHome;
const codexRoutesModule = await import('../../../src/web-server/routes/codex-routes');
const app = express();
app.use(express.json());
app.use('/api/codex', codexRoutesModule.default);
server = app.listen(0, '127.0.0.1');
await new Promise<void>((resolve) => server.on('listening', () => resolve()));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
beforeEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
fs.mkdirSync(codexHome, { recursive: true });
});
afterAll(async () => {
if (server) {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
if (originalCodexHome !== undefined) {
process.env.CODEX_HOME = originalCodexHome;
} else {
delete process.env.CODEX_HOME;
}
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('codex routes', () => {
it('returns the current raw config snapshot from PATCH /config/patch', async () => {
const res = await fetch(`${baseUrl}/api/codex/config/patch`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: 'top-level',
values: {
model: 'gpt-5.4',
sandboxMode: 'workspace-write',
},
}),
});
expect(res.status).toBe(200);
const json = (await res.json()) as {
success: boolean;
exists: boolean;
mtime: number;
rawText: string;
config: Record<string, unknown> | null;
parseError: string | null;
readError: string | null;
};
expect(json.success).toBe(true);
expect(json.exists).toBe(true);
expect(json.mtime).toBeGreaterThan(0);
expect(json.parseError).toBeNull();
expect(json.readError).toBeNull();
expect(json.rawText).toContain('model = "gpt-5.4"');
expect(json.rawText).toContain('sandbox_mode = "workspace-write"');
expect(json.config?.model).toBe('gpt-5.4');
const written = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
expect(written).toBe(json.rawText);
});
it('returns 409 when PATCH /config/patch receives a stale expectedMtime', async () => {
const configPath = path.join(codexHome, 'config.toml');
fs.writeFileSync(configPath, 'model = "gpt-5.3-codex"\n');
const res = await fetch(`${baseUrl}/api/codex/config/patch`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: 'feature',
feature: 'multi_agent',
enabled: true,
expectedMtime: 1,
}),
});
expect(res.status).toBe(409);
const json = (await res.json()) as { error: string; mtime: number };
expect(json.error).toContain('File modified externally.');
expect(json.mtime).toBeGreaterThan(0);
});
it('allows PATCH /config/patch on an existing config.toml without expectedMtime', async () => {
fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n');
const res = await fetch(`${baseUrl}/api/codex/config/patch`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: 'feature',
feature: 'multi_agent',
enabled: true,
}),
});
expect(res.status).toBe(200);
const json = (await res.json()) as {
success: boolean;
rawText: string;
config: Record<string, unknown> | null;
};
expect(json.success).toBe(true);
expect(json.rawText).toContain('multi_agent = true');
expect(json.config?.features).toEqual({ multi_agent: true });
});
it('returns 400 when PATCH /config/patch omits kind', async () => {
const res = await fetch(`${baseUrl}/api/codex/config/patch`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
const json = (await res.json()) as { error: string };
expect(json.error).toBe('kind is required.');
});
it('returns 400 when PATCH /config/patch receives an invalid trust path', async () => {
const res = await fetch(`${baseUrl}/api/codex/config/patch`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: 'project-trust',
path: './relative-workspace',
trustLevel: 'trusted',
}),
});
expect(res.status).toBe(400);
const json = (await res.json()) as { error: string };
expect(json.error).toContain('Project path must be absolute');
});
});
@@ -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();
});
+6
View File
@@ -31,6 +31,7 @@
"i18next": "^25.8.13",
"lucide-react": "^0.556.0",
"prism-react-renderer": "^2.4.1",
"prismjs": "^1.30.0",
"react": "^19.2.0",
"react-day-picker": "^9.12.0",
"react-dom": "^19.2.0",
@@ -41,6 +42,7 @@
"react-simple-code-editor": "^0.14.1",
"react-virtuoso": "^4.17.0",
"recharts": "^2.12.0",
"smol-toml": "^1.6.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"yaml": "^2.8.2",
@@ -925,6 +927,8 @@
"prism-react-renderer": ["prism-react-renderer@2.4.1", "", { "dependencies": { "@types/prismjs": "^1.26.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": ">=16.0.0" } }, "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig=="],
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
@@ -1001,6 +1005,8 @@
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+2
View File
@@ -45,6 +45,7 @@
"i18next": "^25.8.13",
"lucide-react": "^0.556.0",
"prism-react-renderer": "^2.4.1",
"prismjs": "^1.30.0",
"react": "^19.2.0",
"react-day-picker": "^9.12.0",
"react-dom": "^19.2.0",
@@ -55,6 +56,7 @@
"react-simple-code-editor": "^0.14.1",
"react-virtuoso": "^4.17.0",
"recharts": "^2.12.0",
"smol-toml": "^1.6.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"yaml": "^2.8.2",
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path clip-rule="evenodd" d="M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+9
View File
@@ -34,6 +34,7 @@ const CursorPage = lazy(() => import('@/pages/cursor').then((m) => ({ default: m
const ClaudeExtensionPage = lazy(() =>
import('@/pages/claude-extension').then((m) => ({ default: m.ClaudeExtensionPage }))
);
const CodexPage = lazy(() => import('@/pages/codex').then((m) => ({ default: m.CodexPage })));
const DroidPage = lazy(() => import('@/pages/droid').then((m) => ({ default: m.DroidPage })));
const AccountsPage = lazy(() =>
import('@/pages/accounts').then((m) => ({ default: m.AccountsPage }))
@@ -141,6 +142,14 @@ export default function App() {
</Suspense>
}
/>
<Route
path="/codex"
element={
<Suspense fallback={<PageLoader />}>
<CodexPage />
</Suspense>
}
/>
<Route
path="/droid"
element={
@@ -28,6 +28,7 @@ export function ProviderInfoTab({
}: ProviderInfoTabProps) {
const resolvedTarget = defaultTarget || 'claude';
const isDroidTarget = resolvedTarget === 'droid';
const isCodexProvider = provider === 'codex';
return (
<ScrollArea className="h-full">
@@ -88,6 +89,22 @@ export function ProviderInfoTab({
<h3 className="text-sm font-medium mb-3">Quick Usage</h3>
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
<UsageCommand label="Run with prompt" command={`ccs ${provider} "your prompt"`} />
{isCodexProvider && (
<>
<UsageCommand
label="Run on native Codex (--target)"
command={`ccs ${provider} --target codex "your prompt"`}
/>
<UsageCommand
label="Codex alias (explicit)"
command={`ccs-codex ${provider} "your prompt"`}
/>
<UsageCommand
label="Codex alias (short)"
command={`ccsx ${provider} "your prompt"`}
/>
</>
)}
<UsageCommand
label={isDroidTarget ? 'Droid alias (explicit)' : 'Run on Droid'}
command={`ccs-droid ${provider} "your prompt"`}
@@ -0,0 +1,42 @@
import type { ReactNode } from 'react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface CodexConfigCardShellProps {
title: string;
icon?: ReactNode;
badge?: string;
description?: string;
disabledReason?: string | null;
children: ReactNode;
}
export function CodexConfigCardShell({
title,
icon,
badge,
description,
disabledReason,
children,
}: CodexConfigCardShellProps) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
{icon}
{title}
{badge ? (
<Badge variant="outline" className="text-[10px] font-normal">
{badge}
</Badge>
) : null}
</CardTitle>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
</CardHeader>
<CardContent className="space-y-3">
{disabledReason ? <p className="text-xs text-amber-600">{disabledReason}</p> : null}
{children}
</CardContent>
</Card>
);
}
@@ -0,0 +1,165 @@
import { Route } from 'lucide-react';
import { CodexFeaturesCard } from '@/components/compatible-cli/codex-features-card';
import { CodexMcpServersCard } from '@/components/compatible-cli/codex-mcp-servers-card';
import { CodexModelProvidersCard } from '@/components/compatible-cli/codex-model-providers-card';
import { CodexProfilesCard } from '@/components/compatible-cli/codex-profiles-card';
import { CodexProjectTrustCard } from '@/components/compatible-cli/codex-project-trust-card';
import { CodexTopLevelControlsCard } from '@/components/compatible-cli/codex-top-level-controls-card';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import type {
CodexConfigPatchInput,
CodexProfilePatchValues,
CodexTopLevelSettingsPatch,
} from '@/hooks/use-codex-types';
import type {
CodexFeatureCatalogEntry,
CodexMcpServerEntry,
CodexModelProviderEntry,
CodexProfileEntry,
CodexProjectTrustEntry,
CodexTopLevelSettingsView,
} from '@/lib/codex-config';
interface CodexControlCenterTabProps {
workspacePath: string;
activeProfile: string | null;
topLevelSettings: CodexTopLevelSettingsView;
projectTrustEntries: CodexProjectTrustEntry[];
profileEntries: CodexProfileEntry[];
modelProviderEntries: CodexModelProviderEntry[];
mcpServerEntries: CodexMcpServerEntry[];
featureCatalog: CodexFeatureCatalogEntry[];
featureState: Record<string, boolean | null>;
disabled: boolean;
disabledReason: string | null;
saving: boolean;
onPatch: (patch: CodexConfigPatchInput, successMessage: string) => Promise<void>;
}
export function CodexControlCenterTab({
workspacePath,
activeProfile,
topLevelSettings,
projectTrustEntries,
profileEntries,
modelProviderEntries,
mcpServerEntries,
featureCatalog,
featureState,
disabled,
disabledReason,
saving,
onPatch,
}: CodexControlCenterTabProps) {
return (
<ScrollArea className="h-full">
<div className="space-y-4 pr-1">
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Route className="h-4 w-4" />
Structured controls boundary
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<p>
Guided controls write only the user-layer <code>config.toml</code>. They do not model
the full effective Codex runtime once trusted repo layers and CCS transient{' '}
<code>-c</code> overrides are involved.
</p>
<p>
Structured saves normalize TOML formatting and strip comments. Use the raw editor on
the right when exact layout matters.
</p>
</CardContent>
</Card>
<CodexTopLevelControlsCard
values={topLevelSettings}
providerNames={modelProviderEntries.map((entry) => entry.name)}
disabled={disabled}
disabledReason={disabledReason}
saving={saving}
onSave={(values: CodexTopLevelSettingsPatch) =>
onPatch({ kind: 'top-level', values }, 'Saved top-level Codex settings.')
}
/>
<CodexProjectTrustCard
workspacePath={workspacePath}
entries={projectTrustEntries}
disabled={disabled}
disabledReason={disabledReason}
saving={saving}
onSave={(projectPath, trustLevel) =>
onPatch(
{ kind: 'project-trust', path: projectPath, trustLevel },
trustLevel ? 'Saved project trust entry.' : 'Removed project trust entry.'
)
}
/>
<CodexProfilesCard
activeProfile={activeProfile}
entries={profileEntries}
providerNames={modelProviderEntries.map((entry) => entry.name)}
disabled={disabled}
disabledReason={disabledReason}
saving={saving}
onSave={(name, values: CodexProfilePatchValues, setAsActive) =>
onPatch(
{ kind: 'profile', action: 'upsert', name, values, setAsActive },
'Saved profile.'
)
}
onDelete={(name) =>
onPatch({ kind: 'profile', action: 'delete', name }, 'Deleted profile.')
}
onSetActive={(name) =>
onPatch({ kind: 'profile', action: 'set-active', name }, 'Set active profile.')
}
/>
<CodexModelProvidersCard
entries={modelProviderEntries}
disabled={disabled}
disabledReason={disabledReason}
saving={saving}
onSave={(name, values) =>
onPatch(
{ kind: 'model-provider', action: 'upsert', name, values },
'Saved model provider.'
)
}
onDelete={(name) =>
onPatch({ kind: 'model-provider', action: 'delete', name }, 'Deleted model provider.')
}
/>
<CodexMcpServersCard
entries={mcpServerEntries}
disabled={disabled}
disabledReason={disabledReason}
saving={saving}
onSave={(name, values) =>
onPatch({ kind: 'mcp-server', action: 'upsert', name, values }, 'Saved MCP server.')
}
onDelete={(name) =>
onPatch({ kind: 'mcp-server', action: 'delete', name }, 'Deleted MCP server.')
}
/>
<CodexFeaturesCard
catalog={featureCatalog}
state={featureState}
disabled={disabled}
disabledReason={disabledReason}
onToggle={(feature, enabled) =>
onPatch({ kind: 'feature', feature, enabled }, 'Saved feature toggle.')
}
/>
</div>
</ScrollArea>
);
}
@@ -0,0 +1,184 @@
import { type ReactNode } from 'react';
import { ExternalLink, ShieldCheck } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import type {
CompatibleCliProviderDocLink,
CodexDashboardDiagnostics,
} from '@/hooks/use-codex-types';
const DEFAULT_CODEX_DOC_LINKS = [
{
id: 'codex-config-basic',
label: 'Codex Config Basics',
url: 'https://developers.openai.com/codex/config-basic',
description: 'Official user-layer setup, config location, and baseline configuration behavior.',
},
{
id: 'codex-config-advanced',
label: 'Codex Config Advanced',
url: 'https://developers.openai.com/codex/config-advanced',
description: 'Layering, trust, profiles, and advanced config behavior.',
},
{
id: 'codex-config-reference',
label: 'Codex Config Reference',
url: 'https://developers.openai.com/codex/config-reference',
description: 'Canonical upstream config surface for providers, MCP, features, and trust.',
},
{
id: 'codex-releases',
label: 'Codex GitHub Releases',
url: 'https://github.com/openai/codex/releases',
description: 'Track upstream release notes and fast-moving CLI changes.',
},
];
const DEFAULT_PROVIDER_DOCS: CompatibleCliProviderDocLink[] = [
{
provider: 'openai',
label: 'OpenAI Responses API',
apiFormat: 'Responses API',
url: 'https://platform.openai.com/docs/api-reference/responses',
},
];
function renderTextWithLinks(text: string): ReactNode[] {
const urlPattern = /https?:\/\/[^\s)]+/g;
const nodes: ReactNode[] = [];
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = urlPattern.exec(text)) !== null) {
const [url] = match;
const index = match.index;
if (index > cursor) {
nodes.push(text.slice(cursor, index));
}
nodes.push(
<a
key={`${url}-${index}`}
href={url}
target="_blank"
rel="noreferrer"
className="underline underline-offset-2 hover:text-foreground"
>
{url}
</a>
);
cursor = index + url.length;
}
if (cursor < text.length) {
nodes.push(text.slice(cursor));
}
return nodes.length > 0 ? nodes : [text];
}
interface CodexDocsTabProps {
diagnostics: CodexDashboardDiagnostics;
}
export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) {
const docsReference = diagnostics.docsReference ?? {
notes: [],
links: [],
providerDocs: [],
providerValues: [],
settingsHierarchy: [],
};
const docsLinks = docsReference.links.length > 0 ? docsReference.links : DEFAULT_CODEX_DOC_LINKS;
const providerDocs =
docsReference.providerDocs.length > 0 ? docsReference.providerDocs : DEFAULT_PROVIDER_DOCS;
return (
<ScrollArea className="h-full">
<div className="space-y-4 pr-1">
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="h-4 w-4" />
Upstream notes
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{docsReference.notes.map((note, index) => (
<p key={`${index}-${note}`} className="text-muted-foreground">
- {renderTextWithLinks(note)}
</p>
))}
<Separator />
<div className="space-y-2">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Codex docs</p>
<div className="space-y-1.5">
{docsLinks.map((link) => (
<a
key={link.id}
href={link.url}
target="_blank"
rel="noreferrer"
className="block rounded-md border px-2.5 py-2 transition-colors hover:bg-muted/50"
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{link.label}</span>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground">{link.description}</p>
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/90 underline underline-offset-2">
{link.url}
</p>
</a>
))}
</div>
</div>
<Separator />
<div className="space-y-2">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Provider / bridge reference
</p>
<div className="space-y-1.5">
{providerDocs.map((providerDoc) => (
<a
key={`${providerDoc.provider}-${providerDoc.url}`}
href={providerDoc.url}
target="_blank"
rel="noreferrer"
className="block rounded-md border px-2.5 py-2 transition-colors hover:bg-muted/50"
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{providerDoc.label}</span>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground">
provider: {providerDoc.provider} | format: {providerDoc.apiFormat}
</p>
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/90 underline underline-offset-2">
{providerDoc.url}
</p>
</a>
))}
</div>
</div>
{docsReference.providerValues.length > 0 && (
<>
<Separator />
<p className="text-xs text-muted-foreground">
Provider values: {docsReference.providerValues.join(', ')}
</p>
</>
)}
{docsReference.settingsHierarchy.length > 0 && (
<p className="text-xs text-muted-foreground">
Settings hierarchy: {docsReference.settingsHierarchy.join(' -> ')}
</p>
)}
</CardContent>
</Card>
</div>
</ScrollArea>
);
}
@@ -0,0 +1,129 @@
import { Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import type { CodexFeatureCatalogEntry } from '@/lib/codex-config';
import { CodexConfigCardShell } from './codex-config-card-shell';
interface CodexFeaturesCardProps {
catalog: CodexFeatureCatalogEntry[];
state: Record<string, boolean | null>;
disabled?: boolean;
disabledReason?: string | null;
onToggle: (feature: string, enabled: boolean | null) => Promise<void> | void;
}
export function CodexFeaturesCard({
catalog,
state,
disabled = false,
disabledReason,
onToggle,
}: CodexFeaturesCardProps) {
const knownFeatureNames = new Set(catalog.map((feature) => feature.name));
const configOnlyFeatures = Object.entries(state)
.filter(([name]) => !knownFeatureNames.has(name))
.sort(([left], [right]) => left.localeCompare(right));
return (
<CodexConfigCardShell
title="Features"
badge="features"
icon={<Sparkles className="h-4 w-4" />}
description="Toggle the supported Codex feature flags CCS can safely manage."
disabledReason={disabledReason}
>
<div className="space-y-2">
{catalog.map((feature) => {
const current = state[feature.name] ?? null;
return (
<div
key={feature.name}
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{feature.label}</p>
<Badge variant="outline" className="font-mono text-[10px]">
{feature.name}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{feature.description}</p>
</div>
<div className="flex items-center gap-2">
{current !== null ? (
<Button
variant="outline"
size="sm"
onClick={() => onToggle(feature.name, null)}
disabled={disabled}
>
Use default
</Button>
) : null}
<Switch
checked={current === true}
onCheckedChange={(next) => onToggle(feature.name, next)}
disabled={disabled}
/>
</div>
</div>
);
})}
</div>
{configOnlyFeatures.length > 0 ? (
<div className="space-y-2">
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Existing config-only flags
</p>
<p className="text-xs text-muted-foreground">
These feature keys already exist in your `config.toml`, so CCS can surface them
without claiming full catalog coverage.
</p>
</div>
{configOnlyFeatures.map(([name, current]) => (
<div
key={name}
className="flex items-center justify-between gap-3 rounded-md border border-dashed px-3 py-2"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{name}</p>
<Badge variant="secondary" className="text-[10px]">
existing
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{current === null
? 'Stored in a non-boolean form. Use raw TOML if you need to edit it.'
: "Discovered from the current file instead of CCS's built-in catalog."}
</p>
</div>
{current === null ? (
<Badge variant="outline">Raw only</Badge>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onToggle(name, null)}
disabled={disabled}
>
Use default
</Button>
<Switch
checked={current === true}
onCheckedChange={(next) => onToggle(name, next)}
disabled={disabled}
/>
</div>
)}
</div>
))}
</div>
) : null}
</CodexConfigCardShell>
);
}
@@ -0,0 +1,278 @@
import { useMemo, useState } from 'react';
import { Loader2, PlugZap, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { CodexMcpServerPatchValues } from '@/hooks/use-codex-types';
import type { CodexMcpServerEntry } from '@/lib/codex-config';
import { CodexConfigCardShell } from './codex-config-card-shell';
interface CodexMcpServersCardProps {
entries: CodexMcpServerEntry[];
disabled?: boolean;
disabledReason?: string | null;
saving?: boolean;
onSave: (name: string, values: CodexMcpServerPatchValues) => Promise<void> | void;
onDelete: (name: string) => Promise<void> | void;
}
const EMPTY_MCP_SERVER_DRAFT: CodexMcpServerEntry = {
name: '',
transport: 'stdio',
command: null,
args: [],
url: null,
enabled: true,
required: false,
startupTimeoutSec: null,
toolTimeoutSec: null,
enabledTools: [],
disabledTools: [],
};
function toCsv(value: string[]) {
return value.join(', ');
}
function fromCsv(value: string) {
return value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
}
interface McpServerEditorProps {
initialDraft: CodexMcpServerEntry;
isNew: boolean;
disabled: boolean;
saving: boolean;
canDelete: boolean;
onSave: (name: string, values: CodexMcpServerPatchValues) => Promise<void> | void;
onDelete: () => Promise<void> | void;
}
function McpServerEditor({
initialDraft,
isNew,
disabled,
saving,
canDelete,
onSave,
onDelete,
}: McpServerEditorProps) {
const [draft, setDraft] = useState<CodexMcpServerEntry>(initialDraft);
return (
<>
<div className="grid gap-3 sm:grid-cols-2">
<Input
value={draft.name}
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
placeholder="playwright"
disabled={disabled || !isNew}
/>
<Select
value={draft.transport}
onValueChange={(next) =>
setDraft((current) => ({
...current,
transport: next as CodexMcpServerEntry['transport'],
}))
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stdio">stdio</SelectItem>
<SelectItem value="streamable-http">streamable-http</SelectItem>
</SelectContent>
</Select>
{draft.transport === 'stdio' ? (
<>
<Input
value={draft.command ?? ''}
onChange={(event) =>
setDraft((current) => ({ ...current, command: event.target.value || null }))
}
placeholder="npx"
disabled={disabled}
/>
<Input
value={toCsv(draft.args)}
onChange={(event) =>
setDraft((current) => ({ ...current, args: fromCsv(event.target.value) }))
}
placeholder="@playwright/mcp@latest, --flag"
disabled={disabled}
/>
</>
) : (
<Input
className="sm:col-span-2"
value={draft.url ?? ''}
onChange={(event) =>
setDraft((current) => ({ ...current, url: event.target.value || null }))
}
placeholder="https://example.test/mcp"
disabled={disabled}
/>
)}
<Input
type="number"
min={1}
value={draft.startupTimeoutSec ?? ''}
onChange={(event) =>
setDraft((current) => ({
...current,
startupTimeoutSec: event.target.value ? Number(event.target.value) : null,
}))
}
placeholder="Startup timeout (sec)"
disabled={disabled}
/>
<Input
type="number"
min={1}
value={draft.toolTimeoutSec ?? ''}
onChange={(event) =>
setDraft((current) => ({
...current,
toolTimeoutSec: event.target.value ? Number(event.target.value) : null,
}))
}
placeholder="Tool timeout (sec)"
disabled={disabled}
/>
<Input
value={toCsv(draft.enabledTools)}
onChange={(event) =>
setDraft((current) => ({ ...current, enabledTools: fromCsv(event.target.value) }))
}
placeholder="enabled_tools"
disabled={disabled}
/>
<Input
value={toCsv(draft.disabledTools)}
onChange={(event) =>
setDraft((current) => ({ ...current, disabledTools: fromCsv(event.target.value) }))
}
placeholder="disabled_tools"
disabled={disabled}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
Enabled
<Switch
checked={draft.enabled}
onCheckedChange={(next) => setDraft((current) => ({ ...current, enabled: next }))}
disabled={disabled}
/>
</label>
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
Required
<Switch
checked={draft.required}
onCheckedChange={(next) => setDraft((current) => ({ ...current, required: next }))}
disabled={disabled}
/>
</label>
</div>
<div className="flex justify-between gap-2">
<Button variant="outline" onClick={onDelete} disabled={disabled || saving || !canDelete}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
<Button
onClick={() =>
onSave(draft.name, {
transport: draft.transport,
command: draft.command,
args: draft.args,
url: draft.url,
enabled: draft.enabled,
required: draft.required,
startupTimeoutSec: draft.startupTimeoutSec,
toolTimeoutSec: draft.toolTimeoutSec,
enabledTools: draft.enabledTools,
disabledTools: draft.disabledTools,
})
}
disabled={disabled || saving || draft.name.trim().length === 0}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save MCP server
</Button>
</div>
</>
);
}
export function CodexMcpServersCard({
entries,
disabled = false,
disabledReason,
saving = false,
onSave,
onDelete,
}: CodexMcpServersCardProps) {
const [selectedName, setSelectedName] = useState('new');
const selectedEntry = useMemo(
() => entries.find((entry) => entry.name === selectedName) ?? null,
[entries, selectedName]
);
const draftSeed = selectedEntry ?? EMPTY_MCP_SERVER_DRAFT;
const draftKey = JSON.stringify(draftSeed);
return (
<CodexConfigCardShell
title="MCP servers"
badge="mcp_servers"
icon={<PlugZap className="h-4 w-4" />}
description="Manage the safe MCP transport fields. Keep auth headers and bearer tokens in raw TOML."
disabledReason={disabledReason}
>
<Select value={selectedName} onValueChange={setSelectedName} disabled={disabled}>
<SelectTrigger>
<SelectValue placeholder="Select MCP server" />
</SelectTrigger>
<SelectContent>
<SelectItem value="new">Create new MCP server</SelectItem>
{entries.map((entry) => (
<SelectItem key={entry.name} value={entry.name}>
{entry.name}
</SelectItem>
))}
</SelectContent>
</Select>
<McpServerEditor
key={draftKey}
initialDraft={draftSeed}
isNew={selectedName === 'new'}
disabled={disabled}
saving={saving}
canDelete={selectedEntry !== null}
onDelete={async () => {
if (!selectedEntry) return;
await onDelete(selectedEntry.name);
setSelectedName('new');
}}
onSave={async (name, values) => {
await onSave(name, values);
setSelectedName(name);
}}
/>
</CodexConfigCardShell>
);
}
@@ -0,0 +1,209 @@
import { useMemo, useState } from 'react';
import { KeyRound, Loader2, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { CodexModelProviderPatchValues } from '@/hooks/use-codex-types';
import type { CodexModelProviderEntry } from '@/lib/codex-config';
import { CodexConfigCardShell } from './codex-config-card-shell';
interface CodexModelProvidersCardProps {
entries: CodexModelProviderEntry[];
disabled?: boolean;
disabledReason?: string | null;
saving?: boolean;
onSave: (name: string, values: CodexModelProviderPatchValues) => Promise<void> | void;
onDelete: (name: string) => Promise<void> | void;
}
const EMPTY_MODEL_PROVIDER_DRAFT: CodexModelProviderEntry = {
name: '',
displayName: null,
baseUrl: null,
envKey: null,
wireApi: 'responses',
requiresOpenaiAuth: false,
supportsWebsockets: false,
};
interface ModelProviderEditorProps {
initialDraft: CodexModelProviderEntry;
isNew: boolean;
disabled: boolean;
saving: boolean;
canDelete: boolean;
onSave: (name: string, values: CodexModelProviderPatchValues) => Promise<void> | void;
onDelete: () => Promise<void> | void;
}
function ModelProviderEditor({
initialDraft,
isNew,
disabled,
saving,
canDelete,
onSave,
onDelete,
}: ModelProviderEditorProps) {
const [draft, setDraft] = useState<CodexModelProviderEntry>(initialDraft);
return (
<>
<div className="grid gap-3 sm:grid-cols-2">
<Input
value={draft.name}
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
placeholder="Provider id"
disabled={disabled || !isNew}
/>
<Input
value={draft.displayName ?? ''}
onChange={(event) =>
setDraft((current) => ({ ...current, displayName: event.target.value || null }))
}
placeholder="Display name"
disabled={disabled}
/>
<Input
value={draft.baseUrl ?? ''}
onChange={(event) =>
setDraft((current) => ({ ...current, baseUrl: event.target.value || null }))
}
placeholder="http://127.0.0.1:8317/api/provider/codex"
disabled={disabled}
/>
<Input
value={draft.envKey ?? ''}
onChange={(event) =>
setDraft((current) => ({ ...current, envKey: event.target.value || null }))
}
placeholder="CLIPROXY_API_KEY"
disabled={disabled}
/>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<Select
value={draft.wireApi ?? 'responses'}
onValueChange={(next) => setDraft((current) => ({ ...current, wireApi: next }))}
disabled={disabled}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="responses">responses</SelectItem>
</SelectContent>
</Select>
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
Requires OpenAI auth
<Switch
checked={draft.requiresOpenaiAuth}
onCheckedChange={(next) =>
setDraft((current) => ({ ...current, requiresOpenaiAuth: next }))
}
disabled={disabled}
/>
</label>
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
Supports websockets
<Switch
checked={draft.supportsWebsockets}
onCheckedChange={(next) =>
setDraft((current) => ({ ...current, supportsWebsockets: next }))
}
disabled={disabled}
/>
</label>
</div>
<div className="flex justify-between gap-2">
<Button variant="outline" onClick={onDelete} disabled={disabled || saving || !canDelete}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
<Button
onClick={() =>
onSave(draft.name, {
displayName: draft.displayName,
baseUrl: draft.baseUrl,
envKey: draft.envKey,
wireApi: draft.wireApi,
requiresOpenaiAuth: draft.requiresOpenaiAuth,
supportsWebsockets: draft.supportsWebsockets,
})
}
disabled={disabled || saving || draft.name.trim().length === 0}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save provider
</Button>
</div>
</>
);
}
export function CodexModelProvidersCard({
entries,
disabled = false,
disabledReason,
saving = false,
onSave,
onDelete,
}: CodexModelProvidersCardProps) {
const [selectedName, setSelectedName] = useState<string>('new');
const selectedEntry = useMemo(
() => entries.find((entry) => entry.name === selectedName) ?? null,
[entries, selectedName]
);
const draftSeed = selectedEntry ?? EMPTY_MODEL_PROVIDER_DRAFT;
const draftKey = JSON.stringify(draftSeed);
return (
<CodexConfigCardShell
title="Model providers"
badge="model_providers"
icon={<KeyRound className="h-4 w-4" />}
description="Edit the common provider fields CCS can support safely. Keep secret migration and inline bearer tokens in raw TOML."
disabledReason={disabledReason}
>
<Select value={selectedName} onValueChange={setSelectedName} disabled={disabled}>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
<SelectItem value="new">Create new provider</SelectItem>
{entries.map((entry) => (
<SelectItem key={entry.name} value={entry.name}>
{entry.name}
</SelectItem>
))}
</SelectContent>
</Select>
<ModelProviderEditor
key={draftKey}
initialDraft={draftSeed}
isNew={selectedName === 'new'}
disabled={disabled}
saving={saving}
canDelete={selectedEntry !== null}
onDelete={async () => {
if (!selectedEntry) return;
await onDelete(selectedEntry.name);
setSelectedName('new');
}}
onSave={async (name, values) => {
await onSave(name, values);
setSelectedName(name);
}}
/>
</CodexConfigCardShell>
);
}
@@ -0,0 +1,315 @@
import {
AlertTriangle,
CheckCircle2,
Folder,
Info,
Route,
ShieldCheck,
TerminalSquare,
XCircle,
} from 'lucide-react';
import { QuickCommands } from '@/components/shared';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { CodexDashboardDiagnostics } from '@/hooks/use-codex-types';
import { cn } from '@/lib/utils';
function formatTimestamp(value: number | null | undefined): string {
if (!value || !Number.isFinite(value)) return 'N/A';
return new Date(value).toLocaleString();
}
function formatBytes(value: number | null | undefined): string {
if (!value || value <= 0) return '0 B';
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
}
function DetailRow({
label,
value,
mono = false,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<div className="flex items-start justify-between gap-3 text-sm">
<span className="text-muted-foreground shrink-0">{label}</span>
<span className={cn('text-right break-all', mono && 'font-mono text-xs')}>{value}</span>
</div>
);
}
interface CodexOverviewTabProps {
diagnostics: CodexDashboardDiagnostics;
}
export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
return (
<ScrollArea className="h-full">
<div className="space-y-4 pr-1">
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Info className="h-4 w-4" />
How Codex works in CCS
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<p>Codex is a first-class runtime target in CCS, but it stays runtime-only in v1.</p>
<p>
Saved default targets for API profiles and variants still remain on Claude or Droid.
</p>
<p>
CCS-backed Codex launches can apply transient <code>-c</code> overrides and inject
<code> CCS_CODEX_API_KEY</code>, so effective runtime values may not match this file
exactly.
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<TerminalSquare className="h-4 w-4" />
Runtime install
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Status</span>
<Badge variant={diagnostics.binary.installed ? 'default' : 'secondary'}>
{diagnostics.binary.installed ? 'Detected' : 'Not found'}
</Badge>
</div>
<DetailRow label="Detection source" value={diagnostics.binary.source} mono />
<DetailRow label="Binary path" value={diagnostics.binary.path || 'Not found'} mono />
<DetailRow
label="Install directory"
value={diagnostics.binary.installDir || 'N/A'}
mono
/>
<DetailRow label="Version" value={diagnostics.binary.version || 'Unknown'} mono />
<DetailRow label="Alias commands" value="ccs-codex, ccsx" mono />
<div className="flex items-center justify-between rounded-md border px-3 py-2">
<span className="text-sm text-muted-foreground">--config override support</span>
<Badge variant={diagnostics.binary.supportsConfigOverrides ? 'default' : 'secondary'}>
{diagnostics.binary.supportsConfigOverrides ? 'Available' : 'Missing'}
</Badge>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Folder className="h-4 w-4" />
Config file
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="rounded-md border p-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-sm">User config</span>
{diagnostics.file.exists ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-muted-foreground" />
)}
</div>
<DetailRow label="Path" value={diagnostics.file.path} mono />
<DetailRow label="Resolved" value={diagnostics.file.resolvedPath} mono />
<DetailRow label="Size" value={formatBytes(diagnostics.file.sizeBytes)} />
<DetailRow label="Last modified" value={formatTimestamp(diagnostics.file.mtimeMs)} />
{diagnostics.file.parseError && (
<p className="text-xs text-amber-600">
TOML warning: {diagnostics.file.parseError}
</p>
)}
{diagnostics.file.readError && (
<p className="text-xs text-destructive">
Read warning: {diagnostics.file.readError}
</p>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="h-4 w-4" />
Current user-layer summary
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<DetailRow label="Model" value={diagnostics.config.model || 'Not set'} mono />
<DetailRow
label="Model provider"
value={diagnostics.config.modelProvider || 'Not set'}
mono
/>
<DetailRow
label="Active profile"
value={diagnostics.config.activeProfile || 'Not set'}
mono
/>
<DetailRow
label="Approval policy"
value={diagnostics.config.approvalPolicy || 'Not set'}
mono
/>
<DetailRow
label="Sandbox mode"
value={diagnostics.config.sandboxMode || 'Not set'}
mono
/>
<DetailRow label="Web search" value={diagnostics.config.webSearch || 'Not set'} mono />
<Separator />
<div className="grid grid-cols-2 gap-2 text-xs">
<Badge variant="outline" className="justify-center">
providers: {diagnostics.config.modelProviderCount}
</Badge>
<Badge variant="outline" className="justify-center">
profiles: {diagnostics.config.profileCount}
</Badge>
<Badge variant="outline" className="justify-center">
enabled features: {diagnostics.config.enabledFeatures.length}
</Badge>
<Badge variant="outline" className="justify-center">
MCP servers: {diagnostics.config.mcpServerCount}
</Badge>
</div>
{diagnostics.config.topLevelKeys.length > 0 && (
<div className="space-y-2">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
User-layer keys present
</p>
<div className="flex flex-wrap gap-1.5">
{diagnostics.config.topLevelKeys.map((key) => (
<Badge key={key} variant="secondary" className="font-mono text-[10px]">
{key}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
<QuickCommands
snippets={[
{
label: 'Native Codex',
command: 'ccs-codex',
description: 'Launch the native Codex runtime alias.',
},
{
label: 'Short alias',
command: 'ccsx',
description: 'Launch the short Codex runtime alias.',
},
{
label: 'Target override',
command: 'ccs codex --target codex "your prompt"',
description: 'Run a CCS profile on the native Codex target.',
},
{
label: 'Workspace trust',
command: `codex --profile ${diagnostics.config.activeProfile || 'default'}`,
description: 'Inspect the active profile directly in native Codex.',
},
]}
/>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Route className="h-4 w-4" />
Runtime vs provider
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2">
<div className="rounded-md border p-3 text-sm">
<p className="font-medium">Native Codex runtime</p>
<p className="mt-1 text-muted-foreground">
Use <code>ccs-codex</code>, <code>ccsx</code>, or <code>--target codex</code>. CCS
launches the local Codex CLI and depends on native Codex capabilities such as{' '}
<code>--config</code> overrides.
</p>
</div>
<div className="rounded-md border p-3 text-sm">
<p className="font-medium">Codex provider / bridge</p>
<p className="mt-1 text-muted-foreground">
CCS can route provider credentials transiently through CLIProxy. That is not the
same as editing local <code>config.toml</code>, and some routed values may never
persist here.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Supported flows</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Flow</TableHead>
<TableHead>Status</TableHead>
<TableHead>Notes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{diagnostics.supportMatrix.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="font-mono text-xs">{entry.label}</TableCell>
<TableCell>
<Badge variant={entry.supported ? 'default' : 'secondary'}>
{entry.supported ? 'Yes' : 'No'}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{entry.notes}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{diagnostics.warnings.length > 0 && (
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4 text-amber-600" />
Warnings
</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5">
{diagnostics.warnings.map((warning) => (
<p key={warning} className="text-sm text-amber-800 dark:text-amber-300">
- {warning}
</p>
))}
</CardContent>
</Card>
)}
</div>
</ScrollArea>
);
}
@@ -0,0 +1,250 @@
import { useMemo, useState } from 'react';
import { Layers3, Loader2, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { CodexProfilePatchValues } from '@/hooks/use-codex-types';
import type { CodexProfileEntry } from '@/lib/codex-config';
import { CodexConfigCardShell } from './codex-config-card-shell';
interface CodexProfilesCardProps {
activeProfile: string | null;
entries: CodexProfileEntry[];
providerNames: string[];
disabled?: boolean;
disabledReason?: string | null;
saving?: boolean;
onSave: (
name: string,
values: CodexProfilePatchValues,
setAsActive: boolean
) => Promise<void> | void;
onDelete: (name: string) => Promise<void> | void;
onSetActive: (name: string) => Promise<void> | void;
}
interface ProfileEditorProps {
initialName: string;
initialModel: string | null;
initialProvider: string | null;
initialEffort: string | null;
providerNames: string[];
activeProfile: string | null;
selectedEntryName: string | null;
disabled: boolean;
saving: boolean;
onSave: (
name: string,
values: CodexProfilePatchValues,
setAsActive: boolean
) => Promise<void> | void;
onDelete: () => Promise<void> | void;
onSetActive: () => Promise<void> | void;
}
function ProfileEditor({
initialName,
initialModel,
initialProvider,
initialEffort,
providerNames,
activeProfile,
selectedEntryName,
disabled,
saving,
onSave,
onDelete,
onSetActive,
}: ProfileEditorProps) {
const [nameDraft, setNameDraft] = useState(initialName);
const [modelDraft, setModelDraft] = useState<string | null>(initialModel);
const [providerDraft, setProviderDraft] = useState<string | null>(initialProvider);
const [effortDraft, setEffortDraft] = useState<string | null>(initialEffort);
return (
<>
<div className="grid gap-3 sm:grid-cols-2">
<Input
value={nameDraft}
onChange={(event) => setNameDraft(event.target.value)}
placeholder="deep-review"
disabled={disabled || selectedEntryName !== null}
/>
<Input
value={modelDraft ?? ''}
onChange={(event) => setModelDraft(event.target.value || null)}
placeholder="gpt-5.4"
disabled={disabled}
/>
<Select
value={providerDraft ?? '__unset__'}
onValueChange={(next) => setProviderDraft(next === '__unset__' ? null : next)}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use global provider" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unset__">Use global provider</SelectItem>
{providerNames.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={effortDraft ?? '__unset__'}
onValueChange={(next) => setEffortDraft(next === '__unset__' ? null : next)}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use global effort" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unset__">Use global effort</SelectItem>
{['minimal', 'low', 'medium', 'high', 'xhigh'].map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-between gap-2">
<div className="flex gap-2">
<Button
variant="outline"
onClick={onDelete}
disabled={disabled || saving || !selectedEntryName}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
<Button
variant="outline"
onClick={onSetActive}
disabled={
disabled || saving || !selectedEntryName || selectedEntryName === activeProfile
}
>
Set active
</Button>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() =>
onSave(
nameDraft,
{
model: modelDraft,
modelProvider: providerDraft,
modelReasoningEffort: effortDraft,
},
false
)
}
disabled={disabled || saving || nameDraft.trim().length === 0}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save profile
</Button>
<Button
onClick={() =>
onSave(
nameDraft,
{
model: modelDraft,
modelProvider: providerDraft,
modelReasoningEffort: effortDraft,
},
true
)
}
disabled={disabled || saving || nameDraft.trim().length === 0}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save + activate
</Button>
</div>
</div>
</>
);
}
export function CodexProfilesCard({
activeProfile,
entries,
providerNames,
disabled = false,
disabledReason,
saving = false,
onSave,
onDelete,
onSetActive,
}: CodexProfilesCardProps) {
const [selectedName, setSelectedName] = useState('new');
const selectedEntry = useMemo(
() => entries.find((entry) => entry.name === selectedName) ?? null,
[entries, selectedName]
);
const draftKey = JSON.stringify(selectedEntry ?? { name: '', values: {} });
return (
<CodexConfigCardShell
title="Profiles"
badge="profiles"
icon={<Layers3 className="h-4 w-4" />}
description="Create reusable Codex overlays and set the active default profile."
disabledReason={disabledReason}
>
<Select value={selectedName} onValueChange={setSelectedName} disabled={disabled}>
<SelectTrigger>
<SelectValue placeholder="Select profile" />
</SelectTrigger>
<SelectContent>
<SelectItem value="new">Create new profile</SelectItem>
{entries.map((entry) => (
<SelectItem key={entry.name} value={entry.name}>
{entry.name}
{entry.name === activeProfile ? ' (active)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<ProfileEditor
key={draftKey}
initialName={selectedEntry?.name ?? ''}
initialModel={selectedEntry?.values.model ?? null}
initialProvider={selectedEntry?.values.modelProvider ?? null}
initialEffort={selectedEntry?.values.modelReasoningEffort ?? null}
providerNames={providerNames}
activeProfile={activeProfile}
selectedEntryName={selectedEntry?.name ?? null}
disabled={disabled}
saving={saving}
onDelete={async () => {
if (!selectedEntry) return;
await onDelete(selectedEntry.name);
setSelectedName('new');
}}
onSetActive={async () => {
if (!selectedEntry) return;
await onSetActive(selectedEntry.name);
}}
onSave={async (name, values, setAsActive) => {
await onSave(name, values, setAsActive);
setSelectedName(name);
}}
/>
</CodexConfigCardShell>
);
}
@@ -0,0 +1,141 @@
import { useState } from 'react';
import { FolderCheck, Loader2, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { CodexProjectTrustEntry } from '@/lib/codex-config';
import { CodexConfigCardShell } from './codex-config-card-shell';
interface CodexProjectTrustCardProps {
workspacePath: string;
entries: CodexProjectTrustEntry[];
disabled?: boolean;
disabledReason?: string | null;
saving?: boolean;
onSave: (path: string, trustLevel: string | null) => Promise<void> | void;
}
interface ProjectTrustComposerProps {
workspacePath: string;
disabled: boolean;
saving: boolean;
onSave: (path: string, trustLevel: string | null) => Promise<void> | void;
}
function ProjectTrustComposer({
workspacePath,
disabled,
saving,
onSave,
}: ProjectTrustComposerProps) {
const [pathDraft, setPathDraft] = useState(workspacePath);
const [trustLevel, setTrustLevel] = useState('trusted');
return (
<div className="grid gap-2 sm:grid-cols-[1fr_160px_auto]">
<Input
value={pathDraft}
onChange={(event) => setPathDraft(event.target.value)}
placeholder="~/repo or /absolute/path"
disabled={disabled}
/>
<Select value={trustLevel} onValueChange={setTrustLevel} disabled={disabled}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="trusted">trusted</SelectItem>
<SelectItem value="untrusted">untrusted</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => onSave(pathDraft, trustLevel)} disabled={disabled || saving}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save trust
</Button>
</div>
);
}
export function CodexProjectTrustCard({
workspacePath,
entries,
disabled = false,
disabledReason,
saving = false,
onSave,
}: CodexProjectTrustCardProps) {
return (
<CodexConfigCardShell
title="Project trust"
badge="projects"
icon={<FolderCheck className="h-4 w-4" />}
description="Trust current workspaces or remove stale trust entries without opening raw TOML."
disabledReason={disabledReason}
>
<p className="text-xs text-muted-foreground">
Paths must be absolute or start with <code>~/</code>. Relative paths are rejected so CCS
does not trust the wrong folder.
</p>
<ProjectTrustComposer
key={workspacePath}
workspacePath={workspacePath}
disabled={disabled}
saving={saving}
onSave={onSave}
/>
<Button
variant="outline"
className="w-full justify-start"
onClick={() => onSave(workspacePath, 'trusted')}
disabled={disabled || saving}
>
Trust current workspace
</Button>
<div className="space-y-2">
{entries.length === 0 ? (
<p className="text-xs text-muted-foreground">No explicit project trust entries saved.</p>
) : (
entries.map((entry) => (
<div
key={entry.path}
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{entry.path}</p>
<p className="text-xs text-muted-foreground">trust_level = {entry.trustLevel}</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
onSave(entry.path, entry.trustLevel === 'trusted' ? 'untrusted' : 'trusted')
}
disabled={disabled || saving}
>
Toggle
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onSave(entry.path, null)}
disabled={disabled || saving}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</CodexConfigCardShell>
);
}
@@ -0,0 +1,305 @@
import { useState } from 'react';
import { Loader2, SlidersHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { CodexTopLevelSettingsPatch } from '@/hooks/use-codex-types';
import type { CodexTopLevelSettingsView } from '@/lib/codex-config';
import { CodexConfigCardShell } from './codex-config-card-shell';
const UNSET = '__unset__';
interface CodexTopLevelControlsCardProps {
values: CodexTopLevelSettingsView;
providerNames: string[];
disabled?: boolean;
disabledReason?: string | null;
saving?: boolean;
onSave: (values: CodexTopLevelSettingsPatch) => Promise<void> | void;
}
function toSelectValue(value: string | null | undefined) {
return value ?? UNSET;
}
function withCurrentValue(options: string[], current: string | null | undefined) {
return current && !options.includes(current) ? [current, ...options] : options;
}
function buildTopLevelPatch(
initialValues: CodexTopLevelSettingsView,
draft: CodexTopLevelSettingsView
): CodexTopLevelSettingsPatch {
const patch: CodexTopLevelSettingsPatch = {};
if (draft.model !== initialValues.model) patch.model = draft.model;
if (draft.modelReasoningEffort !== initialValues.modelReasoningEffort) {
patch.modelReasoningEffort = draft.modelReasoningEffort;
}
if (draft.modelProvider !== initialValues.modelProvider) {
patch.modelProvider = draft.modelProvider;
}
if (draft.approvalPolicy !== initialValues.approvalPolicy) {
patch.approvalPolicy = draft.approvalPolicy;
}
if (draft.sandboxMode !== initialValues.sandboxMode) patch.sandboxMode = draft.sandboxMode;
if (draft.webSearch !== initialValues.webSearch) patch.webSearch = draft.webSearch;
if (draft.toolOutputTokenLimit !== initialValues.toolOutputTokenLimit) {
patch.toolOutputTokenLimit = draft.toolOutputTokenLimit;
}
if (draft.personality !== initialValues.personality) patch.personality = draft.personality;
return patch;
}
interface TopLevelControlsFormProps {
initialValues: CodexTopLevelSettingsView;
providerNames: string[];
disabled: boolean;
saving: boolean;
onSave: (values: CodexTopLevelSettingsPatch) => Promise<void> | void;
}
function TopLevelControlsForm({
initialValues,
providerNames,
disabled,
saving,
onSave,
}: TopLevelControlsFormProps) {
const [draft, setDraft] = useState<CodexTopLevelSettingsView>(initialValues);
const reasoningOptions = withCurrentValue(
['minimal', 'low', 'medium', 'high', 'xhigh'],
draft.modelReasoningEffort
);
const providerOptions = withCurrentValue(providerNames, draft.modelProvider);
const approvalOptions = withCurrentValue(
['on-request', 'never', 'untrusted'],
draft.approvalPolicy
);
const sandboxOptions = withCurrentValue(
['read-only', 'workspace-write', 'danger-full-access'],
draft.sandboxMode
);
const webSearchOptions = withCurrentValue(['cached', 'live', 'disabled'], draft.webSearch);
const personalityOptions = withCurrentValue(['none', 'friendly', 'pragmatic'], draft.personality);
const patch = buildTopLevelPatch(initialValues, draft);
const hasChanges = Object.keys(patch).length > 0;
return (
<>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<p className="text-xs font-medium">Model</p>
<Input
value={draft.model ?? ''}
onChange={(event) =>
setDraft((current) => ({ ...current, model: event.target.value || null }))
}
placeholder="gpt-5.4"
disabled={disabled}
/>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Reasoning effort</p>
<Select
value={toSelectValue(draft.modelReasoningEffort)}
onValueChange={(next) =>
setDraft((current) => ({
...current,
modelReasoningEffort: next === UNSET ? null : next,
}))
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use default</SelectItem>
{reasoningOptions.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Default provider</p>
<Select
value={toSelectValue(draft.modelProvider)}
onValueChange={(next) =>
setDraft((current) => ({ ...current, modelProvider: next === UNSET ? null : next }))
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use Codex default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use Codex default</SelectItem>
{providerOptions.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Approval policy</p>
<Select
value={toSelectValue(draft.approvalPolicy)}
onValueChange={(next) =>
setDraft((current) => ({ ...current, approvalPolicy: next === UNSET ? null : next }))
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use default</SelectItem>
{approvalOptions.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Sandbox mode</p>
<Select
value={toSelectValue(draft.sandboxMode)}
onValueChange={(next) =>
setDraft((current) => ({ ...current, sandboxMode: next === UNSET ? null : next }))
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use default</SelectItem>
{sandboxOptions.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Web search</p>
<Select
value={toSelectValue(draft.webSearch)}
onValueChange={(next) =>
setDraft((current) => ({ ...current, webSearch: next === UNSET ? null : next }))
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use default</SelectItem>
{webSearchOptions.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Tool output token limit</p>
<Input
type="number"
min={1}
value={draft.toolOutputTokenLimit ?? ''}
onChange={(event) =>
setDraft((current) => ({
...current,
toolOutputTokenLimit: event.target.value ? Number(event.target.value) : null,
}))
}
placeholder="25000"
disabled={disabled}
/>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Personality</p>
<Select
value={toSelectValue(draft.personality)}
onValueChange={(next) =>
setDraft((current) => ({ ...current, personality: next === UNSET ? null : next }))
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use default</SelectItem>
{personalityOptions.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end">
<Button onClick={() => onSave(patch)} disabled={disabled || saving || !hasChanges}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save top-level settings
</Button>
</div>
</>
);
}
export function CodexTopLevelControlsCard({
values,
providerNames,
disabled = false,
disabledReason,
saving = false,
onSave,
}: CodexTopLevelControlsCardProps) {
return (
<CodexConfigCardShell
title="Top-level controls"
badge="config.toml"
icon={<SlidersHorizontal className="h-4 w-4" />}
description="Structured controls for the stable top-level Codex settings users touch most often. Unsupported upstream shapes stay untouched and should be edited in raw TOML."
disabledReason={disabledReason}
>
<TopLevelControlsForm
key={JSON.stringify(values)}
initialValues={values}
providerNames={providerNames}
disabled={disabled}
saving={saving}
onSave={onSave}
/>
</CodexConfigCardShell>
);
}
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { type ReactNode, useState } from 'react';
import { toast } from 'sonner';
import { Copy, FileCode2, Loader2, RefreshCw, Save } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
@@ -7,33 +7,47 @@ import { CodeEditor } from '@/components/shared/code-editor';
import i18n from '@/lib/i18n';
import { cn } from '@/lib/utils';
interface RawJsonSettingsEditorPanelProps {
interface RawConfigEditorPanelProps {
title: string;
pathLabel: string;
loading: boolean;
parseWarning: string | null | undefined;
readWarning?: string | null | undefined;
value: string;
dirty: boolean;
readOnly?: boolean;
saving: boolean;
saveDisabled: boolean;
onChange: (nextValue: string) => void;
onSave: () => Promise<void> | void;
onRefresh: () => Promise<void> | void;
onDiscard?: () => void;
language?: 'json' | 'yaml' | 'toml';
loadingLabel?: string;
parseWarningLabel?: string;
ownershipNotice?: ReactNode;
}
export function RawJsonSettingsEditorPanel({
export function RawConfigEditorPanel({
title,
pathLabel,
loading,
parseWarning,
readWarning,
value,
dirty,
readOnly = false,
saving,
saveDisabled,
onChange,
onSave,
onRefresh,
}: RawJsonSettingsEditorPanelProps) {
onDiscard,
language = 'json',
loadingLabel = 'Loading settings.json...',
parseWarningLabel = 'Parse warning',
ownershipNotice,
}: RawConfigEditorPanelProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
@@ -68,11 +82,16 @@ export function RawJsonSettingsEditorPanel({
)}
Save
</Button>
{onDiscard ? (
<Button variant="outline" size="sm" onClick={onDiscard} disabled={!dirty || loading}>
Discard
</Button>
) : null}
<Button variant="outline" size="sm" onClick={handleCopy} disabled={!value}>
<Copy className="h-4 w-4 mr-1" />
{copied ? 'Copied' : 'Copy'}
</Button>
<Button variant="outline" size="sm" onClick={onRefresh}>
<Button variant="outline" size="sm" onClick={onRefresh} aria-label="Refresh raw config">
<RefreshCw className={cn('h-4 w-4', loading ? 'animate-spin' : '')} />
</Button>
</div>
@@ -82,13 +101,19 @@ export function RawJsonSettingsEditorPanel({
{loading ? (
<div className="h-full flex items-center justify-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
Loading settings.json...
{loadingLabel}
</div>
) : (
<div className="flex h-full min-h-0 flex-col">
{ownershipNotice && <div className="mx-4 mt-4">{ownershipNotice}</div>}
{parseWarning && (
<div className="mx-4 mt-4 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:bg-amber-950/20 dark:text-amber-300">
Parse warning: {parseWarning}
{parseWarningLabel}: {parseWarning}
</div>
)}
{readWarning && (
<div className="mx-4 mt-4 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
Read-only: {readWarning}
</div>
)}
<div className="min-h-0 flex-1 p-4 pt-3">
@@ -96,7 +121,8 @@ export function RawJsonSettingsEditorPanel({
<CodeEditor
value={value}
onChange={onChange}
language="json"
language={language}
readonly={readOnly}
minHeight="100%"
heightMode="fill-parent"
/>
@@ -108,3 +134,5 @@ export function RawJsonSettingsEditorPanel({
</div>
);
}
export const RawJsonSettingsEditorPanel = RawConfigEditorPanel;
+1
View File
@@ -119,6 +119,7 @@ function buildNavGroups(t: (key: string) => string): SidebarGroupDef[] {
title: t('nav.compatibleClis'),
items: [
{ path: '/claude-extension', icon: Puzzle, label: t('nav.claudeExtension') },
{ path: '/codex', iconSrc: '/assets/sidebar/codex.svg', label: 'Codex CLI' },
{ path: '/droid', icon: TerminalSquare, label: t('nav.factoryDroid') },
],
},
+36 -3
View File
@@ -1,12 +1,17 @@
/**
* Code Editor Component
* Lightweight JSON editor with syntax highlighting, line numbers, and validation
* Lightweight JSON/TOML editor with syntax highlighting, line numbers, and validation
* Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB)
*/
import { useState, useCallback, useMemo } from 'react';
import Editor from 'react-simple-code-editor';
import { Highlight, themes } from 'prism-react-renderer';
import Prism from 'prismjs';
import 'prismjs/components/prism-json';
import 'prismjs/components/prism-yaml';
import 'prismjs/components/prism-toml';
import { parse as parseToml } from 'smol-toml';
import { useTheme } from '@/hooks/use-theme';
import { cn } from '@/lib/utils';
import { isSensitiveKey } from '@/lib/sensitive-keys';
@@ -16,7 +21,7 @@ import { Button } from '@/components/ui/button';
interface CodeEditorProps {
value: string;
onChange: (value: string) => void;
language?: 'json' | 'yaml';
language?: 'json' | 'yaml' | 'toml';
readonly?: boolean;
className?: string;
minHeight?: string;
@@ -64,6 +69,26 @@ function validateJson(code: string): ValidationResult {
}
}
function validateToml(code: string): ValidationResult {
if (!code.trim()) {
return { valid: true };
}
try {
parseToml(code);
return { valid: true };
} catch (error) {
const message = (error as Error).message;
const lineMatch = message.match(/line\s+(\d+)/i);
return {
valid: false,
error: message,
line: lineMatch ? Number.parseInt(lineMatch[1], 10) : undefined,
};
}
}
export function CodeEditor({
value,
onChange,
@@ -83,6 +108,9 @@ export function CodeEditor({
if (language === 'json') {
return validateJson(value);
}
if (language === 'toml') {
return validateToml(value);
}
return { valid: true };
}, [value, language]);
@@ -90,7 +118,12 @@ export function CodeEditor({
// Note: Line numbers removed - they break textarea/pre alignment in react-simple-code-editor
const highlightCode = useCallback(
(code: string) => (
<Highlight theme={isDark ? themes.nightOwl : themes.github} code={code} language={language}>
<Highlight
prism={Prism}
theme={isDark ? themes.nightOwl : themes.github}
code={code}
language={language}
>
{({ tokens, getLineProps, getTokenProps }) => {
let nextValueIsSensitive = false;
+22
View File
@@ -0,0 +1,22 @@
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';
+151
View File
@@ -0,0 +1,151 @@
import { useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ApiConflictError, withApiBase } from '@/lib/api-client';
import { safeParseTomlObject } from '@shared/toml-object';
import type {
CodexConfigPatchInput,
CodexConfigPatchResult,
CodexDashboardDiagnostics,
CodexRawConfigResponse,
} from './use-codex-types';
type CodexRawConfig = CodexRawConfigResponse;
interface SaveCodexRawConfigInput {
rawText: string;
expectedMtime?: number;
}
interface SaveCodexRawConfigResponse {
success: true;
mtime: number;
}
type PatchCodexConfigResponse = CodexConfigPatchResult;
async function fetchCodexDiagnostics(): Promise<CodexDashboardDiagnostics> {
const res = await fetch(withApiBase('/codex/diagnostics'));
if (!res.ok) throw new Error('Failed to fetch Codex diagnostics');
return res.json();
}
async function fetchCodexRawConfig(): Promise<CodexRawConfig> {
const res = await fetch(withApiBase('/codex/config/raw'));
if (!res.ok) throw new Error('Failed to fetch Codex raw config');
return res.json();
}
async function saveCodexRawConfig(
data: SaveCodexRawConfigInput
): Promise<SaveCodexRawConfigResponse> {
const res = await fetch(withApiBase('/codex/config/raw'), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (res.status === 409) throw new ApiConflictError('Codex raw config changed externally');
if (!res.ok) {
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(payload?.error || 'Failed to save Codex raw config');
}
return res.json();
}
async function patchCodexConfig(data: CodexConfigPatchInput): Promise<PatchCodexConfigResponse> {
const res = await fetch(withApiBase('/codex/config/patch'), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (res.status === 409) throw new ApiConflictError('Codex config changed externally');
if (!res.ok) {
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(payload?.error || 'Failed to patch Codex config');
}
return res.json();
}
export function useCodex() {
const queryClient = useQueryClient();
const diagnosticsQuery = useQuery({
queryKey: ['codex-diagnostics'],
queryFn: fetchCodexDiagnostics,
refetchInterval: 10000,
});
const rawConfigQuery = useQuery({
queryKey: ['codex-raw-config'],
queryFn: fetchCodexRawConfig,
});
const saveRawConfigMutation = useMutation({
mutationFn: saveCodexRawConfig,
onSuccess: (result, variables) => {
queryClient.setQueryData<CodexRawConfig>(['codex-raw-config'], (current) => {
const path = current?.path ?? '$CODEX_HOME/config.toml';
const resolvedPath = current?.resolvedPath ?? path;
const parsed = safeParseTomlObject(variables.rawText);
return {
path,
resolvedPath,
exists: true,
mtime: result.mtime,
rawText: variables.rawText,
config: parsed.config,
parseError: parsed.parseError,
readError: null,
};
});
queryClient.invalidateQueries({ queryKey: ['codex-diagnostics'] });
},
});
const patchConfigMutation = useMutation({
mutationFn: patchCodexConfig,
onSuccess: (result) => {
queryClient.setQueryData<CodexRawConfig>(['codex-raw-config'], result);
queryClient.invalidateQueries({ queryKey: ['codex-diagnostics'] });
},
});
return useMemo(
() => ({
diagnostics: diagnosticsQuery.data,
diagnosticsLoading: diagnosticsQuery.isLoading,
diagnosticsError: diagnosticsQuery.error,
refetchDiagnostics: diagnosticsQuery.refetch,
rawConfig: rawConfigQuery.data,
rawConfigLoading: rawConfigQuery.isLoading,
rawConfigError: rawConfigQuery.error,
refetchRawConfig: rawConfigQuery.refetch,
saveRawConfig: saveRawConfigMutation.mutate,
saveRawConfigAsync: saveRawConfigMutation.mutateAsync,
isSavingRawConfig: saveRawConfigMutation.isPending,
patchConfig: patchConfigMutation.mutate,
patchConfigAsync: patchConfigMutation.mutateAsync,
isPatchingConfig: patchConfigMutation.isPending,
}),
[
diagnosticsQuery.data,
diagnosticsQuery.isLoading,
diagnosticsQuery.error,
diagnosticsQuery.refetch,
rawConfigQuery.data,
rawConfigQuery.isLoading,
rawConfigQuery.error,
rawConfigQuery.refetch,
saveRawConfigMutation.mutate,
saveRawConfigMutation.mutateAsync,
saveRawConfigMutation.isPending,
patchConfigMutation.mutate,
patchConfigMutation.mutateAsync,
patchConfigMutation.isPending,
]
);
}
+4 -16
View File
@@ -1,6 +1,10 @@
import { useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ApiConflictError, withApiBase } from '@/lib/api-client';
import type {
CompatibleCliDocLink,
CompatibleCliProviderDocLink,
} from '@shared/compatible-cli-contracts';
export interface DroidBinaryDiagnostics {
installed: boolean;
@@ -36,22 +40,6 @@ export interface DroidCustomModelDiagnostics {
apiKeyPreview: string | null;
}
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 DroidDashboardDiagnostics {
binary: DroidBinaryDiagnostics;
files: {
+228
View File
@@ -0,0 +1,228 @@
export interface CodexTopLevelSettingsView {
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 CodexProjectTrustEntry {
path: string;
trustLevel: string;
}
export interface CodexProfileEntry {
name: string;
values: CodexTopLevelSettingsView;
}
export interface CodexModelProviderEntry {
name: string;
displayName: string | null;
baseUrl: string | null;
envKey: string | null;
wireApi: string | null;
requiresOpenaiAuth: boolean;
supportsWebsockets: boolean;
}
export interface CodexMcpServerEntry {
name: string;
transport: 'stdio' | 'streamable-http';
command: string | null;
args: string[];
url: string | null;
enabled: boolean;
required: boolean;
startupTimeoutSec: number | null;
toolTimeoutSec: number | null;
enabledTools: string[];
disabledTools: string[];
}
export interface CodexFeatureCatalogEntry {
name: string;
label: string;
description: string;
}
export const KNOWN_CODEX_FEATURES: CodexFeatureCatalogEntry[] = [
{
name: 'multi_agent',
label: 'Multi-agent',
description: 'Enable subagent collaboration tools.',
},
{
name: 'unified_exec',
label: 'Unified exec',
description: 'Use the PTY-backed unified exec tool.',
},
{
name: 'shell_snapshot',
label: 'Shell snapshot',
description: 'Reuse shell environment snapshots.',
},
{
name: 'apply_patch_freeform',
label: 'Apply patch',
description: 'Enable freeform apply_patch edits.',
},
{ name: 'js_repl', label: 'JS REPL', description: 'Enable the Node-backed JavaScript REPL.' },
{
name: 'runtime_metrics',
label: 'Runtime metrics',
description: 'Collect Codex runtime metrics.',
},
{
name: 'prevent_idle_sleep',
label: 'Prevent idle sleep',
description: 'Keep the machine awake while active.',
},
{ name: 'fast_mode', label: 'Fast mode', description: 'Allow the fast service tier path.' },
{ name: 'apps', label: 'Apps', description: 'Enable ChatGPT Apps and connectors support.' },
{
name: 'smart_approvals',
label: 'Smart approvals',
description: 'Route eligible approvals through the guardian flow.',
},
];
function asObject(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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 asStringArray(value: unknown): string[] {
return Array.isArray(value)
? value
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
: [];
}
export function readCodexTopLevelSettings(
config: Record<string, unknown> | null
): CodexTopLevelSettingsView {
return {
model: asString(config?.model),
modelReasoningEffort: asString(config?.model_reasoning_effort),
modelProvider: asString(config?.model_provider),
approvalPolicy: asString(config?.approval_policy),
sandboxMode: asString(config?.sandbox_mode),
webSearch: asString(config?.web_search),
toolOutputTokenLimit: asNumber(config?.tool_output_token_limit),
personality: asString(config?.personality),
};
}
export function readCodexProjectTrust(
config: Record<string, unknown> | null
): CodexProjectTrustEntry[] {
const projects = asObject(config?.projects);
if (!projects) return [];
return Object.entries(projects)
.map(([projectPath, value]) => {
const trustLevel = asString(asObject(value)?.trust_level);
return trustLevel ? { path: projectPath, trustLevel } : null;
})
.filter((entry): entry is CodexProjectTrustEntry => entry !== null)
.sort((left, right) => left.path.localeCompare(right.path));
}
export function readCodexProfiles(config: Record<string, unknown> | null): CodexProfileEntry[] {
const profiles = asObject(config?.profiles);
if (!profiles) return [];
return Object.entries(profiles)
.map(([name, value]) => ({ name, values: readCodexTopLevelSettings(asObject(value)) }))
.sort((left, right) => left.name.localeCompare(right.name));
}
export function readCodexModelProviders(
config: Record<string, unknown> | null
): CodexModelProviderEntry[] {
const providers = asObject(config?.model_providers);
if (!providers) return [];
return Object.entries(providers)
.map(([name, value]) => {
const provider = asObject(value);
if (!provider) return null;
return {
name,
displayName: asString(provider.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,
};
})
.filter((entry): entry is CodexModelProviderEntry => entry !== null)
.sort((left, right) => left.name.localeCompare(right.name));
}
export function readCodexMcpServers(config: Record<string, unknown> | null): CodexMcpServerEntry[] {
const servers = asObject(config?.mcp_servers);
if (!servers) return [];
return Object.entries(servers)
.map(([name, value]) => {
const server = asObject(value);
if (!server) return null;
const transport = asString(server.command) ? 'stdio' : 'streamable-http';
const startupTimeoutMs = asNumber(server.startup_timeout_ms);
return {
name,
transport,
command: asString(server.command),
args: asStringArray(server.args),
url: asString(server.url),
enabled: server.enabled !== false,
required: server.required === true,
startupTimeoutSec:
asNumber(server.startup_timeout_sec) ??
(startupTimeoutMs !== null ? startupTimeoutMs / 1000 : null),
toolTimeoutSec: asNumber(server.tool_timeout_sec),
enabledTools: asStringArray(server.enabled_tools),
disabledTools: asStringArray(server.disabled_tools),
};
})
.filter((entry): entry is CodexMcpServerEntry => entry !== null)
.sort((left, right) => left.name.localeCompare(right.name));
}
export function readCodexFeatureState(
config: Record<string, unknown> | null
): Record<string, boolean | null> {
const features = asObject(config?.features);
const state: Record<string, boolean | null> = {};
for (const feature of KNOWN_CODEX_FEATURES) {
const value = features?.[feature.name];
state[feature.name] = typeof value === 'boolean' ? value : null;
}
if (features) {
for (const [name, value] of Object.entries(features)) {
if (!(name in state)) {
state[name] = typeof value === 'boolean' ? value : null;
}
}
}
return state;
}
+4 -4
View File
@@ -839,7 +839,7 @@ const resources = {
supportLine1Suffix: '(token-based)',
supportLine2Prefix: 'Reasoning effort:',
supportLine2SuffixPrefix: '(suffix or ',
supportLine2SuffixPostfix: ': medium/high/xhigh)',
supportLine2SuffixPostfix: ': minimal/low/medium/high/xhigh)',
supportLine3Prefix: 'Codex suffixes pin effort (for example ',
supportLine3Suffix: '); unsuffixed models use Thinking mode.',
},
@@ -2012,7 +2012,7 @@ const resources = {
supportLine1Suffix: '(基于 token',
supportLine2Prefix: '推理强度:',
supportLine2SuffixPrefix: '(后缀或 ',
supportLine2SuffixPostfix: 'medium/high/xhigh',
supportLine2SuffixPostfix: 'minimal/low/medium/high/xhigh',
supportLine3Prefix: 'Codex 后缀会固定强度(例如 ',
supportLine3Suffix: ');无后缀模型使用 Thinking mode。',
},
@@ -3232,7 +3232,7 @@ const resources = {
supportLine1Suffix: '(dựa trên token)',
supportLine2Prefix: 'Nỗ lực lý luận:',
supportLine2SuffixPrefix: '(hậu tố hoặc ',
supportLine2SuffixPostfix: ': medium/high/xhigh)',
supportLine2SuffixPostfix: ': minimal/low/medium/high/xhigh)',
supportLine3Prefix: 'Hậu tố Codex cố định mức effort (ví dụ ',
supportLine3Suffix: '); model không hậu tố sẽ dùng chế độ Thinking.',
},
@@ -4469,7 +4469,7 @@ const resources = {
supportLine1Suffix: '(トークンベース)',
supportLine2Prefix: '推論強度:',
supportLine2SuffixPrefix: '(サフィックス、または ',
supportLine2SuffixPostfix: ': medium/high/xhigh)',
supportLine2SuffixPostfix: ': minimal/low/medium/high/xhigh)',
supportLine3Prefix: 'Codex のサフィックスは推論強度を固定します(例: ',
supportLine3Suffix: ')。サフィックスなしのモデルは思考モードを使います。',
},
+4 -1
View File
@@ -689,7 +689,10 @@ export function findCatalogModel(provider: string, modelId: string) {
.sort((left, right) => 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;
+66 -1
View File
@@ -55,6 +55,48 @@ export const SUPPORT_SCOPE_LABELS: Record<SupportScope, string> = {
};
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-codex-dashboard',
label: 'Open Codex dashboard',
description: 'Review Codex runtime support, config layers, and dashboard setup flows.',
type: 'route',
path: '/codex',
},
],
routes: [{ label: 'Codex CLI', path: '/codex' }],
commands: ['ccs-codex', 'ccsx', 'ccs codex --target codex "your prompt"'],
},
{
id: 'droid-target-support',
title: 'Factory Droid support is live',
@@ -185,6 +227,24 @@ 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: 'Codex CLI', path: '/codex' }],
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 +260,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',
+267
View File
@@ -0,0 +1,267 @@
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { GripVertical, Loader2 } from 'lucide-react';
import { CodexControlCenterTab } from '@/components/compatible-cli/codex-control-center-tab';
import { CodexDocsTab } from '@/components/compatible-cli/codex-docs-tab';
import { useCodex } from '@/hooks/use-codex';
import { isApiConflictError } from '@/lib/api-client';
import { CodexOverviewTab } from '@/components/compatible-cli/codex-overview-tab';
import { RawConfigEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
KNOWN_CODEX_FEATURES,
readCodexFeatureState,
readCodexMcpServers,
readCodexModelProviders,
readCodexProfiles,
readCodexProjectTrust,
readCodexTopLevelSettings,
} from '@/lib/codex-config';
import { safeParseTomlObject } from '@shared/toml-object';
export function CodexPage() {
const {
diagnostics,
diagnosticsLoading,
diagnosticsError,
refetchDiagnostics,
rawConfig,
rawConfigLoading,
rawConfigError,
refetchRawConfig,
saveRawConfigAsync,
isSavingRawConfig,
patchConfigAsync,
isPatchingConfig,
} = useCodex();
const [rawDraftText, setRawDraftText] = useState<string | null>(null);
const rawBaseText = rawConfig?.rawText ?? '';
const rawEditorText = rawDraftText ?? rawBaseText;
const rawConfigDirty = rawDraftText !== null && rawDraftText !== rawBaseText;
const rawEditorParsed = safeParseTomlObject(rawEditorText);
const rawEditorValidation = rawEditorParsed.parseError
? { valid: false as const, error: rawEditorParsed.parseError }
: { valid: true as const };
const controlsConfig = rawConfig?.config ?? null;
const structuredControlsDisabled =
rawConfigLoading ||
!rawConfig ||
rawConfigDirty ||
rawConfig?.parseError !== null ||
rawConfig?.readError !== null;
const controlsDisabledReason = rawConfigError
? 'Structured controls unavailable: failed to load the current config.toml.'
: rawConfig?.readError
? `Structured controls unavailable: ${rawConfig.readError}`
: rawConfigDirty
? rawEditorValidation.valid
? 'Save or discard raw TOML edits before using structured controls.'
: 'Fix or discard raw TOML edits before using structured controls.'
: rawConfig?.parseError
? `Structured controls disabled: ${rawConfig.parseError}`
: null;
const topLevelSettings = useMemo(
() => readCodexTopLevelSettings(controlsConfig),
[controlsConfig]
);
const projectTrustEntries = useMemo(
() => readCodexProjectTrust(controlsConfig),
[controlsConfig]
);
const profileEntries = useMemo(() => readCodexProfiles(controlsConfig), [controlsConfig]);
const modelProviderEntries = useMemo(
() => readCodexModelProviders(controlsConfig),
[controlsConfig]
);
const mcpServerEntries = useMemo(() => readCodexMcpServers(controlsConfig), [controlsConfig]);
const featureState = useMemo(() => readCodexFeatureState(controlsConfig), [controlsConfig]);
const setRawEditorDraftText = (nextText: string) => {
if (nextText === rawBaseText) {
setRawDraftText(null);
return;
}
setRawDraftText(nextText);
};
const refreshAll = async () => {
try {
const results = await Promise.all([refetchDiagnostics(), refetchRawConfig()]);
const refreshFailed = results.some(
(result) => !result || result.status === 'error' || result.isError || result.error
);
if (refreshFailed) {
toast.error('Failed to refresh Codex snapshot. Raw edits were kept.');
return;
}
setRawDraftText(null);
} catch (error) {
toast.error((error as Error).message || 'Failed to refresh Codex snapshot.');
}
};
const handleSaveRawConfig = async () => {
if (!rawEditorValidation.valid) {
toast.error('Fix TOML before saving.');
return;
}
try {
await saveRawConfigAsync({
rawText: rawEditorText,
expectedMtime: rawConfig?.exists ? rawConfig.mtime : undefined,
});
setRawDraftText(null);
toast.success('Saved Codex config.toml.');
await refetchDiagnostics();
} catch (error) {
if (isApiConflictError(error)) {
toast.error('config.toml changed externally. Refresh and retry.');
} else {
toast.error((error as Error).message || 'Failed to save Codex config.toml.');
}
}
};
const runConfigPatch = async (
patch: Parameters<typeof patchConfigAsync>[0],
successMessage: string
) => {
try {
await patchConfigAsync({
...patch,
expectedMtime: rawConfig?.exists ? rawConfig.mtime : undefined,
});
setRawDraftText(null);
toast.success(successMessage);
} catch (error) {
if (isApiConflictError(error)) {
toast.error('config.toml changed externally. Refresh and retry.');
} else {
toast.error((error as Error).message || 'Failed to update Codex config.');
}
}
};
const tabContentClassName = 'mt-0 h-full border-0 p-0 data-[state=inactive]:hidden';
const renderSidebar = () => {
if (diagnosticsLoading) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Loading Codex diagnostics...
</div>
);
}
if (diagnosticsError || !diagnostics) {
return (
<div className="flex h-full items-center justify-center px-6 text-center text-destructive">
Failed to load Codex diagnostics.
</div>
);
}
return (
<Tabs defaultValue="overview" className="flex h-full flex-col">
<div className="shrink-0 px-4 pt-4">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="controls">Control Center</TabsTrigger>
<TabsTrigger value="docs">Docs</TabsTrigger>
</TabsList>
</div>
<div className="flex-1 min-h-0 overflow-hidden px-4 pb-4 pt-3">
<TabsContent value="overview" className={tabContentClassName}>
<CodexOverviewTab diagnostics={diagnostics} />
</TabsContent>
<TabsContent value="controls" className={tabContentClassName}>
<CodexControlCenterTab
workspacePath={diagnostics.workspacePath}
activeProfile={diagnostics.config.activeProfile}
topLevelSettings={topLevelSettings}
projectTrustEntries={projectTrustEntries}
profileEntries={profileEntries}
modelProviderEntries={modelProviderEntries}
mcpServerEntries={mcpServerEntries}
featureCatalog={KNOWN_CODEX_FEATURES}
featureState={featureState}
disabled={structuredControlsDisabled}
disabledReason={controlsDisabledReason}
saving={isPatchingConfig}
onPatch={runConfigPatch}
/>
</TabsContent>
<TabsContent value="docs" className={tabContentClassName}>
<CodexDocsTab diagnostics={diagnostics} />
</TabsContent>
</div>
</Tabs>
);
};
return (
<div className="h-full min-h-0 overflow-hidden">
<PanelGroup direction="horizontal" className="h-full">
<Panel defaultSize={45} minSize={35}>
<div className="h-full border-r bg-muted/20">{renderSidebar()}</div>
</Panel>
<PanelResizeHandle className="group flex w-2 cursor-col-resize items-center justify-center bg-border transition-colors hover:bg-primary/20">
<GripVertical className="h-3 w-3 text-muted-foreground group-hover:text-primary" />
</PanelResizeHandle>
<Panel defaultSize={55} minSize={35}>
<RawConfigEditorPanel
title="Codex config.toml"
pathLabel={rawConfig?.path || diagnostics?.file.path || '$CODEX_HOME/config.toml'}
loading={rawConfigLoading}
parseWarning={
rawEditorValidation.valid ? rawConfig?.parseError : rawEditorValidation.error
}
readWarning={rawConfig?.readError}
value={rawEditorText}
dirty={rawConfigDirty}
readOnly={Boolean(rawConfig?.readError)}
saving={isSavingRawConfig}
saveDisabled={
!rawConfigDirty ||
isSavingRawConfig ||
rawConfigLoading ||
!rawEditorValidation.valid ||
Boolean(rawConfig?.readError)
}
onChange={(next) => {
setRawEditorDraftText(next);
}}
onSave={handleSaveRawConfig}
onRefresh={refreshAll}
onDiscard={() => setRawDraftText(null)}
language="toml"
loadingLabel="Loading config.toml..."
parseWarningLabel="TOML warning"
ownershipNotice={
<div className="rounded-md border border-amber-200 bg-amber-50/60 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/20 dark:text-amber-300">
<p className="font-medium">This file is upstream-owned by Codex CLI.</p>
<p>
CCS does not keep <code>~/.codex/config.toml</code> in sync for you.
</p>
<p>
CCS-backed Codex launches may apply transient <code>-c</code> overrides and
<code> CCS_CODEX_API_KEY</code>; those effective values may not appear here.
</p>
</div>
}
/>
</Panel>
</PanelGroup>
</div>
);
}
+2
View File
@@ -20,4 +20,6 @@ export { ClaudeExtensionPage } from './claude-extension';
export { UpdatesPage } from './updates';
export { CodexPage } from './codex';
export { DroidPage } from './droid';
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen, userEvent } from '@tests/setup/test-utils';
import { CodexTopLevelControlsCard } from '@/components/compatible-cli/codex-top-level-controls-card';
describe('CodexTopLevelControlsCard', () => {
it('submits only changed fields so untouched unsupported values are preserved upstream', async () => {
const onSave = vi.fn();
render(
<CodexTopLevelControlsCard
values={{
model: null,
modelReasoningEffort: null,
modelProvider: null,
approvalPolicy: null,
sandboxMode: null,
webSearch: null,
toolOutputTokenLimit: null,
personality: null,
}}
providerNames={[]}
onSave={onSave}
/>
);
const saveButton = screen.getByRole('button', { name: 'Save top-level settings' });
expect(saveButton).toBeDisabled();
await userEvent.type(screen.getByPlaceholderText('gpt-5.4'), 'gpt-5.4-mini');
expect(saveButton).toBeEnabled();
await userEvent.click(saveButton);
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledWith({ model: 'gpt-5.4-mini' });
});
});
@@ -55,4 +55,16 @@ describe('CodeEditor', () => {
expect(container.querySelector('[data-slot="code-editor-viewport"]')).not.toBeInTheDocument();
});
it('validates TOML payloads when language is toml', () => {
render(
<CodeEditor
value={'model = "gpt-5.4"\n[features]\nmulti_agent = true\n'}
onChange={vi.fn()}
language="toml"
/>
);
expect(screen.getByText('Valid TOML')).toBeInTheDocument();
});
});
+144
View File
@@ -0,0 +1,144 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';
import { AllProviders } from '../../setup/test-utils';
import { useCodex } from '@/hooks/use-codex';
function createJsonResponse(body: Record<string, unknown>, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
const diagnosticsResponse = {
binary: {
installed: true,
path: '/tmp/codex',
installDir: '/tmp',
source: 'PATH',
version: 'codex-cli 0.118.0-alpha.3',
overridePath: null,
supportsConfigOverrides: true,
},
file: {
label: 'Codex user config',
path: '$CODEX_HOME/config.toml',
resolvedPath: '/tmp/.codex/config.toml',
exists: true,
isSymlink: false,
isRegularFile: true,
sizeBytes: 64,
mtimeMs: 100,
parseError: null,
readError: null,
},
workspacePath: '/tmp/workspace',
config: {
model: 'gpt-5.3-codex',
modelReasoningEffort: null,
modelProvider: null,
activeProfile: null,
approvalPolicy: null,
sandboxMode: null,
webSearch: null,
toolOutputTokenLimit: null,
personality: null,
topLevelKeys: ['model'],
profileCount: 0,
profileNames: [],
modelProviderCount: 0,
modelProviders: [],
featureCount: 0,
enabledFeatures: [],
disabledFeatures: [],
trustedProjectCount: 0,
untrustedProjectCount: 0,
projectTrust: [],
mcpServerCount: 0,
mcpServers: [],
},
supportMatrix: [],
warnings: [],
docsReference: {
providerValues: [],
settingsHierarchy: [],
notes: [],
links: [],
providerDocs: [],
},
};
const initialRawConfigResponse = {
path: '$CODEX_HOME/config.toml',
resolvedPath: '/tmp/.codex/config.toml',
exists: true,
mtime: 100,
rawText: 'model = "gpt-5.3-codex"\n',
config: { model: 'gpt-5.3-codex' },
parseError: null,
readError: null,
};
const patchedRawConfigResponse = {
success: true,
path: '$CODEX_HOME/config.toml',
resolvedPath: '/tmp/.codex/config.toml',
exists: true,
mtime: 200,
rawText: 'model = "gpt-5.4"\n',
config: { model: 'gpt-5.4' },
parseError: null,
readError: null,
};
const wrapper = ({ children }: { children: ReactNode }) => <AllProviders>{children}</AllProviders>;
describe('useCodex', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('updates cached raw config immediately after a structured patch save', async () => {
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith('/api/codex/diagnostics')) {
return Promise.resolve(createJsonResponse(diagnosticsResponse));
}
if (url.endsWith('/api/codex/config/raw') && !init?.method) {
return Promise.resolve(createJsonResponse(initialRawConfigResponse));
}
if (url.endsWith('/api/codex/config/patch')) {
return Promise.resolve(createJsonResponse(patchedRawConfigResponse));
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
});
vi.stubGlobal('fetch', fetchMock);
const { result } = renderHook(() => useCodex(), { wrapper });
await waitFor(() => expect(result.current.rawConfig?.mtime).toBe(100));
await act(async () => {
await result.current.patchConfigAsync({
kind: 'top-level',
values: { model: 'gpt-5.4' },
expectedMtime: 100,
});
});
await waitFor(() => expect(result.current.rawConfig?.mtime).toBe(200));
expect(result.current.rawConfig?.rawText).toBe('model = "gpt-5.4"\n');
expect(result.current.rawConfig?.config?.model).toBe('gpt-5.4');
expect(
fetchMock.mock.calls.filter(([input]) => String(input).endsWith('/api/codex/config/raw'))
).toHaveLength(1);
});
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { CLI_SUPPORT_ENTRIES, SUPPORT_NOTICES } from '@/lib/support-updates-catalog';
describe('support-updates catalog codex routing', () => {
it('routes the Codex runtime notice to the Codex dashboard', () => {
const notice = SUPPORT_NOTICES.find((entry) => entry.id === 'codex-target-runtime-support');
expect(notice).toBeDefined();
expect(notice?.routes).toContainEqual({ label: 'Codex CLI', path: '/codex' });
expect(notice?.actions).toContainEqual(
expect.objectContaining({
id: 'open-codex-dashboard',
type: 'route',
path: '/codex',
})
);
});
it('routes the Codex target entry to the Codex dashboard', () => {
const entry = CLI_SUPPORT_ENTRIES.find((item) => item.id === 'codex-target');
expect(entry).toBeDefined();
expect(entry?.routes).toEqual([{ label: 'Codex CLI', path: '/codex' }]);
});
});
+230
View File
@@ -0,0 +1,230 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';
import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
const mocks = vi.hoisted(() => ({
useCodex: vi.fn(),
refetchDiagnostics: vi.fn(),
refetchRawConfig: vi.fn(),
saveRawConfigAsync: vi.fn(),
patchConfigAsync: vi.fn(),
}));
vi.mock('@/hooks/use-codex', () => ({
useCodex: mocks.useCodex,
}));
vi.mock('react-resizable-panels', () => ({
PanelGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Panel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PanelResizeHandle: () => <div data-testid="panel-resize-handle" />,
}));
vi.mock('@/components/shared/code-editor', () => ({
CodeEditor: ({
value,
onChange,
readonly,
}: {
value: string;
onChange: (next: string) => void;
readonly?: boolean;
}) => (
<textarea
aria-label="codex raw editor"
value={value}
readOnly={readonly}
onChange={(event) => onChange(event.target.value)}
/>
),
}));
vi.mock('@/components/compatible-cli/codex-control-center-tab', () => ({
CodexControlCenterTab: () => <div>Control Center</div>,
}));
vi.mock('@/components/compatible-cli/codex-docs-tab', () => ({
CodexDocsTab: () => <div>Docs</div>,
}));
vi.mock('@/components/compatible-cli/codex-overview-tab', () => ({
CodexOverviewTab: () => <div>Overview</div>,
}));
import { CodexPage } from '@/pages/codex';
const diagnostics = {
binary: {
installed: true,
path: '/tmp/codex',
installDir: '/tmp',
source: 'PATH',
version: 'codex-cli 0.118.0-alpha.3',
overridePath: null,
supportsConfigOverrides: true,
},
file: {
label: 'Codex user config',
path: '$CODEX_HOME/config.toml',
resolvedPath: '/tmp/.codex/config.toml',
exists: true,
isSymlink: false,
isRegularFile: true,
sizeBytes: 64,
mtimeMs: 100,
parseError: null,
readError: null,
},
workspacePath: '/tmp/workspace',
config: {
model: 'gpt-5.4',
modelReasoningEffort: null,
modelProvider: null,
activeProfile: null,
approvalPolicy: null,
sandboxMode: null,
webSearch: null,
toolOutputTokenLimit: null,
personality: null,
topLevelKeys: ['model'],
profileCount: 0,
profileNames: [],
modelProviderCount: 0,
modelProviders: [],
featureCount: 0,
enabledFeatures: [],
disabledFeatures: [],
trustedProjectCount: 0,
untrustedProjectCount: 0,
projectTrust: [],
mcpServerCount: 0,
mcpServers: [],
},
supportMatrix: [],
warnings: [],
docsReference: {
providerValues: [],
settingsHierarchy: [],
notes: [],
links: [],
providerDocs: [],
},
};
function buildUseCodexResult(overrides?: Partial<ReturnType<typeof mocks.useCodex>>) {
return {
diagnostics,
diagnosticsLoading: false,
diagnosticsError: null,
refetchDiagnostics: mocks.refetchDiagnostics,
rawConfig: {
path: '$CODEX_HOME/config.toml',
resolvedPath: '/tmp/.codex/config.toml',
exists: true,
mtime: 100,
rawText: 'model = "gpt-5.4"\n',
config: { model: 'gpt-5.4' },
parseError: null,
readError: null,
},
rawConfigLoading: false,
rawConfigError: null,
refetchRawConfig: mocks.refetchRawConfig,
saveRawConfigAsync: mocks.saveRawConfigAsync,
isSavingRawConfig: false,
patchConfigAsync: mocks.patchConfigAsync,
isPatchingConfig: false,
...overrides,
};
}
describe('CodexPage', () => {
beforeEach(() => {
mocks.refetchDiagnostics.mockClear();
mocks.refetchRawConfig.mockClear();
mocks.refetchDiagnostics.mockResolvedValue({ status: 'success', isError: false, error: null });
mocks.refetchRawConfig.mockResolvedValue({ status: 'success', isError: false, error: null });
mocks.saveRawConfigAsync.mockReset();
mocks.patchConfigAsync.mockReset();
});
it('discards local raw TOML edits when the user refreshes the page snapshot successfully', async () => {
mocks.useCodex.mockReturnValue(buildUseCodexResult());
render(<CodexPage />);
const editor = screen.getByLabelText('codex raw editor');
await userEvent.clear(editor);
await userEvent.type(editor, 'model = "gpt-5.4-mini"');
expect(editor).toHaveValue('model = "gpt-5.4-mini"');
await userEvent.click(screen.getByLabelText('Refresh raw config'));
await waitFor(() => expect(mocks.refetchDiagnostics).toHaveBeenCalledTimes(1));
await waitFor(() => expect(mocks.refetchRawConfig).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(screen.getByLabelText('codex raw editor')).toHaveValue('model = "gpt-5.4"\n')
);
});
it('keeps local raw TOML edits when refresh resolves with an error state', async () => {
mocks.refetchRawConfig.mockResolvedValueOnce({
status: 'error',
isError: true,
error: new Error('Failed to fetch Codex raw config'),
});
mocks.useCodex.mockReturnValue(buildUseCodexResult());
render(<CodexPage />);
const editor = screen.getByLabelText('codex raw editor');
await userEvent.clear(editor);
await userEvent.type(editor, 'model = "gpt-5.4-mini"');
await userEvent.click(screen.getByLabelText('Refresh raw config'));
await waitFor(() => expect(mocks.refetchDiagnostics).toHaveBeenCalledTimes(1));
await waitFor(() => expect(mocks.refetchRawConfig).toHaveBeenCalledTimes(1));
expect(screen.getByLabelText('codex raw editor')).toHaveValue('model = "gpt-5.4-mini"');
expect(screen.getByText('Unsaved')).toBeInTheDocument();
});
it('restores the last fetched snapshot when the user discards local raw TOML edits', async () => {
mocks.useCodex.mockReturnValue(buildUseCodexResult());
render(<CodexPage />);
const editor = screen.getByLabelText('codex raw editor');
await userEvent.clear(editor);
await userEvent.type(editor, 'model = "gpt-5.4-mini"');
const discardButton = screen.getByRole('button', { name: 'Discard' });
expect(discardButton).toBeEnabled();
await userEvent.click(discardButton);
expect(screen.getByLabelText('codex raw editor')).toHaveValue('model = "gpt-5.4"\n');
});
it('shows read errors and makes the raw editor read-only when the file cannot be edited safely', () => {
mocks.useCodex.mockReturnValue(
buildUseCodexResult({
rawConfig: {
path: '$CODEX_HOME/config.toml',
resolvedPath: '/tmp/.codex/config.toml',
exists: true,
mtime: 100,
rawText: '',
config: null,
parseError: null,
readError: 'Refusing symlink file for safety.',
},
})
);
render(<CodexPage />);
expect(screen.getByText(/Read-only: Refusing symlink file for safety\./)).toBeInTheDocument();
expect(screen.getByLabelText('codex raw editor')).toHaveAttribute('readonly');
});
});
@@ -17,6 +17,7 @@ const layoutManagedRouteFiles = [
'src/pages/copilot.tsx',
'src/pages/cursor.tsx',
'src/pages/claude-extension.tsx',
'src/pages/codex.tsx',
'src/pages/droid.tsx',
'src/pages/accounts.tsx',
'src/pages/settings/index.tsx',
@@ -26,13 +27,27 @@ const layoutManagedRouteFiles = [
const forbiddenViewportHeightPattern = /\b(?:h-screen|min-h-screen)\b|calc\(100(?:d|l|s)?vh/i;
function readSource(relativePath: string): string {
return readFileSync(path.join(projectRoot, relativePath), 'utf8');
}
describe('dashboard route height contract', () => {
it.each(layoutManagedRouteFiles)(
'%s relies on the shared layout for viewport height',
(relativePath) => {
const source = readFileSync(path.join(projectRoot, relativePath), 'utf8');
const source = readSource(relativePath);
expect(source).not.toMatch(forbiddenViewportHeightPattern);
}
);
it('keeps the Codex dashboard registered in router and sidebar navigation', () => {
const appSource = readSource('src/App.tsx');
const sidebarSource = readSource('src/components/layout/app-sidebar.tsx');
expect(appSource).toContain('path="/codex"');
expect(appSource).toContain('<CodexPage />');
expect(sidebarSource).toContain("path: '/codex'");
expect(sidebarSource).toContain("label: 'Codex CLI'");
});
});