Merge pull request #854 from kaitranntt/dev

feat(release): promote dev to main
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-03-29 21:54:18 -04:00
commit 5dc66a5843
95 files changed
+9460 -240

No files matched your search

+94 -3
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
@@ -196,22 +197,32 @@ ccs api import ./glm.ccs-profile.json # Import exported profile bundle
### Runtime Aliases (built-in bins / `argv[0]` pattern)
Built-in Droid runtime aliases are installed with the package:
Built-in Droid and native Codex runtime aliases are installed with the package:
```bash
ccs-droid glm # explicit alias
ccsd glm # legacy shortcut
ccs-codex # explicit native Codex alias
ccsx # short native Codex alias
```
CCS also ships an opinionated Codex provider shortcut:
```bash
ccsxp # same as: ccs codex --target codex
```
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 +250,86 @@ 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 native Codex alias
ccsxp # built-in CCS Codex provider on native Codex
ccs codex --target codex # explicit equivalent of ccsxp
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.
#### CLIProxy-backed native Codex
There are two supported ways to use CLIProxy with native Codex:
1. `ccsxp` or `ccs codex --target codex`
Uses the built-in CCS Codex provider route on native Codex. This path relies on transient
CCS-managed `-c` overrides and does **not** require changing your saved `model_provider`.
2. Plain `codex` or a personal alias like `cxp`
Save CLIProxy as the native default provider in `~/.codex/config.toml` (or
`$CODEX_HOME/config.toml`), then export `CLIPROXY_API_KEY` in your shell.
```toml
model_provider = "cliproxy"
[model_providers.cliproxy]
base_url = "http://127.0.0.1:8317/api/provider/codex"
env_key = "CLIPROXY_API_KEY"
wire_api = "responses"
```
The top-level `model_provider = "cliproxy"` line is required. Defining only
`[model_providers.cliproxy]` is not enough for plain `codex` to pick it by default.
```bash
export CLIPROXY_API_KEY="ccs-internal-managed"
ccsxp "your prompt" # CCS shortcut for the built-in provider route
codex "your prompt" # native Codex using your saved cliproxy default
```
Dashboard parity: `ccs config` -> `Compatible` -> `Codex CLI`
- `Overview`: explains `ccsx` vs `ccsxp`
- `Top-level settings`: set **Default provider** to `cliproxy`
- `Model providers`: save `cliproxy` with `env_key = "CLIPROXY_API_KEY"`
### 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=="],
+20 -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,14 +198,21 @@ Resolves which adapter to use via `resolveTargetType()`:
```
1. --target <name> flag (highest priority)
↓
2. Profile config: profileConfig.target field
2. explicit runtime entrypoint (`CCS_INTERNAL_ENTRY_TARGET`):
- ccs-droid / ccsd → droid
- ccs-codex / ccsx → codex
- ccsxp → codex (provider shortcut)
↓
3. argv[0] detection (runtime alias pattern):
3. argv[0] detection (runtime alias pattern / custom alias map):
- ccs-droid → droid
- ccsd → droid
- ccs-codex → codex
- ccsx → codex
- ccs → default
↓
4. Fallback: 'claude' (lowest priority)
4. Profile config: profileConfig.target field
↓
5. Fallback: 'claude' (lowest priority)
```
### Registration Pattern
@@ -216,6 +223,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 +724,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
+36 -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,10 @@ 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 # Forces droid target for ccs-droid / ccsd package bins
│ ├── codex-runtime.ts # Forces codex target for ccs-codex / ccsx package bins
│ └── ccsxp-runtime.ts # Forces built-in codex profile + codex target for ccsxp
├── types/ # TypeScript type definitions
│ ├── index.ts # Barrel export (aggregates all types)
│ ├── cli.ts # CLI types (ParsedArgs, ExitCode)
@@ -66,9 +70,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 > runtime entrypoint / 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 +211,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 +246,22 @@ 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
- Dedicated runtime entrypoints: `ccs-codex` and `ccsx` resolve through `src/bin/codex-runtime.ts`, while `ccsxp` resolves through `src/bin/ccsxp-runtime.ts`; all three set `CCS_INTERNAL_ENTRY_TARGET=codex` before delegating to `src/targets/target-resolver.ts`.
- Provider shortcut behavior: `ccsxp` also strips user-supplied `--target` overrides and rewrites argv to `ccs codex --target codex ...`, so it always lands on the built-in Codex-via-CLIProxy route.
- `argv[0]` alias mapping still exists in `src/targets/target-resolver.ts` for same-binary/custom alias scenarios, but the built-in npm bins above do not depend on that map at runtime.
- 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 +274,17 @@ 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)
- Explicit runtime entrypoint via `CCS_INTERNAL_ENTRY_TARGET` (used by `src/bin/droid-runtime.ts`, `src/bin/codex-runtime.ts`, and `src/bin/ccsxp-runtime.ts`)
- `argv[0]` detection for custom/same-binary runtime aliases
- Per-profile `target` field (from config.yaml)
- `argv[0]` detection (runtime alias pattern: `ccs-droid` / `ccsd` → droid)
- Default: `claude`
3. **Implementations:**
@@ -609,7 +633,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.
+15 -7
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 > runtime entrypoint / 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,20 @@ Spawn Target Process
- Spawns: `droid -m custom:ccs-<profile> <args>`
- Model config includes baseUrl, apiKey, provider
**Runtime alias pattern (built-in bins / argv[0]-style):**
- **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 entrypoints (built-in bins) and argv[0]-style aliases:**
```
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)
ccsxp → Target: codex (provider shortcut; rewrites argv to `ccs codex --target codex`)
```
For details on the adapter architecture, see [Target Adapters](./target-adapters.md).
+175 -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,18 +83,27 @@ 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. Explicit runtime entrypoint (`CCS_INTERNAL_ENTRY_TARGET`) — dedicated bin shims
└─ ccs-droid / ccsd → droid
└─ ccs-codex / ccsx → codex
└─ ccsxp → codex, then rewrites argv to `ccs codex --target codex ...`
3. argv[0] detection (runtime alias pattern) — binary name mapping for same-binary/custom aliases
└─ ccs-droid (explicit alias) → droid
└─ ccsd (legacy shortcut) → droid
└─ ccs-codex (explicit alias) → codex
└─ ccsx (short alias) → codex
└─ ccs (regular command) → default
4. 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
5. Fallback: 'claude' — lowest priority
```
### Implementation
@@ -103,18 +122,25 @@ export function resolveTargetType(
return parsed.targetOverride;
}
// 2. Check profile config
if (profileConfig?.target) {
return profileConfig.target;
// 2. Check explicit runtime entrypoint shim
const entrypointTarget = resolveEntrypointTarget();
if (entrypointTarget) {
return entrypointTarget;
}
// 3. Check argv[0] (binary name)
// 3. Check argv[0] (binary name / custom alias map)
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];
}
// 4. Default to claude
// 4. Check profile config
if (profileConfig?.target) {
// Persisted targets intentionally exclude runtime-only codex.
return profileConfig.target;
}
// 5. Default to claude
return 'claude';
}
```
@@ -380,6 +406,130 @@ 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 Entrypoints and argv[0] Fallback
```bash
# Built-in package bin entrypoints
ccs-codex
→ dist/bin/codex-runtime.js
→ CCS_INTERNAL_ENTRY_TARGET=codex
ccsx
→ dist/bin/codex-runtime.js
→ CCS_INTERNAL_ENTRY_TARGET=codex
ccsxp
→ dist/bin/ccsxp-runtime.js
→ CCS_INTERNAL_ENTRY_TARGET=codex
→ injects built-in codex profile shortcut
```
If a user launches CCS through a custom shim instead of the built-in package bins, target
resolution falls back to `argv[0]` aliases from `CCS_TARGET_ALIASES` or legacy
`CCS_CODEX_ALIASES`:
```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 +565,7 @@ At startup, adapters self-register:
registerTarget(new ClaudeAdapter());
registerTarget(new DroidAdapter());
registerTarget(new CodexAdapter());
```
---
@@ -522,7 +673,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 +772,14 @@ ccs --target claude help
# Test Droid adapter (if installed)
ccs --target droid help
# Test Codex adapter (if installed)
ccs --target codex
ccs-codex
ccsxp
# Test argv[0] detection
ccs-droid help
ccsx
```
---
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.62.2",
"version": "7.62.2-dev.4",
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
"keywords": [
"cli",
@@ -28,7 +28,10 @@
"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",
"ccsxp": "dist/bin/ccsxp-runtime.js"
},
"files": [
"dist/",
@@ -105,6 +108,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';
+19
View File
@@ -0,0 +1,19 @@
const { stripTargetFlag } = require('../targets/target-resolver');
const { fail } = require('../utils/ui');
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
// ccsxp is an opinionated shortcut for the built-in Codex-on-Codex route.
// Strip user-supplied target overrides before forcing the shortcut target.
const forwardedArgs = (() => {
try {
return stripTargetFlag(process.argv.slice(2));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(fail(message));
process.exit(1);
}
})();
process.argv.splice(2, process.argv.length - 2, 'codex', '--target', 'codex', ...forwardedArgs);
require('../ccs');
+2
View File
@@ -0,0 +1,2 @@
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
require('../ccs');
+248 -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,16 @@ 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']);
const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']);
/**
* Smart profile detection
*/
@@ -97,6 +110,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 {
@@ -179,10 +240,68 @@ function resolveNativeClaudeLaunchArgs(
return buildOfficialChannelsArgs(args, plan.appliedChannels, plan.wantsPermissionBypass);
}
function shouldPassthroughNativeCodexFlagCommand(args: string[]): boolean {
return getNativeCodexPassthroughArgs(args) !== null;
}
function getNativeCodexPassthroughArgs(args: string[]): string[] | null {
const targetArgs = stripTargetFlag(args);
if (resolveTargetType(args) !== 'codex' || targetArgs.length === 0) {
return null;
}
const firstArg = targetArgs[0] || '';
if (CODEX_NATIVE_PASSTHROUGH_FLAGS.has(firstArg)) {
return targetArgs;
}
const secondArg = targetArgs[1] || '';
if (firstArg === 'codex' && CODEX_NATIVE_PASSTHROUGH_FLAGS.has(secondArg)) {
return targetArgs.slice(1);
}
return null;
}
function execNativeCodexFlagCommand(args: string[]): void {
const adapter = getTarget('codex');
if (!adapter) {
console.error(fail('Target adapter not found for "codex"'));
process.exit(1);
}
const binaryInfo = adapter.detectBinary();
if (!binaryInfo) {
console.error(fail('Codex CLI not found.'));
console.error(info('Install a recent @openai/codex build, then retry.'));
process.exit(1);
}
const targetArgs = getNativeCodexPassthroughArgs(args);
if (!targetArgs) {
console.error(fail('Native Codex passthrough args could not be resolved.'));
process.exit(1);
}
const creds: TargetCredentials = {
profile: 'default',
baseUrl: '',
apiKey: '',
};
const builtArgs = adapter.buildArgs('default', targetArgs, {
creds,
profileType: 'default',
binaryInfo,
});
const targetEnv = adapter.buildEnv(creds, 'default');
adapter.exec(builtArgs, targetEnv, { binaryInfo });
}
async function main(): Promise<void> {
// Register target adapters
registerTarget(new ClaudeAdapter());
registerTarget(new DroidAdapter());
registerTarget(new CodexAdapter());
const args = process.argv.slice(2);
@@ -254,6 +373,11 @@ async function main(): Promise<void> {
}
}
if (shouldPassthroughNativeCodexFlagCommand(args)) {
execNativeCodexFlagCommand(args);
return;
}
const firstArg = args[0];
// Trigger update check early for ALL commands except version/help/update
@@ -372,6 +496,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 +508,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 +581,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 +601,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 +627,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 +797,7 @@ async function main(): Promise<void> {
baseUrl: envVars['ANTHROPIC_BASE_URL'],
model: envVars['ANTHROPIC_MODEL'],
}),
reasoningOverride: droidReasoningOverride,
reasoningOverride: runtimeReasoningOverride,
envVars,
};
@@ -643,7 +815,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 +894,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 +1039,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 +1140,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 +1152,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 {
+227 -7
View File
@@ -36,8 +36,10 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs';
* v12: Removed denylisted Antigravity Claude 4.5 aliases
* v13: Removed aggressive Gemini alias expansion to reduce model list noise in Control Panel
* v14: Added Gemini 3.1 Flash Antigravity aliases for upcoming rollout compatibility
* v15: Prune stale generated Antigravity Gemini preview aliases during regeneration
* v16: Narrow stale Gemini alias cleanup to broad multi-version guessed ranges
*/
export const CLIPROXY_CONFIG_VERSION = 14;
export const CLIPROXY_CONFIG_VERSION = 16;
interface OAuthModelAliasEntry {
name: string;
@@ -45,6 +47,11 @@ interface OAuthModelAliasEntry {
fork?: boolean;
}
interface PreservedAntigravityAliasesResult {
yaml: string;
prunedLegacyAliasCount: number;
}
const DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX = 'gemini-claude-';
const UPSTREAM_CLAUDE_ALIAS_PREFIX = 'claude-';
const DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX = /^claude-sonnet-4(?:[.-])6-thinking$/i;
@@ -69,6 +76,17 @@ const DEFAULT_ANTIGRAVITY_ALIASES: OAuthModelAliasEntry[] = [
{ name: 'claude-opus-4-6-thinking', alias: 'claude-opus-4-6-thinking', fork: true },
];
const BUILT_IN_GEMINI_ALIAS_NAMES = new Set(
DEFAULT_ANTIGRAVITY_ALIASES.filter((entry) => entry.alias.startsWith('gemini-3')).map(
(entry) => entry.name
)
);
const MIN_STALE_GUESSED_GEMINI_MINOR_VERSIONS = 2;
const MIN_STALE_HIGH_ONLY_GEMINI_MINOR_VERSIONS = 3;
const MIN_STALE_GUESSED_GEMINI_AVERAGE_VARIANTS_PER_MINOR = 2;
const LEGACY_GEMINI_STALE_ALIAS_MIGRATION_VERSION = 16;
const MAX_LEGACY_MANUAL_GEMINI_MINOR_VERSION = 2;
/**
* Get provider configuration
* Model mappings are loaded from config/base-{provider}.settings.json
@@ -148,6 +166,10 @@ function addAliasEntry(
entries.push(normalized);
}
function buildAntigravityAliasKey(entry: Pick<OAuthModelAliasEntry, 'name' | 'alias'>): string {
return `${sanitizeYamlScalar(entry.name)}\u0000${normalizeAntigravityAlias(entry.alias)}`;
}
function parseExistingAntigravityAliases(existingAliases: string): OAuthModelAliasEntry[] {
const entries: OAuthModelAliasEntry[] = [];
const lines = existingAliases.replace(/\r\n/g, '\n').split('\n');
@@ -204,6 +226,16 @@ function parseExistingAntigravityAliases(existingAliases: string): OAuthModelAli
return entries;
}
function getConfigVersionFromContent(content: string): number | null {
const versionMatch = content.match(/CCS v(\d+)/);
if (!versionMatch) {
return null;
}
const parsedVersion = Number.parseInt(versionMatch[1], 10);
return Number.isNaN(parsedVersion) ? null : parsedVersion;
}
function toDottedGeminiVersionAlias(alias: string): string | null {
const match = alias.match(/^(gemini-\d+)-(\d+)(-.+)$/);
if (!match) return null;
@@ -267,6 +299,184 @@ function getCompatibilityAliases(entries: OAuthModelAliasEntry[]): OAuthModelAli
return compatibilityAliases;
}
function buildGeneratedAntigravityAliases(): OAuthModelAliasEntry[] {
const generatedAliases: OAuthModelAliasEntry[] = [];
const generatedAliasIndex = new Map<string, number>();
for (const alias of DEFAULT_ANTIGRAVITY_ALIASES) {
addAliasEntry(generatedAliases, generatedAliasIndex, alias);
}
for (const alias of getCompatibilityAliases(generatedAliases)) {
addAliasEntry(generatedAliases, generatedAliasIndex, alias);
}
return generatedAliases;
}
const GENERATED_ANTIGRAVITY_ALIASES = buildGeneratedAntigravityAliases();
const GENERATED_ANTIGRAVITY_ALIAS_MAP = new Map(
GENERATED_ANTIGRAVITY_ALIASES.map((entry) => [buildAntigravityAliasKey(entry), entry] as const)
);
function serializeAntigravityAliases(entries: OAuthModelAliasEntry[]): string {
if (entries.length === 0) {
return '';
}
const lines = [' antigravity:'];
for (const entry of entries) {
lines.push(` - name: ${entry.name}`);
lines.push(` alias: ${entry.alias}`);
if (entry.fork) {
lines.push(' fork: true');
}
}
return lines.join('\n');
}
function getLegacyGeneratedGeminiPreviewInfo(
entry: OAuthModelAliasEntry
): { nameKey: string; minorVersion: string } | null {
if (!BUILT_IN_GEMINI_ALIAS_NAMES.has(entry.name)) {
return null;
}
const normalizedAlias = normalizeAntigravityAlias(entry.alias).toLowerCase();
if (!normalizedAlias.startsWith('gemini-3') || !normalizedAlias.includes('-preview')) {
return null;
}
const aliasWithoutCustomtools = normalizedAlias.endsWith('-customtools')
? normalizedAlias.slice(0, -'-customtools'.length)
: normalizedAlias;
const canonicalAlias =
toDottedGeminiVersionAlias(aliasWithoutCustomtools) ?? aliasWithoutCustomtools;
const minorVersionMatch = canonicalAlias.match(/^gemini-3\.(\d+)(-.+-preview)$/);
if (!minorVersionMatch) {
return null;
}
return {
nameKey: sanitizeYamlScalar(entry.name),
minorVersion: minorVersionMatch[1],
};
}
function buildLegacyGeneratedGeminiAliasPruneSet(
parsedEntries: OAuthModelAliasEntry[]
): Set<string> {
const aliasKeysByNameAndMinor = new Map<string, Map<string, string[]>>();
for (const entry of parsedEntries) {
if (entry.fork) {
continue;
}
const aliasKey = buildAntigravityAliasKey(entry);
if (GENERATED_ANTIGRAVITY_ALIAS_MAP.has(aliasKey)) {
continue;
}
const previewInfo = getLegacyGeneratedGeminiPreviewInfo(entry);
if (!previewInfo) {
continue;
}
const aliasKeysByMinor = aliasKeysByNameAndMinor.get(previewInfo.nameKey) ?? new Map();
const aliasKeys = aliasKeysByMinor.get(previewInfo.minorVersion) ?? [];
aliasKeys.push(aliasKey);
aliasKeysByMinor.set(previewInfo.minorVersion, aliasKeys);
aliasKeysByNameAndMinor.set(previewInfo.nameKey, aliasKeysByMinor);
}
const staleAliasKeys = new Set<string>();
for (const aliasKeysByMinor of aliasKeysByNameAndMinor.values()) {
const sortedMinorVersions = [...aliasKeysByMinor.keys()].sort((left, right) => {
return Number(left) - Number(right);
});
const totalAliasCount = sortedMinorVersions.reduce((total, minorVersion) => {
return total + (aliasKeysByMinor.get(minorVersion)?.length ?? 0);
}, 0);
const preservedMinorVersion =
Number(sortedMinorVersions[0]) <= MAX_LEGACY_MANUAL_GEMINI_MINOR_VERSION
? sortedMinorVersions[0]
: null;
const minimumMinorVersionsToPrune = preservedMinorVersion
? MIN_STALE_GUESSED_GEMINI_MINOR_VERSIONS
: MIN_STALE_HIGH_ONLY_GEMINI_MINOR_VERSIONS;
if (sortedMinorVersions.length < minimumMinorVersionsToPrune) {
continue;
}
if (
totalAliasCount <
sortedMinorVersions.length * MIN_STALE_GUESSED_GEMINI_AVERAGE_VARIANTS_PER_MINOR
) {
continue;
}
for (const minorVersion of sortedMinorVersions) {
if (minorVersion === preservedMinorVersion) {
continue;
}
const aliasKeys = aliasKeysByMinor.get(minorVersion) ?? [];
for (const aliasKey of aliasKeys) {
staleAliasKeys.add(aliasKey);
}
}
}
return staleAliasKeys;
}
function extractPreservedAntigravityAliases(
existingAliases: string,
options?: { enableLegacyGeminiStaleCleanup?: boolean }
): PreservedAntigravityAliasesResult {
if (!existingAliases.trim()) {
return { yaml: '', prunedLegacyAliasCount: 0 };
}
const parsedEntries = parseExistingAntigravityAliases(existingAliases);
const staleAliasKeys = options?.enableLegacyGeminiStaleCleanup
? buildLegacyGeneratedGeminiAliasPruneSet(parsedEntries)
: new Set<string>();
const preservedEntries = parsedEntries.filter((entry) => {
const aliasKey = buildAntigravityAliasKey(entry);
const generatedEntry = GENERATED_ANTIGRAVITY_ALIAS_MAP.get(aliasKey);
if (generatedEntry) {
return Boolean(entry.fork) && !generatedEntry.fork;
}
if (entry.fork) {
return true;
}
return !staleAliasKeys.has(aliasKey);
});
return {
yaml: serializeAntigravityAliases(preservedEntries),
prunedLegacyAliasCount: parsedEntries.filter((entry) =>
staleAliasKeys.has(buildAntigravityAliasKey(entry))
).length,
};
}
function writeLegacyGeminiAliasCleanupBackup(configPath: string, existingContent: string): void {
const backupPath = `${configPath}.pre-v16-gemini-alias-cleanup.bak`;
if (fs.existsSync(backupPath)) {
return;
}
fs.writeFileSync(backupPath, existingContent, { mode: 0o600 });
}
/**
* Generate oauth-model-alias YAML section.
* Merges default Antigravity aliases with any user-added custom aliases.
@@ -543,8 +753,20 @@ export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string {
// Preserve claude-api-key section (managed via dashboard/API)
claudeApiKeySection = extractYamlSection(content, 'claude-api-key');
// Preserve existing oauth-model-alias user customizations
existingAliases = extractYamlSection(content, 'oauth-model-alias');
// Preserve user customizations while pruning legacy generated Gemini preview noise.
const existingConfigVersion = getConfigVersionFromContent(content);
const preservedAliases = extractPreservedAntigravityAliases(
extractYamlSection(content, 'oauth-model-alias'),
{
enableLegacyGeminiStaleCleanup:
existingConfigVersion === null ||
existingConfigVersion < LEGACY_GEMINI_STALE_ALIAS_MIGRATION_VERSION,
}
);
existingAliases = preservedAliases.yaml;
if (preservedAliases.prunedLegacyAliasCount > 0) {
writeLegacyGeminiAliasCleanupBackup(configPath, content);
}
} catch {
// Use defaults if reading fails
}
@@ -583,12 +805,10 @@ export function configNeedsRegeneration(): boolean {
const content = fs.readFileSync(configPath, 'utf-8');
// Check for version marker
const versionMatch = content.match(/CCS v(\d+)/);
if (!versionMatch) {
const configVersion = getConfigVersionFromContent(content);
if (configVersion === null) {
return true; // No version marker = old config
}
const configVersion = parseInt(versionMatch[1], 10);
return configVersion < CLIPROXY_CONFIG_VERSION;
} catch {
return true; // Error reading = regenerate
@@ -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;
}
+1 -3
View File
@@ -177,9 +177,7 @@ function showHelp(): void {
console.log(
` $ ${color('ccs config channels --clear-token discord', 'command')} ${dim('# Clear one token')}`
);
console.log(
` ${dim('Official Channels only work on native Claude default/account sessions, not on ccs glm or other API/OAuth/Droid targets.')}`
);
console.log(` ${dim(getOfficialChannelsSupportMessage())}`);
console.log('');
}
+2 -2
View File
@@ -1,4 +1,5 @@
import { extractOption, hasAnyFlag, scanCommandArgs } from './arg-extractor';
import { getOfficialChannelsSupportMessage } from '../channels/official-channels-runtime';
const CONFIG_COMMAND_FLAGS = ['--help', '-h', '--port', '-p', '--host', '-H', '--dev'] as const;
@@ -92,8 +93,7 @@ export function showConfigCommandHelp(): void {
console.log(' --set-token <s> Save channel token (telegram=<t> or discord=<t>)');
console.log(' --clear-token Remove all saved channel tokens');
console.log(' --clear-token <c> Remove one saved channel token');
console.log(' Works only for native Claude default/account sessions');
console.log(' Not for ccs glm, other API/OAuth profiles, or Droid targets');
console.log(` ${getOfficialChannelsSupportMessage()}`);
console.log('');
console.log(' auth Manage dashboard authentication');
console.log(' auth setup Configure username and password');
+32 -5
View File
@@ -4,6 +4,7 @@ import { initUI, box, color, dim, sectionHeader, subheader } from '../utils/ui';
import { isUnifiedMode } from '../config/unified-config-loader';
import { getCcsDir, getCcsDirSource } from '../utils/config-manager';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import { getOfficialChannelsSupportMessage } from '../channels/official-channels-runtime';
type HelpWriter = (line: string) => void;
@@ -236,7 +237,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 +392,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 +406,9 @@ 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]'],
['ccsxp [args]', 'Shortcut for: ccs codex --target codex [args]'],
],
writeLine
);
@@ -416,6 +420,16 @@ 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'],
['ccsxp "your prompt"', 'Run built-in CLIProxy Codex on native Codex CLI'],
['ccs codex --target codex', 'Explicit form of ccsxp'],
[
'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'],
[
@@ -439,6 +453,20 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
writeLine
);
printSubSection(
'Codex + CLIProxy',
[
['ccsxp "your prompt"', 'Use the built-in CCS Codex provider shortcut on native Codex'],
['ccs config', 'Open Compatible -> Codex CLI for native Codex setup and diagnostics'],
[
'Default provider',
'Set to cliproxy if plain codex or a personal cxp alias should use CLIProxy',
],
['Model provider auth', 'Save cliproxy with env_key = "CLIPROXY_API_KEY"'],
],
writeLine
);
// Configuration
printConfigSection(
'Configuration',
@@ -512,7 +540,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>'],
@@ -581,8 +609,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['ccs config channels --clear-token [channel]', 'Remove one or all saved channel tokens'],
['', ''],
['', 'Fastest path: turn on the channel, save the token if needed, then run ccs.'],
['Note:', 'Runtime-only. Applies to native Claude default/account sessions only.'],
['', 'Not supported for ccs glm, other API/OAuth profiles, or Droid targets.'],
['Note:', getOfficialChannelsSupportMessage()],
['', 'Telegram/Discord tokens live in ~/.claude/channels/<channel>/.env.'],
['', 'Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work for that launch.'],
['', 'iMessage is macOS-only and requires local OS permissions instead of a bot token.'],
+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,
};
}
}
+257
View File
@@ -0,0 +1,257 @@
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, stripCodexSessionEnv } from '../utils/shell-executor';
import type {
TargetAdapter,
TargetBinaryInfo,
TargetCredentials,
TargetType,
} from './target-adapter';
import {
codexBinarySupportsConfigOverrides,
detectCodexCli,
getCodexBinaryInfo,
readCodexVersion,
} 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 hydrateCodexBinaryVersion(binaryInfo?: TargetBinaryInfo): TargetBinaryInfo | undefined {
if (!binaryInfo || binaryInfo.version || !binaryInfo.path) {
return binaryInfo;
}
return {
...binaryInfo,
version: readCodexVersion(binaryInfo.path),
};
}
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({ includeVersion: false, includeFeatures: false });
}
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(hydrateCodexBinaryVersion(options?.binaryInfo));
}
return [
...buildConfigOverrideArgs([
`model_reasoning_effort=${formatTomlString(reasoningOverride)}`,
]),
...userArgs,
];
}
return userArgs;
}
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
throw buildConfigOverrideSupportError(hydrateCodexBinaryVersion(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 = {
...stripCodexSessionEnv(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';
}
}
+149
View File
@@ -0,0 +1,149 @@
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;
}
}
export 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(options?: {
includeVersion?: boolean;
includeFeatures?: boolean;
}): 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: options?.includeVersion === false ? undefined : readCodexVersion(codexPath),
features: options?.includeFeatures === false ? undefined : detectCodexFeatures(codexPath),
};
}
export function codexBinarySupportsConfigOverrides(
binaryInfo: TargetBinaryInfo | null | undefined
): boolean {
if (!binaryInfo) {
return false;
}
if (binaryInfo.features) {
return binaryInfo.features.includes(CODEX_CONFIG_OVERRIDE_FEATURE);
}
return detectCodexFeatures(binaryInfo.path).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: ccsxp, ccs codex --target 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.');
}
+19
View File
@@ -42,6 +42,25 @@ export function stripClaudeCodeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return result;
}
/**
* Strip Codex session-scoped env vars before launching a nested Codex process.
*
* Keep real user config such as CODEX_HOME intact. Only remove the known
* session/runtime metadata exported by the current Codex host process.
*/
export function stripCodexSessionEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const sessionKeys = new Set(['CODEX_CI', 'CODEX_MANAGED_BY_BUN', 'CODEX_THREAD_ID']);
const result: NodeJS.ProcessEnv = {};
for (const key of Object.keys(env)) {
const upperKey = key.toUpperCase();
if (sessionKeys.has(upperKey)) {
continue;
}
result[key] = env[key];
}
return result;
}
/**
* Resolve CCS-managed environment overrides for Claude launch.
* - preferences.auto_update: false -> DISABLE_AUTOUPDATER=1
+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,894 @@
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']);
const BUILT_IN_CODEX_MODEL_PROVIDERS = new Set(['openai', 'oss']);
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));
}
function isBuiltInCodexModelProvider(name: string | null): boolean {
return name !== null && BUILT_IN_CODEX_MODEL_PROVIDERS.has(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 activeModelProvider = asString(config?.model_provider);
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 (activeModelProvider) {
const activeProvider = modelProviders.find((provider) => provider.name === activeModelProvider);
if (!activeProvider && !isBuiltInCodexModelProvider(activeModelProvider)) {
warnings.push(
`Active model provider "${activeModelProvider}" is selected but missing from [model_providers].`
);
} else if (activeProvider) {
if (!activeProvider.baseUrl) {
warnings.push(`Active model provider "${activeProvider.name}" is missing base_url.`);
}
if (!activeProvider.envKey && !activeProvider.requiresOpenaiAuth) {
warnings.push(`Active model provider "${activeProvider.name}" is missing env_key.`);
}
}
}
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: {
+31 -2
View File
@@ -8,7 +8,7 @@ try {
expandPath = require('../../dist/utils/helpers').expandPath;
} catch (e) {
// If helpers module doesn't exist or doesn't export expandPath, create a mock
expandPath = function(p) {
expandPath = function (p) {
if (!p || typeof p !== 'string') return p;
if (p.startsWith('~/')) {
return path.join(os.homedir(), p.slice(2));
@@ -76,7 +76,10 @@ describe('cross-platform', () => {
describe('platform-specific behavior', () => {
it('detects platform correctly', () => {
const platform = os.platform();
assert(['darwin', 'linux', 'win32'].includes(platform), 'Should be running on supported platform');
assert(
['darwin', 'linux', 'win32'].includes(platform),
'Should be running on supported platform'
);
});
it('handles path separators correctly', () => {
@@ -131,6 +134,9 @@ 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(packageJson.bin.ccsxp, 'bin field should specify ccsxp command');
assert.notStrictEqual(
packageJson.bin['ccs-droid'],
packageJson.bin.ccs,
@@ -141,10 +147,33 @@ 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.notStrictEqual(
packageJson.bin.ccsxp,
packageJson.bin.ccsx,
'ccsxp should use a dedicated shortcut 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(
fs.existsSync(path.join(__dirname, '..', '..', packageJson.bin.ccsxp)),
'dedicated ccsxp shortcut 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');
});
});
+55
View File
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
const wrapperPath = require.resolve('../../../src/bin/ccsxp-runtime.ts');
const ccsPath = require.resolve('../../../src/ccs.ts');
describe('ccsxp runtime wrapper', () => {
const originalArgv = process.argv;
const originalEntryTarget = process.env.CCS_INTERNAL_ENTRY_TARGET;
beforeEach(() => {
delete require.cache[wrapperPath];
delete require.cache[ccsPath];
});
afterEach(() => {
process.argv = originalArgv;
if (originalEntryTarget === undefined) {
delete process.env.CCS_INTERNAL_ENTRY_TARGET;
} else {
process.env.CCS_INTERNAL_ENTRY_TARGET = originalEntryTarget;
}
delete require.cache[wrapperPath];
delete require.cache[ccsPath];
});
it('prepends the built-in codex profile and target before loading CCS', () => {
process.argv = ['node', wrapperPath, 'fix failing tests'];
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
require(wrapperPath);
expect(process.env.CCS_INTERNAL_ENTRY_TARGET).toBe('codex');
expect(process.argv.slice(2)).toEqual(['codex', '--target', 'codex', 'fix failing tests']);
});
it('keeps flag-only invocations routed through the built-in codex profile shortcut', () => {
process.argv = ['node', wrapperPath, '--version'];
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
require(wrapperPath);
expect(process.argv.slice(2)).toEqual(['codex', '--target', 'codex', '--version']);
});
it('strips user-supplied target overrides before forcing the codex shortcut target', () => {
process.argv = ['node', wrapperPath, '--target', 'claude', '--version'];
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
require(wrapperPath);
expect(process.argv.slice(2)).toEqual(['codex', '--target', 'codex', '--version']);
});
});
@@ -753,6 +753,433 @@ oauth-model-alias:
);
});
it('drops stale multi-version Gemini preview ranges while preserving true custom aliases', () => {
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
const guessedMinors = ['3', '4', '5', '6'];
const buildGuessedRange = (name, family) =>
guessedMinors
.flatMap((minor) => [
` - name: ${name}`,
` alias: gemini-3.${minor}-${family}-preview`,
` - name: ${name}`,
` alias: gemini-3.${minor}-${family}-preview-customtools`,
` - name: ${name}`,
` alias: gemini-3-${minor}-${family}-preview`,
` - name: ${name}`,
` alias: gemini-3-${minor}-${family}-preview-customtools`,
])
.join('\n');
const initialConfig = `# CLIProxyAPI config generated by CCS v12
port: 8317
api-keys:
- "ccs-internal-managed"
auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
oauth-model-alias:
antigravity:
${buildGuessedRange('gemini-3-pro-high', 'pro')}
${buildGuessedRange('gemini-3-flash', 'flash')}
- name: custom-model
alias: keep-me
`;
fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig);
regenerateConfig();
const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
for (const minor of guessedMinors) {
assert(
!newConfig.includes(`alias: gemini-3.${minor}-pro-preview`),
`Should remove stale generated Pro aliases for 3.${minor}`
);
assert(
!newConfig.includes(`alias: gemini-3-${minor}-flash-preview-customtools`),
`Should remove stale generated Flash aliases for 3.${minor}`
);
}
assert(newConfig.includes('alias: keep-me'), 'Should preserve true custom aliases');
assert(
newConfig.includes('alias: gemini-3-pro-preview'),
'Should still keep current baseline Pro preview alias'
);
assert(
newConfig.includes('alias: gemini-3.1-flash-preview-customtools'),
'Should still keep current baseline Flash compatibility alias'
);
});
it('preserves sparse manual minor-version overrides when a stale guessed range is pruned', () => {
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
const guessedMinors = ['3', '4', '5', '6'];
const staleRange = guessedMinors
.flatMap((minor) => [
` - name: gemini-3-pro-high`,
` alias: gemini-3.${minor}-pro-preview`,
` - name: gemini-3-pro-high`,
` alias: gemini-3.${minor}-pro-preview-customtools`,
` - name: gemini-3-pro-high`,
` alias: gemini-3-${minor}-pro-preview`,
` - name: gemini-3-pro-high`,
` alias: gemini-3-${minor}-pro-preview-customtools`,
])
.join('\n');
const initialConfig = `# CLIProxyAPI config generated by CCS v12
port: 8317
api-keys:
- "ccs-internal-managed"
auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
oauth-model-alias:
antigravity:
- name: gemini-3-pro-high
alias: gemini-3.2-pro-preview
${staleRange}
`;
fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig);
regenerateConfig();
const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
assert(
newConfig.includes('alias: gemini-3.2-pro-preview'),
'Should preserve sparse manual minor-version override'
);
assert(
newConfig.includes('alias: gemini-3.2-pro-preview-customtools'),
'Should still expand compatibility aliases for preserved sparse manual override'
);
for (const minor of guessedMinors) {
assert(
!newConfig.includes(`alias: gemini-3.${minor}-pro-preview`),
`Should prune stale guessed aliases for 3.${minor}`
);
}
});
it('preserves a manual 3.2 compatibility cluster while pruning higher stale guessed minors', () => {
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
const guessedMinors = ['3', '4', '5', '6'];
const staleRange = guessedMinors
.flatMap((minor) => [
` - name: gemini-3-pro-high`,
` alias: gemini-3.${minor}-pro-preview`,
` - name: gemini-3-pro-high`,
` alias: gemini-3.${minor}-pro-preview-customtools`,
` - name: gemini-3-pro-high`,
` alias: gemini-3-${minor}-pro-preview`,
` - name: gemini-3-pro-high`,
` alias: gemini-3-${minor}-pro-preview-customtools`,
])
.join('\n');
const initialConfig = `# CLIProxyAPI config generated by CCS v12
port: 8317
api-keys:
- "ccs-internal-managed"
auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
oauth-model-alias:
antigravity:
- name: gemini-3-pro-high
alias: gemini-3.2-pro-preview
- name: gemini-3-pro-high
alias: gemini-3.2-pro-preview-customtools
- name: gemini-3-pro-high
alias: gemini-3-2-pro-preview
- name: gemini-3-pro-high
alias: gemini-3-2-pro-preview-customtools
${staleRange}
`;
fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig);
regenerateConfig();
const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
assert(
newConfig.includes('alias: gemini-3.2-pro-preview'),
'Should preserve the manual 3.2 compatibility cluster during legacy cleanup'
);
assert(
newConfig.includes('alias: gemini-3-2-pro-preview-customtools'),
'Should preserve hyphenated manual compatibility aliases for 3.2'
);
for (const minor of guessedMinors) {
assert(
!newConfig.includes(`alias: gemini-3.${minor}-pro-preview`),
`Should prune higher stale guessed aliases for 3.${minor}`
);
}
});
it('prunes partially retained broad guessed ranges during legacy cleanup', () => {
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
const guessedMinors = ['3', '4', '5'];
const partialRange = guessedMinors
.flatMap((minor) => [
` - name: gemini-3-pro-high`,
` alias: gemini-3.${minor}-pro-preview`,
` - name: gemini-3-pro-high`,
` alias: gemini-3.${minor}-pro-preview-customtools`,
])
.join('\n');
const initialConfig = `# CLIProxyAPI config generated by CCS v12
port: 8317
api-keys:
- "ccs-internal-managed"
auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
oauth-model-alias:
antigravity:
${partialRange}
`;
fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig);
regenerateConfig();
const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
for (const minor of guessedMinors) {
assert(
!newConfig.includes(`alias: gemini-3.${minor}-pro-preview`),
`Should prune partially retained guessed aliases for 3.${minor}`
);
}
});
it('preserves manual high-only multi-version clusters that are not broad guessed ranges', () => {
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
const initialConfig = `# CLIProxyAPI config generated by CCS v12
port: 8317
api-keys:
- "ccs-internal-managed"
auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
oauth-model-alias:
antigravity:
- name: gemini-3-pro-high
alias: gemini-3.3-pro-preview
- name: gemini-3-pro-high
alias: gemini-3.3-pro-preview-customtools
- name: gemini-3-pro-high
alias: gemini-3.4-pro-preview
- name: gemini-3-pro-high
alias: gemini-3.4-pro-preview-customtools
`;
fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig);
regenerateConfig();
const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
assert(
newConfig.includes('alias: gemini-3.3-pro-preview'),
'Should preserve manual 3.3 aliases when the cluster is not broad enough to infer stale fan-out'
);
assert(
newConfig.includes('alias: gemini-3.4-pro-preview-customtools'),
'Should preserve manual 3.4 compatibility aliases when provenance is ambiguous'
);
});
it('preserves single-minor high-version aliases when they are not part of a guessed range', () => {
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
const initialConfig = `# CLIProxyAPI config generated by CCS v12
port: 8317
api-keys:
- "ccs-internal-managed"
auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
oauth-model-alias:
antigravity:
- name: gemini-3-pro-high
alias: gemini-3.11-pro-preview
- name: gemini-3-pro-high
alias: gemini-3.11-pro-preview-customtools
`;
fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig);
regenerateConfig();
const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
assert(
newConfig.includes('alias: gemini-3.11-pro-preview'),
'Should preserve lone manual high-version aliases during legacy cleanup'
);
assert(
newConfig.includes('alias: gemini-3.11-pro-preview-customtools'),
'Should still expand compatibility aliases for preserved high-version aliases'
);
});
it('writes a one-time backup before pruning legacy Gemini aliases during migration', () => {
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
const configPath = path.join(cliproxyDir, 'config.yaml');
const guessedMinors = ['3', '4', '5'];
const staleRange = guessedMinors
.flatMap((minor) => [
` - name: gemini-3-pro-high`,
` alias: gemini-3.${minor}-pro-preview`,
` - name: gemini-3-pro-high`,
` alias: gemini-3.${minor}-pro-preview-customtools`,
])
.join('\n');
const initialConfig = `# CLIProxyAPI config generated by CCS v12
port: 8317
api-keys:
- "ccs-internal-managed"
auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
oauth-model-alias:
antigravity:
${staleRange}
`;
fs.writeFileSync(configPath, initialConfig);
regenerateConfig();
const backupPath = `${configPath}.pre-v16-gemini-alias-cleanup.bak`;
assert(fs.existsSync(backupPath), 'Should keep a backup of the legacy config before pruning');
assert(
fs.readFileSync(backupPath, 'utf-8').includes('gemini-3.5-pro-preview'),
'Should preserve the pruned legacy alias in the migration backup'
);
});
it('preserves manual Gemini preview overrides on built-in alias names', () => {
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
const initialConfig = `# CLIProxyAPI config generated by CCS v12
port: 8317
api-keys:
- "ccs-internal-managed"
auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
oauth-model-alias:
antigravity:
- name: gemini-3-pro-high
alias: gemini-3.2-pro-preview
- name: gemini-3-flash
alias: gemini-3.2-flash-preview
fork: true
`;
fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig);
regenerateConfig();
const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
assert(
newConfig.includes('alias: gemini-3.2-pro-preview'),
'Should preserve manual Pro preview overrides on built-in alias names'
);
assert(
newConfig.includes('alias: gemini-3.2-flash-preview'),
'Should preserve manual Flash preview overrides on built-in alias names'
);
assert(
newConfig.includes('alias: gemini-3.2-pro-preview-customtools'),
'Should still expand compatibility aliases for preserved manual Pro override'
);
assert(
newConfig.includes('alias: gemini-3.2-flash-preview-customtools'),
'Should still expand compatibility aliases for preserved manual Flash override'
);
const lines = newConfig.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('alias: gemini-3.2-flash-preview')) {
assert(
lines[i + 1] && lines[i + 1].trim() === 'fork: true',
'Should preserve fork: true on manual Flash preview override'
);
break;
}
}
});
it('preserves manual Gemini preview compatibility clusters on built-in alias names', () => {
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
const initialConfig = `# CLIProxyAPI config generated by CCS v12
port: 8317
api-keys:
- "ccs-internal-managed"
auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
oauth-model-alias:
antigravity:
- name: gemini-3-pro-high
alias: gemini-3.2-pro-preview
- name: gemini-3-pro-high
alias: gemini-3.2-pro-preview-customtools
- name: gemini-3-pro-high
alias: gemini-3-2-pro-preview
- name: gemini-3-pro-high
alias: gemini-3-2-pro-preview-customtools
`;
fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig);
regenerateConfig();
const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
assert(
newConfig.includes('alias: gemini-3.2-pro-preview'),
'Should preserve manual dotted Pro preview override'
);
assert(
newConfig.includes('alias: gemini-3.2-pro-preview-customtools'),
'Should preserve manual dotted Pro customtools override'
);
assert(
newConfig.includes('alias: gemini-3-2-pro-preview'),
'Should preserve manual hyphenated Pro preview override'
);
assert(
newConfig.includes('alias: gemini-3-2-pro-preview-customtools'),
'Should preserve manual hyphenated Pro customtools override'
);
});
it('does not re-prune multi-version manual clusters once the config is already migrated', () => {
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
const initialConfig = `# CLIProxyAPI config generated by CCS v16
port: 8317
api-keys:
- "ccs-internal-managed"
auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
oauth-model-alias:
antigravity:
- name: gemini-3-pro-high
alias: gemini-3.2-pro-preview
- name: gemini-3-pro-high
alias: gemini-3.3-pro-preview
- name: gemini-3-pro-high
alias: gemini-3.4-pro-preview
`;
fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig);
regenerateConfig();
const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
assert(
newConfig.includes('alias: gemini-3.2-pro-preview'),
'Should preserve manual aliases after legacy migration has already run'
);
assert(
newConfig.includes('alias: gemini-3.3-pro-preview'),
'Should not re-prune later manual version clusters on v16+ configs'
);
assert(
newConfig.includes('alias: gemini-3.4-pro-preview'),
'Should leave already-migrated multi-version manual aliases untouched'
);
});
it('preserves user-added aliases with fork during regeneration', () => {
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
@@ -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']);
@@ -81,7 +81,8 @@ describe('help command parity', () => {
'Fastest path: turn on the channel, save the token if needed, then run ccs.'
)
).toBe(true);
expect(rendered.includes('Not supported for ccs glm')).toBe(true);
expect(rendered.includes('ccs codex')).toBe(true);
expect(rendered.includes('ccs --target codex')).toBe(true);
expect(
rendered.includes('Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work')
).toBe(true);
@@ -99,6 +100,22 @@ 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('ccsxp [args]')).toBe(true);
expect(rendered.includes('ccs --target codex')).toBe(true);
expect(rendered.includes('ccsxp "your prompt"')).toBe(true);
expect(rendered.includes('ccs codex-api --target codex')).toBe(true);
expect(rendered.includes('Compatible -> Codex CLI')).toBe(true);
expect(rendered.includes('CLIPROXY_API_KEY')).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 +130,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');
});
});
+238
View File
@@ -0,0 +1,238 @@
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 originalCodeXHome = process.env.CODEX_HOME;
const originalCodeXCi = process.env.CODEX_CI;
const originalCodeXManagedByBun = process.env.CODEX_MANAGED_BY_BUN;
const originalCodeXThreadId = process.env.CODEX_THREAD_ID;
const originalAnthropicBaseUrl = process.env.ANTHROPIC_BASE_URL;
try {
process.env.CODEX_HOME = '/tmp/codex-home';
process.env.CODEX_CI = '1';
process.env.CODEX_MANAGED_BY_BUN = '1';
process.env.CODEX_THREAD_ID = 'thread-123';
process.env.ANTHROPIC_BASE_URL = 'https://stale-proxy.invalid';
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');
expect(settingsEnv.CODEX_HOME).toBe('/tmp/codex-home');
expect(settingsEnv.CODEX_CI).toBeUndefined();
expect(settingsEnv.CODEX_MANAGED_BY_BUN).toBeUndefined();
expect(settingsEnv.CODEX_THREAD_ID).toBeUndefined();
expect(settingsEnv.ANTHROPIC_BASE_URL).toBeUndefined();
const defaultEnv = adapter.buildEnv(
{
profile: 'default',
baseUrl: '',
apiKey: '',
},
'default'
);
expect(defaultEnv.CCS_CODEX_API_KEY).toBeUndefined();
expect(defaultEnv.CODEX_HOME).toBe('/tmp/codex-home');
expect(defaultEnv.CODEX_CI).toBeUndefined();
expect(defaultEnv.CODEX_MANAGED_BY_BUN).toBeUndefined();
expect(defaultEnv.CODEX_THREAD_ID).toBeUndefined();
expect(defaultEnv.ANTHROPIC_BASE_URL).toBeUndefined();
} finally {
if (originalCodeXHome === undefined) {
delete process.env.CODEX_HOME;
} else {
process.env.CODEX_HOME = originalCodeXHome;
}
if (originalCodeXCi === undefined) {
delete process.env.CODEX_CI;
} else {
process.env.CODEX_CI = originalCodeXCi;
}
if (originalCodeXManagedByBun === undefined) {
delete process.env.CODEX_MANAGED_BY_BUN;
} else {
process.env.CODEX_MANAGED_BY_BUN = originalCodeXManagedByBun;
}
if (originalCodeXThreadId === undefined) {
delete process.env.CODEX_THREAD_ID;
} else {
process.env.CODEX_THREAD_ID = originalCodeXThreadId;
}
if (originalAnthropicBaseUrl === undefined) {
delete process.env.ANTHROPIC_BASE_URL;
} else {
process.env.ANTHROPIC_BASE_URL = originalAnthropicBaseUrl;
}
}
});
});
+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,385 @@
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 runCodexAlias(args: string[], env: NodeJS.ProcessEnv): RunResult {
const codexEntry = path.join(process.cwd(), 'src', 'bin', 'codex-runtime.ts');
const result = spawnSync(process.execPath, [codexEntry, ...args], {
encoding: 'utf8',
env,
timeout: 20000,
});
return {
status: result.status,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
function runCcsxpAlias(args: string[], env: NodeJS.ProcessEnv): RunResult {
const ccsxpEntry = path.join(process.cwd(), 'src', 'bin', 'ccsxp-runtime.ts');
const result = spawnSync(process.execPath, [ccsxpEntry, ...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[]);
}
function readLoggedCodexEnv(logPath: string): Record<string, string | undefined>[] {
if (!fs.existsSync(logPath)) {
return [];
}
return fs
.readFileSync(logPath, 'utf8')
.trim()
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line) as Record<string, string | undefined>);
}
describe('codex runtime integration', () => {
let tmpHome: string;
let ccsDir: string;
let fakeCodexPath: string;
let codexArgsLogPath: string;
let codexEnvLogPath: 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');
codexEnvLogPath = path.join(tmpHome, 'codex-env.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;
const cliArgs = process.argv.slice(2);
if (out) {
fs.appendFileSync(out, JSON.stringify(cliArgs) + '\\n');
}
const envOut = process.env.CCS_TEST_CODEX_ENV_OUT;
if (envOut) {
fs.appendFileSync(
envOut,
JSON.stringify({
CODEX_HOME: process.env.CODEX_HOME,
CODEX_CI: process.env.CODEX_CI,
CODEX_MANAGED_BY_BUN: process.env.CODEX_MANAGED_BY_BUN,
CODEX_THREAD_ID: process.env.CODEX_THREAD_ID,
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
}) + '\\n'
);
}
if (cliArgs.includes('--version') || cliArgs.includes('-v')) {
process.stdout.write(process.env.CCS_TEST_CODEX_VERSION || 'codex-cli 0.118.0-alpha.3');
process.exit(0);
}
if (cliArgs.includes('--help') || cliArgs.includes('-h')) {
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('does not preflight native Codex default launches when no runtime overrides are needed', () => {
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).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).toEqual([['fix failing tests']]);
});
for (const versionFlag of ['--version', '-v']) {
it(`passes ccsx ${versionFlag} straight through to the native Codex binary`, () => {
if (process.platform === 'win32') return;
const result = runCodexAlias([versionFlag], {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test',
});
expect(result.status).toBe(0);
expect(result.stdout).toContain('codex-cli 9.9.9-test');
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([[versionFlag]]);
});
}
for (const helpFlag of ['--help', '-h']) {
it(`passes ccsx ${helpFlag} straight through to the native Codex binary`, () => {
if (process.platform === 'win32') return;
const result = runCodexAlias([helpFlag], {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
CCS_TEST_CODEX_HELP: 'codex native help text',
});
expect(result.status).toBe(0);
expect(result.stdout).toContain('codex native help text');
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([[helpFlag]]);
});
}
it('strips nested Codex session env from passthrough launches while keeping CODEX_HOME', () => {
if (process.platform === 'win32') return;
const result = runCodexAlias(['--version'], {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath,
CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test',
CODEX_HOME: '/tmp/codex-home',
CODEX_CI: '1',
CODEX_MANAGED_BY_BUN: '1',
CODEX_THREAD_ID: 'thread-123',
ANTHROPIC_BASE_URL: 'https://stale-proxy.invalid',
});
expect(result.status).toBe(0);
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([['--version']]);
expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([
{
CODEX_HOME: '/tmp/codex-home',
CODEX_CI: undefined,
CODEX_MANAGED_BY_BUN: undefined,
CODEX_THREAD_ID: undefined,
ANTHROPIC_BASE_URL: undefined,
},
]);
});
it('keeps ccsxp pinned to native Codex even when a user passes another --target override', () => {
if (process.platform === 'win32') return;
const result = runCcsxpAlias(['--target', 'claude', '--version'], {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test',
});
expect(result.status).toBe(0);
expect(result.stdout).toContain('codex-cli 9.9.9-test');
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([['--version']]);
});
it('fails with a clean CLI error when ccsxp receives a malformed --target flag', () => {
if (process.platform === 'win32') return;
const result = runCcsxpAlias(['--target'], {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
});
expect(result.status).toBe(1);
expect(result.stderr).toContain('[X] --target requires a value (claude, droid, codex)');
expect(result.stderr).not.toContain('at parseTargetFlags');
});
it('passes ccs codex --target codex --version through to the native Codex binary', () => {
if (process.platform === 'win32') return;
const result = runCcs(['codex', '--target', 'codex', '--version'], {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test',
});
expect(result.status).toBe(0);
expect(result.stdout).toContain('codex-cli 9.9.9-test');
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([['--version']]);
});
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_VERSION: 'codex-cli 9.9.9-test',
CCS_TEST_CODEX_HELP: ' -p, --profile <CONFIG_PROFILE>\\n',
}
);
expect(result.status).toBe(1);
expect(result.stderr).toContain('Codex CLI (codex-cli 9.9.9-test)');
expect(result.stderr).toContain('does not advertise --config overrides');
const calls = readLoggedCodexCalls(codexArgsLogPath);
expect(calls).toEqual([['--help'], ['--version']]);
});
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,644 @@
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('warns when the active model provider is selected without base_url or env_key', async () => {
fs.writeFileSync(
path.join(codexHome, 'config.toml'),
`model_provider = "cliproxy"
[model_providers.cliproxy]
wire_api = "responses"
`
);
const diagnostics = await getCodexDashboardDiagnostics();
expect(diagnostics.warnings.some((warning) => warning.includes('missing base_url'))).toBe(true);
expect(diagnostics.warnings.some((warning) => warning.includes('missing env_key'))).toBe(true);
});
it('does not warn for built-in openai provider without a custom model_providers entry', async () => {
fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model_provider = "openai"\n');
const diagnostics = await getCodexDashboardDiagnostics();
expect(diagnostics.warnings.some((warning) => warning.includes('missing from [model_providers]'))).toBe(
false
);
});
it('allows custom providers that use requires_openai_auth without env_key warnings', async () => {
fs.writeFileSync(
path.join(codexHome, 'config.toml'),
`model_provider = "corp-openai"
[model_providers.corp-openai]
base_url = "https://example.test/v1"
requires_openai_auth = true
`
);
const diagnostics = await getCodexDashboardDiagnostics();
expect(diagnostics.warnings.some((warning) => warning.includes('missing env_key'))).toBe(false);
});
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={
@@ -297,6 +297,7 @@ export function ProviderEditor({
defaultTarget={defaultTarget}
data={data}
authStatus={authStatus}
supportsModelConfig={Boolean(catalog)}
/>
</TabsContent>
</div>
@@ -17,6 +17,7 @@ interface ProviderInfoTabProps {
defaultTarget?: CliTarget;
data?: SettingsResponse;
authStatus: AuthStatus;
supportsModelConfig?: boolean;
}
export function ProviderInfoTab({
@@ -25,9 +26,16 @@ export function ProviderInfoTab({
defaultTarget,
data,
authStatus,
supportsModelConfig = false,
}: ProviderInfoTabProps) {
const resolvedTarget = defaultTarget || 'claude';
const isDroidTarget = resolvedTarget === 'droid';
const isCodexProvider = provider === 'codex';
const managementPrefix =
resolvedTarget === 'claude' ? `ccs ${provider}` : `ccs ${provider} --target claude`;
const changeModelCommand = `${managementPrefix} --config`;
const addAccountCommand = `${managementPrefix} --auth --add`;
const listAccountsCommand = `${managementPrefix} --accounts`;
return (
<ScrollArea className="h-full">
@@ -88,6 +96,18 @@ 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 (shortcut)"
command={`ccsxp "your prompt"`}
/>
<UsageCommand
label="Run on native Codex (--target)"
command={`ccs ${provider} --target codex "your prompt"`}
/>
</>
)}
<UsageCommand
label={isDroidTarget ? 'Droid alias (explicit)' : 'Run on Droid'}
command={`ccs-droid ${provider} "your prompt"`}
@@ -96,9 +116,30 @@ export function ProviderInfoTab({
label={isDroidTarget ? 'Override to Claude' : 'Run on Droid (--target)'}
command={`ccs ${provider} --target ${isDroidTarget ? 'claude' : 'droid'} "your prompt"`}
/>
<UsageCommand label="Change model" command={`ccs ${provider} --config`} />
<UsageCommand label="Add account" command={`ccs ${provider} --add`} />
<UsageCommand label="List accounts" command={`ccs ${provider} --accounts`} />
{supportsModelConfig && (
<UsageCommand
label={
resolvedTarget === 'claude' ? 'Change model' : 'Change model (Claude target)'
}
command={changeModelCommand}
/>
)}
<UsageCommand
label={resolvedTarget === 'claude' ? 'Add account' : 'Add account (Claude target)'}
command={addAccountCommand}
/>
<UsageCommand
label={
resolvedTarget === 'claude' ? 'List accounts' : 'List accounts (Claude target)'
}
command={listAccountsCommand}
/>
{resolvedTarget !== 'claude' && (
<p className="text-xs text-muted-foreground">
Account and model-management flags stay on Claude target. Codex and Droid runtime
launches reject those CLIProxy management commands.
</p>
)}
</div>
</div>
</div>
@@ -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,215 @@
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';
import { CLIPROXY_NATIVE_CODEX_RECIPE } from '@/lib/codex-config';
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" />
CCS bridge recipe
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>
Use <code>ccsxp</code> if you want the built-in CCS Codex provider shortcut on native
Codex. Use the saved recipe below if you want plain <code>codex</code> or a personal
alias like <code>cxp</code> to default to CLIProxy.
</p>
<pre className="overflow-x-auto rounded-md border bg-muted/20 p-3 text-xs text-foreground">
{CLIPROXY_NATIVE_CODEX_RECIPE}
</pre>
<div className="space-y-1">
<p>
1. Save the <code>cliproxy</code> provider in your user config.
</p>
<p>
2. Set top-level <code>model_provider</code> to <code>cliproxy</code>.
</p>
<p>
3. Export <code>CLIPROXY_API_KEY</code> in your shell before launching native Codex.
</p>
</div>
</CardContent>
</Card>
<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,242 @@
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,
};
const CLIPROXY_CODEX_PROVIDER_PRESET: CodexModelProviderEntry = {
name: 'cliproxy',
displayName: 'CLIProxy Codex',
baseUrl: 'http://127.0.0.1:8317/api/provider/codex',
envKey: 'CLIPROXY_API_KEY',
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 (
<>
{isNew && (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground">
<p>
Quick start: apply the CLIProxy Codex preset here, then set{' '}
<strong>Default provider</strong> to <code>cliproxy</code> in Top-level settings.
</p>
<Button
variant="outline"
size="sm"
onClick={() => setDraft(CLIPROXY_CODEX_PROVIDER_PRESET)}
disabled={disabled}
>
Use CLIProxy Codex preset
</Button>
</div>
)}
<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>
<p className="text-xs text-muted-foreground">
If you want plain native <code>codex</code> to default to CLIProxy, save a provider named{' '}
<code>cliproxy</code> with <code>CLIPROXY_API_KEY</code> here, then pick{' '}
<code>cliproxy</code> in the <strong>Default provider</strong> control above.
</p>
</>
);
}
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,373 @@
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 { CLIPROXY_NATIVE_CODEX_RECIPE } from '@/lib/codex-config';
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) {
const inspectProfileCommand = diagnostics.config.activeProfile
? `codex --profile ${diagnostics.config.activeProfile}`
: 'codex';
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>
<code>ccs-codex</code> and <code>ccsx</code> launch native Codex against your saved
native config, while <code>ccsxp</code> is the opinionated shortcut for{' '}
<code>ccs codex --target codex</code>.
</p>
<p>
Plain <code>codex</code> or a personal alias like <code>cxp</code> needs{' '}
<code>model_provider = "cliproxy"</code> plus a matching{' '}
<code>[model_providers.cliproxy]</code> entry if you want CLIProxy as the saved native
default.
</p>
<p>
Built-in <code>openai</code> and <code>oss</code> providers are also valid native
defaults and do not need a custom <code>[model_providers]</code> stanza.
</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="Native aliases" value="ccs-codex, ccsx" mono />
<DetailRow label="CCS provider shortcut" value="ccsxp" 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">
<Route className="h-4 w-4" />
CLIProxy-backed native Codex
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>
There are two supported paths. Use <code>ccsxp</code> if you want the built-in CCS
Codex provider shortcut. Use the saved recipe below if you want plain{' '}
<code>codex</code> or a personal alias like <code>cxp</code> to default to CLIProxy.
</p>
<div className="rounded-md border bg-muted/20 p-3">
<p className="font-medium text-foreground">Saved native Codex recipe</p>
<pre className="mt-2 overflow-x-auto rounded-md bg-background p-3 text-xs text-foreground">
{CLIPROXY_NATIVE_CODEX_RECIPE}
</pre>
</div>
<div className="space-y-1">
<p>
1. Save a provider named <code>cliproxy</code> with the base URL and env key above.
</p>
<p>
2. In <strong>Top-level settings</strong>, set <strong>Default provider</strong> to{' '}
<code>cliproxy</code>.
</p>
<p>
3. Export <code>CLIPROXY_API_KEY</code> in your shell before launching native Codex.
</p>
</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 short alias',
command: 'ccsx',
description: 'Launch the short native Codex runtime alias.',
},
{
label: 'CCS Codex shortcut',
command: 'ccsxp "your prompt"',
description: 'Run the built-in CCS Codex provider on native Codex.',
},
{
label: 'Explicit provider route',
command: 'ccs codex --target codex "your prompt"',
description: 'Use the explicit built-in Codex provider route.',
},
{
label: diagnostics.config.activeProfile
? 'Inspect active profile'
: 'Open native Codex',
command: inspectProfileCommand,
description: diagnostics.config.activeProfile
? 'Inspect the active named profile directly in native Codex.'
: 'Open native Codex without forcing a named profile overlay.',
},
]}
/>
<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> when
you want the local Codex CLI to honor your saved native user config.
</p>
</div>
<div className="rounded-md border p-3 text-sm">
<p className="font-medium">CCS Codex provider / bridge</p>
<p className="mt-1 text-muted-foreground">
Use <code>ccsxp</code> or <code>ccs codex --target codex</code> when you want the
built-in CCS Codex provider on native Codex. That path uses transient CCS-managed
overrides and is separate from the saved <code>cliproxy</code> recipe above.
</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: {
+235
View File
@@ -0,0 +1,235 @@
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 CLIPROXY_NATIVE_CODEX_RECIPE = `model_provider = "cliproxy"
[model_providers.cliproxy]
base_url = "http://127.0.0.1:8317/api/provider/codex"
env_key = "CLIPROXY_API_KEY"
wire_api = "responses"`;
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;
}
+8 -8
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.',
},
@@ -950,7 +950,7 @@ const resources = {
actionRequired: 'Action Required',
done: 'Done',
all: 'All',
inboxTitle: 'Updates Inbox',
inboxTitle: 'Updates Center',
inboxSubtitle: 'Focus on actions, then mark updates done or dismissed.',
needsAction: 'Needs Action',
doneCount: 'Done',
@@ -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。',
},
@@ -2118,7 +2118,7 @@ const resources = {
actionRequired: '待处理',
done: '已完成',
all: '全部',
inboxTitle: '更新收件箱',
inboxTitle: '更新中心',
inboxSubtitle: '先处理操作,再将更新标记为完成或忽略。',
needsAction: '需处理',
doneCount: '已完成',
@@ -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.',
},
@@ -3344,7 +3344,7 @@ const resources = {
actionRequired: 'Hành động bắt buộc',
done: 'Xong',
all: 'Tất cả',
inboxTitle: 'Hộp thư cập nhật',
inboxTitle: 'Trung tâm cập nhật',
inboxSubtitle:
'Tập trung vào các hành động, sau đó đánh dấu các cập nhật đã hoàn tất hoặc bị loại bỏ.',
needsAction: 'Cần hành động',
@@ -4469,7 +4469,7 @@ const resources = {
supportLine1Suffix: '(トークンベース)',
supportLine2Prefix: '推論強度:',
supportLine2SuffixPrefix: '(サフィックス、または ',
supportLine2SuffixPostfix: ': medium/high/xhigh)',
supportLine2SuffixPostfix: ': minimal/low/medium/high/xhigh)',
supportLine3Prefix: 'Codex のサフィックスは推論強度を固定します(例: ',
supportLine3Suffix: ')。サフィックスなしのモデルは思考モードを使います。',
},
@@ -4580,7 +4580,7 @@ const resources = {
actionRequired: '対応が必要',
done: '完了',
all: 'すべて',
inboxTitle: '更新インボックス',
inboxTitle: '更新センター',
inboxSubtitle: '対応を優先し、その後で完了または非表示にします。',
needsAction: '要対応',
doneCount: '完了',
+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;
+87 -6
View File
@@ -55,6 +55,61 @@ 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, ccsxp, 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.',
'Use ccsxp for the built-in CCS Codex provider shortcut on native Codex.',
'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 shortcut on native Codex.',
type: 'command',
command: 'ccsxp "your prompt"',
},
{
id: 'copy-codex-provider-command-explicit',
label: 'Run built-in Codex on Codex (explicit)',
description: 'Use the explicit built-in Codex provider route on native Codex.',
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',
'ccsxp "your prompt"',
'ccs codex --target codex "your prompt"',
],
},
{
id: 'droid-target-support',
title: 'Factory Droid support is live',
@@ -114,9 +169,9 @@ export const SUPPORT_NOTICES: SupportNotice[] = [
},
{
id: 'updates-center-launch',
title: 'Updates inbox is available for rollout tasks',
title: 'Updates Center is available for rollout tasks',
summary:
'A focused updates inbox exists for setup tasks and rollout guidance when you need it.',
'A focused Updates Center exists for setup tasks and rollout guidance when you need it.',
primaryAction:
'Use this page only when needed for rollout tasks, then return to your normal workflow.',
publishedAt: '2026-02-25',
@@ -131,7 +186,7 @@ export const SUPPORT_NOTICES: SupportNotice[] = [
actions: [
{
id: 'open-updates-page',
label: 'Open updates inbox when needed',
label: 'Open Updates Center when needed',
description: 'Review rollout tasks only when you want guided setup changes.',
type: 'route',
path: '/updates',
@@ -144,7 +199,7 @@ export const SUPPORT_NOTICES: SupportNotice[] = [
command: 'ccs config',
},
],
routes: [{ label: 'Updates Inbox', path: '/updates' }],
routes: [{ label: 'Updates Center', path: '/updates' }],
commands: ['ccs config'],
},
];
@@ -185,12 +240,30 @@ 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',
scope: 'cliproxy',
status: 'stable',
summary: 'OAuth-backed provider with configurable variant model and target.',
summary: 'OAuth-backed provider with configurable variant model and a native Codex shortcut.',
pillars: {
baseUrl: 'Managed by CLIProxy backend',
auth: 'OAuth account via CLIProxy auth flow',
@@ -200,7 +273,15 @@ 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: [
'ccsxp "your prompt"',
'ccs codex --target codex',
'ccs codex',
'ccs cliproxy create mycodex --provider codex',
'ccs api create codex-api --cliproxy-provider codex',
],
notes:
'Use ccsxp when you want the built-in Codex provider on native Codex without retyping --target 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,54 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@tests/setup/test-utils';
import { ProviderInfoTab } from '@/components/cliproxy/provider-editor/provider-info-tab';
const authenticatedStatus = {
provider: 'codex',
displayName: 'Codex',
authenticated: true,
lastAuth: null,
tokenFiles: 1,
accounts: [],
};
describe('ProviderInfoTab', () => {
it('routes management commands through Claude target for codex-target variants', () => {
render(
<ProviderInfoTab
provider="codex"
displayName="Codex"
defaultTarget="codex"
authStatus={authenticatedStatus}
supportsModelConfig
/>
);
expect(screen.getByText('ccs codex --target claude --config')).toBeInTheDocument();
expect(screen.getByText('ccs codex --target claude --auth --add')).toBeInTheDocument();
expect(screen.getByText('ccs codex --target claude --accounts')).toBeInTheDocument();
expect(
screen.getByText(
/Codex and Droid runtime launches reject those CLIProxy management commands\./
)
).toBeInTheDocument();
});
it('hides unsupported model-config commands when the provider has no catalog support', () => {
render(
<ProviderInfoTab
provider="custom-provider"
displayName="Custom Provider"
defaultTarget="claude"
authStatus={{
...authenticatedStatus,
provider: 'custom-provider',
displayName: 'Custom Provider',
}}
supportsModelConfig={false}
/>
);
expect(screen.queryByText('Change model')).not.toBeInTheDocument();
expect(screen.getByText('ccs custom-provider --auth --add')).toBeInTheDocument();
});
});
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@tests/setup/test-utils';
import { CodexOverviewTab } from '@/components/compatible-cli/codex-overview-tab';
import type { CodexDashboardDiagnostics } from '@/hooks/use-codex-types';
function buildDiagnostics(activeProfile: string | null): CodexDashboardDiagnostics {
return {
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: 'openai',
activeProfile,
approvalPolicy: null,
sandboxMode: null,
webSearch: null,
toolOutputTokenLimit: null,
personality: null,
topLevelKeys: ['model_provider'],
profileCount: activeProfile ? 1 : 0,
profileNames: activeProfile ? [activeProfile] : [],
modelProviderCount: 0,
modelProviders: [],
featureCount: 0,
enabledFeatures: [],
disabledFeatures: [],
trustedProjectCount: 0,
untrustedProjectCount: 0,
projectTrust: [],
mcpServerCount: 0,
mcpServers: [],
},
supportMatrix: [],
warnings: [],
docsReference: {
providerValues: ['openai', 'oss', 'custom model_providers'],
settingsHierarchy: [],
notes: [],
links: [],
providerDocs: [],
},
};
}
describe('CodexOverviewTab', () => {
it('uses a bare codex command when no active profile is set', () => {
render(<CodexOverviewTab diagnostics={buildDiagnostics(null)} />);
expect(screen.getByText('Open native Codex')).toBeInTheDocument();
expect(screen.getAllByText('codex').length).toBeGreaterThan(0);
expect(screen.queryByText('codex --profile default')).not.toBeInTheDocument();
expect(
screen.getAllByText(
(_, node) =>
node?.textContent?.includes(
'Built-in openai and oss providers are also valid native defaults'
) ?? false
).length
).toBeGreaterThan(0);
});
it('uses the active named profile when one is selected', () => {
render(<CodexOverviewTab diagnostics={buildDiagnostics('work')} />);
expect(screen.getByText('Inspect active profile')).toBeInTheDocument();
expect(screen.getByText('codex --profile work')).toBeInTheDocument();
});
});
@@ -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,39 @@
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' }]);
});
it('uses Updates Center naming consistently for the rollout notice', () => {
const notice = SUPPORT_NOTICES.find((entry) => entry.id === 'updates-center-launch');
expect(notice).toBeDefined();
expect(notice?.title).toContain('Updates Center');
expect(notice?.actions).toContainEqual(
expect.objectContaining({
id: 'open-updates-page',
label: 'Open Updates Center when needed',
})
);
expect(notice?.routes).toContainEqual({ label: 'Updates Center', path: '/updates' });
});
});
+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'");
});
});