Skip to main content

codex/
lib.rs

1#![forbid(unsafe_code)]
2//! Async helper around the OpenAI Codex CLI for programmatic prompting, streaming, apply/diff helpers, and server flows.
3//!
4//! Shells out to `codex exec`, applies sane defaults (non-interactive color handling, timeouts, model hints), and surfaces single-response, streaming, apply/diff, and MCP/app-server helpers.
5//!
6//! ## Setup: binary + `CODEX_HOME`
7//! - Defaults pull `CODEX_BINARY` or `codex` on `PATH`; call [`CodexClientBuilder::binary`] (optionally fed by [`resolve_bundled_binary`]) to pin an app-bundled binary without touching user installs.
8//! - Isolate state with [`CodexClientBuilder::codex_home`] (config/auth/history/logs live under that directory) and optionally create the layout with [`CodexClientBuilder::create_home_dirs`]. [`CodexHomeLayout`] inspects `config.toml`, `auth.json`, `.credentials.json`, `history.jsonl`, `conversations/`, and `logs/`.
9//! - [`CodexHomeLayout::seed_auth_from`] copies `auth.json`/`.credentials.json` from a trusted seed home into an isolated `CODEX_HOME` without touching history/logs; use [`AuthSeedOptions`] to require files or skip missing ones.
10//! - [`AuthSessionHelper`] checks `codex login status` and can launch ChatGPT or API key login flows with an app-scoped `CODEX_HOME` without mutating the parent process env.
11//! - Wrapper defaults: temp working dir per call unless `working_dir` is set, `--skip-git-repo-check`, 120s timeout (use `Duration::ZERO` to disable), ANSI colors off, `RUST_LOG=error` if unset.
12//! - Model defaults: `gpt-5*`/`gpt-5.1*` (including codex variants) get `model_reasoning_effort="medium"`/`model_reasoning_summary="auto"`/`model_verbosity="low"` to avoid unsupported “minimal” combos.
13//!
14//! ## Bundled binary (Workstream J)
15//! - Apps can ship Codex inside an app-owned bundle rooted at e.g. `~/.myapp/codex-bin/<platform>/<version>/codex`; [`resolve_bundled_binary`] resolves that path without ever falling back to `PATH` or `CODEX_BINARY`. Hosts own downloads and version pins; missing bundles are hard errors.
16//! - Pair bundled binaries with per-project `CODEX_HOME` roots such as `~/.myapp/codex-homes/<project>/`, optionally seeding `auth.json` + `.credentials.json` from an app-owned seed home. History/logs remain per project; the wrapper still injects `CODEX_BINARY`/`CODEX_HOME` per spawn so the parent env stays untouched.
17//! - Default behavior remains unchanged until the helper is used; env/CLI defaults stay as documented above.
18//!
19//! ```rust,no_run
20//! use codex::CodexClient;
21//! # use std::time::Duration;
22//! # #[tokio::main]
23//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
24//! std::env::set_var("CODEX_HOME", "/tmp/my-app-codex");
25//! let client = CodexClient::builder()
26//!     .binary("/opt/myapp/bin/codex")
27//!     .model("gpt-5-codex")
28//!     .timeout(Duration::from_secs(45))
29//!     .build();
30//! let reply = client.send_prompt("Health check").await?;
31//! println!("{reply}");
32//! # Ok(()) }
33//! ```
34//!
35//! Surfaces:
36//! - [`CodexClient::send_prompt`] for a single prompt/response with optional `--json` output.
37//! - [`CodexClient::stream_exec`] for typed, real-time JSONL events from `codex exec --json`, returning an [`ExecStream`] with an event stream plus a completion future.
38//! - [`CodexClient::apply`] / [`CodexClient::diff`] to run `codex apply <TASK_ID>` and `codex cloud diff <TASK_ID>`, echo stdout/stderr according to the builder (`mirror_stdout` / `quiet`), and return captured output + exit status.
39//! - [`CodexClient::generate_app_server_bindings`] to refresh app-server protocol bindings via `codex app-server generate-ts` (optional `--prettier`) or `generate-json-schema`, returning captured stdout/stderr plus the exit status.
40//! - [`CodexClient::start_app_server_proxy`] and [`CodexClient::start_exec_server`] to spawn the newer `codex app-server proxy` and `codex exec-server` helpers with piped stdio for host-managed lifecycles.
41//! - [`CodexClient::run_sandbox`] to wrap `codex sandbox <platform>` (macOS/Linux/Windows), pass `--full-auto`/`--log-denials`/`--config`/`--enable`/`--disable`, and return the inner command status + output. macOS is the only platform that emits denial logs; Linux depends on the bundled `codex-linux-sandbox`; Windows sandboxing is experimental and relies on the upstream helper (no capability gating—non-zero exits bubble through).
42//! - [`CodexClient::check_execpolicy`] to evaluate shell commands against Starlark execpolicy files with repeatable `--policy` flags, optional pretty JSON, and parsed decision output (allow/prompt/forbidden or noMatch).
43//! - [`CodexClient::list_features`] to wrap `codex features list` with optional `--json` parsing, shared config/profile overrides, and parsed feature entries (name/stage/enabled).
44//! - [`CodexClient::debug_models`], [`CodexClient::debug_prompt_input`], and the `plugin*` helpers expose the additive 0.125.0 debug/plugin command families without dropping to raw process management.
45//! - [`CodexClient::start_responses_api_proxy`] to launch the `codex responses-api-proxy` helper with an API key piped via stdin plus optional port/server-info/upstream/shutdown flags.
46//! - [`CodexClient::stdio_to_uds`] to spawn `codex stdio-to-uds <SOCKET_PATH>` with piped stdio so callers can bridge Unix domain sockets manually.
47//!
48//! ## Streaming, events, and artifacts
49//! - `.json(true)` requests JSONL streaming. Expect `thread.started`/`thread.resumed`, `turn.started`/`turn.completed`/`turn.failed`, and `item.created`/`item.updated` with `item.type` such as `agent_message`, `reasoning`, `command_execution`, `file_change`, `mcp_tool_call`, `web_search`, or `todo_list` plus optional `status`/`content`/`input`. Errors surface as `{"type":"error","message":...}`.
50//! - Sample payloads ship with the streaming examples (`crates/codex/examples/fixtures/*`); most examples support `--sample` for offline inspection.
51//! - Disable `mirror_stdout` when parsing JSON so stdout stays under caller control; `quiet` controls stderr mirroring. `json_event_log` tees raw JSONL lines to disk before parsing; `idle_timeout`, `output_last_message`, and `output_schema` cover artifact handling.
52//! - `crates/codex/examples/stream_events.rs`, `stream_last_message.rs`, `stream_with_log.rs`, and `json_stream.rs` cover typed consumption, artifact handling, log teeing, and minimal streaming.
53//!
54//! ## Resume + apply/diff
55//! - `codex exec --json resume --last [-]` streams the same `thread/turn/item` events as `codex exec --json` but starts from an existing session (`thread.resumed`).
56//! - Apply/diff require task IDs: `codex apply <TASK_ID>` applies a diff, and `codex cloud diff <TASK_ID>` prints a cloud task diff when supported by the binary.
57//! - Convenience: [`CodexClient::apply`] / [`CodexClient::diff`] will append `<TASK_ID>` from `CODEX_TASK_ID` when set; otherwise they still spawn the command and return the non-zero exit status/output from the CLI.
58//! - `crates/codex/examples/resume_apply.rs` shows a CLI-native resume/apply flow and ships `--sample` fixtures for offline inspection.
59//!
60//! ## Servers and capability detection
61//! - Integrate the stdio servers via `codex mcp-server` / `codex app-server` (`crates/codex/examples/mcp_codex_flow.rs`, `mcp_codex_tool.rs`, `mcp_codex_reply.rs`, `app_server_turns.rs`, `app_server_thread_turn.rs`) to drive JSON-RPC flows, approvals, and shutdown.
62//! - `probe_capabilities` and the `feature_detection` example focus on `--output-schema`, `--add-dir`, `codex login --mcp`, and `codex features list` availability; other subcommand drift (like cloud-only commands) is surfaced by the parity snapshot/reports in `cli_manifests/codex/`.
63//!
64//! More end-to-end flows and CLI mappings live in `crates/codex/README.md` and `crates/codex/EXAMPLES.md`.
65//!
66//! ## Capability/versioning surfaces (Workstream F)
67//! - `probe_capabilities` captures `--version`, `features list`, and `--help` hints into a `CodexCapabilities` snapshot with `collected_at` timestamps and `BinaryFingerprint` metadata keyed by canonical binary path.
68//! - Guard helpers (`guard_output_schema`, `guard_add_dir`, `guard_mcp_login`, `guard_features_list`) keep optional flags disabled when support is unknown and return operator-facing notes for unsupported features.
69//! - Cache controls: `CapabilityCachePolicy::{PreferCache, Refresh, Bypass}` plus builder helpers steer cache reuse. Use `Refresh` for TTL/backoff windows or hot-swaps that reuse the same binary path; use `Bypass` when metadata is missing (FUSE/overlay filesystems) or when you need an isolated probe.
70//! - TTL/backoff helper: `capability_cache_ttl_decision` inspects `collected_at` to suggest when to reuse, refresh, or bypass cached snapshots and stretches the recommended policy when metadata is missing.
71//! - Overrides + persistence: `capability_snapshot`, `capability_overrides`, `write_capabilities_snapshot`, `read_capabilities_snapshot`, and `capability_snapshot_matches_binary` let hosts reuse snapshots across processes and fall back to probes when fingerprints diverge.
72
73mod apply_diff;
74mod auth;
75mod builder;
76mod bundled_binary;
77mod cli;
78mod client_core;
79mod commands;
80mod defaults;
81mod error;
82mod events;
83mod exec;
84mod execpolicy;
85mod home;
86pub mod jsonl;
87pub mod mcp;
88mod process;
89pub mod rollout_jsonl;
90pub mod wrapper_coverage_manifest;
91
92pub use crate::error::CodexError;
93pub use apply_diff::{ApplyDiffArtifacts, CloudApplyRequest, CloudDiffRequest};
94pub use auth::{AuthSessionHelper, CodexAuthMethod, CodexAuthStatus, CodexLogoutStatus};
95pub use builder::{
96    ApprovalPolicy, CliOverrides, CliOverridesPatch, CodexClientBuilder, ColorMode, ConfigOverride,
97    FeatureToggles, FlagState, LocalProvider, ModelVerbosity, ReasoningEffort, ReasoningOverrides,
98    ReasoningSummary, ReasoningSummaryFormat, SafetyOverride, SandboxMode,
99};
100pub use bundled_binary::{
101    default_bundled_platform_label, resolve_bundled_binary, BundledBinary, BundledBinaryError,
102    BundledBinarySpec,
103};
104pub use cli::{
105    AppServerCodegenOutput, AppServerCodegenRequest, AppServerCodegenTarget, AppServerProxyRequest,
106    AppServerRequest, CloudExecRequest, CloudListOutput, CloudListRequest, CloudOverviewRequest,
107    CloudStatusRequest, CodexFeature, CodexFeatureStage, DebugAppServerHelpRequest,
108    DebugAppServerRequest, DebugAppServerSendMessageV2Request, DebugCommandRequest,
109    DebugHelpRequest, DebugModelsRequest, DebugPromptInputRequest, ExecRequest,
110    ExecReviewCommandRequest, ExecServerRequest, FeaturesCommandRequest, FeaturesDisableRequest,
111    FeaturesEnableRequest, FeaturesListFormat, FeaturesListOutput, FeaturesListRequest,
112    ForkSessionRequest, HelpCommandRequest, HelpScope, McpAddRequest, McpAddTransport,
113    McpGetRequest, McpListOutput, McpListRequest, McpLogoutRequest, McpOauthLoginRequest,
114    McpOverviewRequest, McpRemoveRequest, PluginCommandRequest, PluginHelpRequest,
115    PluginMarketplaceAddRequest, PluginMarketplaceCommandRequest, PluginMarketplaceHelpRequest,
116    PluginMarketplaceRemoveRequest, PluginMarketplaceUpgradeRequest, ResponsesApiProxyHandle,
117    ResponsesApiProxyInfo, ResponsesApiProxyRequest, ResumeSessionRequest, ReviewCommandRequest,
118    SandboxCommandRequest, SandboxPlatform, SandboxRun, StdioToUdsRequest,
119};
120pub use events::{
121    CommandExecutionDelta, CommandExecutionState, EventError, FileChangeDelta, FileChangeKind,
122    FileChangeState, ItemDelta, ItemDeltaPayload, ItemEnvelope, ItemFailure, ItemPayload,
123    ItemSnapshot, ItemStatus, McpToolCallDelta, McpToolCallState, TextContent, TextDelta,
124    ThreadEvent, ThreadStarted, TodoItem, TodoListDelta, TodoListState, ToolCallStatus,
125    TurnCompleted, TurnFailed, TurnStarted, WebSearchDelta, WebSearchState, WebSearchStatus,
126};
127pub use exec::{
128    DynExecCompletion, DynThreadEventStream, ExecCompletion, ExecStream, ExecStreamControl,
129    ExecStreamError, ExecStreamRequest, ExecTerminationHandle, ResumeRequest, ResumeSelector,
130};
131pub use execpolicy::{
132    ExecPolicyCheckRequest, ExecPolicyCheckResult, ExecPolicyDecision, ExecPolicyEvaluation,
133    ExecPolicyMatch, ExecPolicyNoMatch, ExecPolicyRuleMatch,
134};
135pub use home::{AuthSeedError, AuthSeedOptions, AuthSeedOutcome, CodexHomeLayout};
136pub use jsonl::{
137    thread_event_jsonl_file, thread_event_jsonl_reader, JsonlThreadEventParser,
138    ThreadEventJsonlFileReader, ThreadEventJsonlReader, ThreadEventJsonlRecord,
139};
140pub use rollout_jsonl::{
141    find_rollout_file_by_id, find_rollout_files, rollout_jsonl_file, rollout_jsonl_reader,
142    RolloutBaseInstructions, RolloutContentPart, RolloutEvent, RolloutEventMsg,
143    RolloutEventMsgPayload, RolloutJsonlError, RolloutJsonlFileReader, RolloutJsonlParser,
144    RolloutJsonlReader, RolloutJsonlRecord, RolloutResponseItem, RolloutResponseItemPayload,
145    RolloutSessionMeta, RolloutSessionMetaPayload, RolloutUnknown,
146};
147
148use std::{
149    collections::BTreeMap,
150    path::{Path, PathBuf},
151    time::{Duration, SystemTime},
152};
153
154use home::CommandEnvironment;
155use process::command_output_text;
156use tracing::warn;
157
158#[cfg(test)]
159use tokio::time;
160
161#[cfg(test)]
162use tokio::sync::mpsc;
163
164#[cfg(test)]
165use builder::{
166    cli_override_args, reasoning_config_for, DEFAULT_REASONING_CONFIG_GPT5,
167    DEFAULT_REASONING_CONFIG_GPT5_1, DEFAULT_REASONING_CONFIG_GPT5_CODEX,
168};
169
170fn normalize_non_empty(value: &str) -> Option<String> {
171    let trimmed = value.trim();
172    (!trimmed.is_empty()).then_some(trimmed.to_string())
173}
174
175type Command = tokio::process::Command;
176type ConsoleTarget = crate::process::ConsoleTarget;
177
178#[cfg(test)]
179type OsString = std::ffi::OsString;
180
181async fn tee_stream<R>(
182    reader: R,
183    target: ConsoleTarget,
184    mirror_console: bool,
185) -> Result<Vec<u8>, std::io::Error>
186where
187    R: tokio::io::AsyncRead + Unpin,
188{
189    crate::process::tee_stream(reader, target, mirror_console).await
190}
191
192fn spawn_with_retry(
193    command: &mut Command,
194    binary: &std::path::Path,
195) -> Result<tokio::process::Child, CodexError> {
196    crate::process::spawn_with_retry(command, binary)
197}
198
199fn resolve_cli_overrides(
200    builder: &CliOverrides,
201    patch: &CliOverridesPatch,
202    model: Option<&str>,
203) -> builder::ResolvedCliOverrides {
204    builder::resolve_cli_overrides(builder, patch, model)
205}
206
207fn apply_cli_overrides(
208    command: &mut Command,
209    resolved: &builder::ResolvedCliOverrides,
210    include_search: bool,
211) {
212    builder::apply_cli_overrides(command, resolved, include_search);
213}
214
215#[cfg(test)]
216fn bundled_binary_filename(platform: &str) -> &'static str {
217    bundled_binary::bundled_binary_filename(platform)
218}
219
220mod capabilities;
221mod version;
222pub use capabilities::*;
223pub use version::update_advisory_from_capabilities;
224
225/// High-level client for interacting with `codex exec`.
226///
227/// Spawns the CLI with safe defaults (`--skip-git-repo-check`, temp working dirs unless
228/// `working_dir` is set, 120s timeout unless zero, ANSI colors off, `RUST_LOG=error` if unset),
229/// mirrors stdout by default, and returns whatever the CLI printed. See the crate docs for
230/// streaming/log tee/server patterns and example links.
231#[derive(Clone, Debug)]
232pub struct CodexClient {
233    command_env: CommandEnvironment,
234    model: Option<String>,
235    timeout: Duration,
236    color_mode: ColorMode,
237    working_dir: Option<PathBuf>,
238    add_dirs: Vec<PathBuf>,
239    images: Vec<PathBuf>,
240    json_output: bool,
241    output_schema: bool,
242    quiet: bool,
243    mirror_stdout: bool,
244    json_event_log: Option<PathBuf>,
245    cli_overrides: CliOverrides,
246    capability_overrides: CapabilityOverrides,
247    capability_cache_policy: CapabilityCachePolicy,
248}
249
250impl CodexClient {
251    /// Returns a [`CodexClientBuilder`] preloaded with safe defaults.
252    pub fn builder() -> CodexClientBuilder {
253        CodexClientBuilder::default()
254    }
255
256    /// Returns the configured `CODEX_HOME` layout, if one was provided.
257    /// This does not create any directories on disk; pair with
258    /// [`CodexClientBuilder::create_home_dirs`] to control materialization.
259    pub fn codex_home_layout(&self) -> Option<CodexHomeLayout> {
260        self.command_env.codex_home_layout()
261    }
262
263    /// Probes the configured binary for version/build metadata and supported feature flags.
264    ///
265    /// Results are cached per canonical binary path and invalidated when file metadata changes.
266    /// Caller-supplied overrides (see [`CodexClientBuilder::capability_overrides`]) can
267    /// short-circuit probes or layer hints; snapshots are still cached against the current
268    /// binary fingerprint so changes on disk trigger revalidation. Missing fingerprints skip
269    /// cache reuse to force a re-probe. Cache interaction follows the policy configured on
270    /// the builder (see [`CodexClientBuilder::capability_cache_policy`]).
271    /// Failures are logged and return conservative defaults so callers can gate optional flags.
272    pub async fn probe_capabilities(&self) -> CodexCapabilities {
273        self.probe_capabilities_internal(self.capability_cache_policy, &[], None)
274            .await
275    }
276
277    /// Probes capabilities using per-invocation environment overrides.
278    ///
279    /// Env overrides are applied after the wrapper's internal environment injection so the probe
280    /// observes the same effective environment as `stream_*_with_env_overrides_control`.
281    /// Non-empty overrides bypass the process-wide capability cache to avoid polluting cached
282    /// snapshots keyed only by binary path.
283    pub async fn probe_capabilities_with_env_overrides(
284        &self,
285        env_overrides: &BTreeMap<String, String>,
286    ) -> CodexCapabilities {
287        if env_overrides.is_empty() {
288            return self.probe_capabilities().await;
289        }
290
291        let env_overrides: Vec<(String, String)> = env_overrides
292            .iter()
293            .map(|(key, value)| (key.clone(), value.clone()))
294            .collect();
295
296        self.probe_capabilities_internal(CapabilityCachePolicy::Bypass, &env_overrides, None)
297            .await
298    }
299
300    /// Probes capabilities with an explicit cache policy.
301    pub async fn probe_capabilities_with_policy(
302        &self,
303        cache_policy: CapabilityCachePolicy,
304    ) -> CodexCapabilities {
305        self.probe_capabilities_internal(cache_policy, &[], None)
306            .await
307    }
308
309    pub(crate) async fn probe_capabilities_for_current_dir(
310        &self,
311        current_dir: &Path,
312    ) -> CodexCapabilities {
313        self.probe_capabilities_internal(self.capability_cache_policy, &[], Some(current_dir))
314            .await
315    }
316
317    pub(crate) async fn probe_capabilities_with_env_overrides_for_current_dir(
318        &self,
319        env_overrides: &BTreeMap<String, String>,
320        current_dir: &Path,
321    ) -> CodexCapabilities {
322        if env_overrides.is_empty() {
323            return self.probe_capabilities_for_current_dir(current_dir).await;
324        }
325
326        let env_overrides: Vec<(String, String)> = env_overrides
327            .iter()
328            .map(|(key, value)| (key.clone(), value.clone()))
329            .collect();
330
331        self.probe_capabilities_internal(
332            CapabilityCachePolicy::Bypass,
333            &env_overrides,
334            Some(current_dir),
335        )
336        .await
337    }
338
339    async fn probe_capabilities_internal(
340        &self,
341        cache_policy: CapabilityCachePolicy,
342        env_overrides: &[(String, String)],
343        current_dir: Option<&Path>,
344    ) -> CodexCapabilities {
345        let cache_key = capability_cache_key_for_current_dir_with_env(
346            self.command_env.binary_path(),
347            current_dir,
348            env_overrides,
349        );
350        let fingerprint = current_fingerprint(&cache_key);
351        let overrides = &self.capability_overrides;
352
353        let cache_reads_enabled = matches!(cache_policy, CapabilityCachePolicy::PreferCache)
354            && has_fingerprint_metadata(&fingerprint);
355        let cache_writes_enabled = !matches!(cache_policy, CapabilityCachePolicy::Bypass)
356            && has_fingerprint_metadata(&fingerprint);
357
358        if let Some(snapshot) = overrides.snapshot.clone() {
359            let capabilities = finalize_capabilities_with_overrides(
360                snapshot,
361                overrides,
362                cache_key.clone(),
363                fingerprint.clone(),
364                true,
365            );
366            if cache_writes_enabled {
367                update_capability_cache(capabilities.clone());
368            }
369            return capabilities;
370        }
371
372        if cache_reads_enabled {
373            if let Some(cached) = cached_capabilities(&cache_key, &fingerprint) {
374                if overrides.is_empty() {
375                    return cached;
376                }
377                let merged = finalize_capabilities_with_overrides(
378                    cached,
379                    overrides,
380                    cache_key.clone(),
381                    fingerprint.clone(),
382                    false,
383                );
384                if cache_writes_enabled {
385                    update_capability_cache(merged.clone());
386                }
387                return merged;
388            }
389        }
390
391        let probed = self
392            .probe_capabilities_uncached(
393                &cache_key,
394                fingerprint.clone(),
395                env_overrides,
396                current_dir,
397            )
398            .await;
399
400        let capabilities =
401            finalize_capabilities_with_overrides(probed, overrides, cache_key, fingerprint, false);
402
403        if cache_writes_enabled {
404            update_capability_cache(capabilities.clone());
405        }
406
407        capabilities
408    }
409
410    async fn probe_capabilities_uncached(
411        &self,
412        cache_key: &CapabilityCacheKey,
413        fingerprint: Option<BinaryFingerprint>,
414        env_overrides: &[(String, String)],
415        current_dir: Option<&Path>,
416    ) -> CodexCapabilities {
417        let mut plan = CapabilityProbePlan::default();
418        let mut features = CodexFeatureFlags::default();
419        let mut version = None;
420
421        plan.steps.push(CapabilityProbeStep::VersionFlag);
422        match self
423            .run_basic_command_with_env_overrides_and_current_dir(
424                ["--version"],
425                env_overrides,
426                current_dir,
427            )
428            .await
429        {
430            Ok(output) => {
431                if !output.status.success() {
432                    warn!(
433                        status = ?output.status,
434                        binary = ?cache_key.binary_path,
435                        "codex --version exited non-zero"
436                    );
437                }
438                let text = command_output_text(&output);
439                if !text.trim().is_empty() {
440                    version = Some(version::parse_version_output(&text));
441                }
442            }
443            Err(error) => warn!(
444                ?error,
445                binary = ?cache_key.binary_path,
446                "codex --version probe failed"
447            ),
448        }
449
450        let mut parsed_features = false;
451
452        plan.steps.push(CapabilityProbeStep::FeaturesListJson);
453        match self
454            .run_basic_command_with_env_overrides_and_current_dir(
455                ["features", "list", "--json"],
456                env_overrides,
457                current_dir,
458            )
459            .await
460        {
461            Ok(output) => {
462                if !output.status.success() {
463                    warn!(
464                        status = ?output.status,
465                        binary = ?cache_key.binary_path,
466                        "codex features list --json exited non-zero"
467                    );
468                }
469                if output.status.success() {
470                    features.supports_features_list = true;
471                }
472                let text = command_output_text(&output);
473                if let Some(parsed) = version::parse_features_from_json(&text) {
474                    version::merge_feature_flags(&mut features, parsed);
475                    parsed_features = version::detected_feature_flags(&features);
476                } else if !text.is_empty() {
477                    let parsed = version::parse_features_from_text(&text);
478                    version::merge_feature_flags(&mut features, parsed);
479                    parsed_features = version::detected_feature_flags(&features);
480                }
481            }
482            Err(error) => warn!(
483                ?error,
484                binary = ?cache_key.binary_path,
485                "codex features list --json probe failed"
486            ),
487        }
488
489        if !parsed_features {
490            plan.steps.push(CapabilityProbeStep::FeaturesListText);
491            match self
492                .run_basic_command_with_env_overrides_and_current_dir(
493                    ["features", "list"],
494                    env_overrides,
495                    current_dir,
496                )
497                .await
498            {
499                Ok(output) => {
500                    if !output.status.success() {
501                        warn!(
502                            status = ?output.status,
503                            binary = ?cache_key.binary_path,
504                            "codex features list exited non-zero"
505                        );
506                    }
507                    if output.status.success() {
508                        features.supports_features_list = true;
509                    }
510                    let text = command_output_text(&output);
511                    let parsed = version::parse_features_from_text(&text);
512                    version::merge_feature_flags(&mut features, parsed);
513                }
514                Err(error) => warn!(
515                    ?error,
516                    binary = ?cache_key.binary_path,
517                    "codex features list probe failed"
518                ),
519            }
520        }
521
522        if version::should_run_help_fallback(&features) {
523            plan.steps.push(CapabilityProbeStep::HelpFallback);
524            match self
525                .run_basic_command_with_env_overrides_and_current_dir(
526                    ["--help"],
527                    env_overrides,
528                    current_dir,
529                )
530                .await
531            {
532                Ok(output) => {
533                    if !output.status.success() {
534                        warn!(
535                            status = ?output.status,
536                            binary = ?cache_key.binary_path,
537                            "codex --help exited non-zero"
538                        );
539                    }
540                    let text = command_output_text(&output);
541                    let parsed = version::parse_help_output(&text);
542                    version::merge_feature_flags(&mut features, parsed);
543                }
544                Err(error) => warn!(
545                    ?error,
546                    binary = ?cache_key.binary_path,
547                    "codex --help probe failed"
548                ),
549            }
550        }
551
552        CodexCapabilities {
553            cache_key: cache_key.clone(),
554            fingerprint,
555            version,
556            features,
557            probe_plan: plan,
558            collected_at: SystemTime::now(),
559        }
560    }
561
562    /// Computes an update advisory by comparing the probed Codex version against
563    /// caller-supplied latest releases.
564    ///
565    /// The crate does not fetch release metadata itself; hosts should populate
566    /// [`CodexLatestReleases`] using their preferred update channel (npm,
567    /// Homebrew, GitHub releases) and then call this helper. Results leverage
568    /// the capability probe cache; callers with an existing
569    /// [`CodexCapabilities`] snapshot can skip the probe by invoking
570    /// [`update_advisory_from_capabilities`].
571    pub async fn update_advisory(
572        &self,
573        latest_releases: &CodexLatestReleases,
574    ) -> CodexUpdateAdvisory {
575        let capabilities = self.probe_capabilities().await;
576        update_advisory_from_capabilities(&capabilities, latest_releases)
577    }
578}
579
580impl Default for CodexClient {
581    fn default() -> Self {
582        CodexClient::builder().build()
583    }
584}
585
586#[cfg(all(test, unix))]
587mod tests;