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::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).
41//! - [`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).
42//! - [`CodexClient::list_features`] to wrap `codex features list` with optional `--json` parsing, shared config/profile overrides, and parsed feature entries (name/stage/enabled).
43//! - [`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.
44//! - [`CodexClient::stdio_to_uds`] to spawn `codex stdio-to-uds <SOCKET_PATH>` with piped stdio so callers can bridge Unix domain sockets manually.
45//!
46//! ## Streaming, events, and artifacts
47//! - `.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":...}`.
48//! - Sample payloads ship with the streaming examples (`crates/codex/examples/fixtures/*`); most examples support `--sample` for offline inspection.
49//! - 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.
50//! - `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.
51//!
52//! ## Resume + apply/diff
53//! - `codex exec --json resume --last [-]` streams the same `thread/turn/item` events as `codex exec --json` but starts from an existing session (`thread.resumed`).
54//! - 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.
55//! - 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.
56//! - `crates/codex/examples/resume_apply.rs` shows a CLI-native resume/apply flow and ships `--sample` fixtures for offline inspection.
57//!
58//! ## Servers and capability detection
59//! - 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.
60//! - `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/`.
61//!
62//! More end-to-end flows and CLI mappings live in `crates/codex/README.md` and `crates/codex/EXAMPLES.md`.
63//!
64//! ## Capability/versioning surfaces (Workstream F)
65//! - `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.
66//! - 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.
67//! - 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.
68//! - 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.
69//! - 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.
70
71mod apply_diff;
72mod auth;
73mod builder;
74mod bundled_binary;
75mod cli;
76mod client_core;
77mod commands;
78mod defaults;
79mod error;
80mod events;
81mod exec;
82mod execpolicy;
83mod home;
84pub mod jsonl;
85pub mod mcp;
86mod process;
87pub mod rollout_jsonl;
88pub mod wrapper_coverage_manifest;
89
90pub use crate::error::CodexError;
91pub use apply_diff::{ApplyDiffArtifacts, CloudApplyRequest, CloudDiffRequest};
92pub use auth::{AuthSessionHelper, CodexAuthMethod, CodexAuthStatus, CodexLogoutStatus};
93pub use builder::{
94    ApprovalPolicy, CliOverrides, CliOverridesPatch, CodexClientBuilder, ColorMode, ConfigOverride,
95    FeatureToggles, FlagState, LocalProvider, ModelVerbosity, ReasoningEffort, ReasoningOverrides,
96    ReasoningSummary, ReasoningSummaryFormat, SafetyOverride, SandboxMode,
97};
98pub use bundled_binary::{
99    default_bundled_platform_label, resolve_bundled_binary, BundledBinary, BundledBinaryError,
100    BundledBinarySpec,
101};
102pub use cli::{
103    AppServerCodegenOutput, AppServerCodegenRequest, AppServerCodegenTarget, CloudExecRequest,
104    CloudListOutput, CloudListRequest, CloudOverviewRequest, CloudStatusRequest, CodexFeature,
105    CodexFeatureStage, DebugAppServerHelpRequest, DebugAppServerRequest,
106    DebugAppServerSendMessageV2Request, DebugCommandRequest, DebugHelpRequest, ExecRequest,
107    ExecReviewCommandRequest, FeaturesCommandRequest, FeaturesDisableRequest,
108    FeaturesEnableRequest, FeaturesListFormat, FeaturesListOutput, FeaturesListRequest,
109    ForkSessionRequest, HelpCommandRequest, HelpScope, McpAddRequest, McpAddTransport,
110    McpGetRequest, McpListOutput, McpListRequest, McpLogoutRequest, McpOauthLoginRequest,
111    McpOverviewRequest, McpRemoveRequest, ResponsesApiProxyHandle, ResponsesApiProxyInfo,
112    ResponsesApiProxyRequest, ResumeSessionRequest, ReviewCommandRequest, SandboxCommandRequest,
113    SandboxPlatform, SandboxRun, StdioToUdsRequest,
114};
115pub use events::{
116    CommandExecutionDelta, CommandExecutionState, EventError, FileChangeDelta, FileChangeKind,
117    FileChangeState, ItemDelta, ItemDeltaPayload, ItemEnvelope, ItemFailure, ItemPayload,
118    ItemSnapshot, ItemStatus, McpToolCallDelta, McpToolCallState, TextContent, TextDelta,
119    ThreadEvent, ThreadStarted, TodoItem, TodoListDelta, TodoListState, ToolCallStatus,
120    TurnCompleted, TurnFailed, TurnStarted, WebSearchDelta, WebSearchState, WebSearchStatus,
121};
122pub use exec::{
123    DynExecCompletion, DynThreadEventStream, ExecCompletion, ExecStream, ExecStreamControl,
124    ExecStreamError, ExecStreamRequest, ExecTerminationHandle, ResumeRequest, ResumeSelector,
125};
126pub use execpolicy::{
127    ExecPolicyCheckRequest, ExecPolicyCheckResult, ExecPolicyDecision, ExecPolicyEvaluation,
128    ExecPolicyMatch, ExecPolicyNoMatch, ExecPolicyRuleMatch,
129};
130pub use home::{AuthSeedError, AuthSeedOptions, AuthSeedOutcome, CodexHomeLayout};
131pub use jsonl::{
132    thread_event_jsonl_file, thread_event_jsonl_reader, JsonlThreadEventParser,
133    ThreadEventJsonlFileReader, ThreadEventJsonlReader, ThreadEventJsonlRecord,
134};
135pub use rollout_jsonl::{
136    find_rollout_file_by_id, find_rollout_files, rollout_jsonl_file, rollout_jsonl_reader,
137    RolloutBaseInstructions, RolloutContentPart, RolloutEvent, RolloutEventMsg,
138    RolloutEventMsgPayload, RolloutJsonlError, RolloutJsonlFileReader, RolloutJsonlParser,
139    RolloutJsonlReader, RolloutJsonlRecord, RolloutResponseItem, RolloutResponseItemPayload,
140    RolloutSessionMeta, RolloutSessionMetaPayload, RolloutUnknown,
141};
142
143use std::{
144    collections::BTreeMap,
145    path::{Path, PathBuf},
146    time::{Duration, SystemTime},
147};
148
149use home::CommandEnvironment;
150use process::command_output_text;
151use tracing::warn;
152
153#[cfg(test)]
154use tokio::time;
155
156#[cfg(test)]
157use tokio::sync::mpsc;
158
159#[cfg(test)]
160use builder::{
161    cli_override_args, reasoning_config_for, DEFAULT_REASONING_CONFIG_GPT5,
162    DEFAULT_REASONING_CONFIG_GPT5_1, DEFAULT_REASONING_CONFIG_GPT5_CODEX,
163};
164
165fn normalize_non_empty(value: &str) -> Option<String> {
166    let trimmed = value.trim();
167    (!trimmed.is_empty()).then_some(trimmed.to_string())
168}
169
170type Command = tokio::process::Command;
171type ConsoleTarget = crate::process::ConsoleTarget;
172
173#[cfg(test)]
174type OsString = std::ffi::OsString;
175
176async fn tee_stream<R>(
177    reader: R,
178    target: ConsoleTarget,
179    mirror_console: bool,
180) -> Result<Vec<u8>, std::io::Error>
181where
182    R: tokio::io::AsyncRead + Unpin,
183{
184    crate::process::tee_stream(reader, target, mirror_console).await
185}
186
187fn spawn_with_retry(
188    command: &mut Command,
189    binary: &std::path::Path,
190) -> Result<tokio::process::Child, CodexError> {
191    crate::process::spawn_with_retry(command, binary)
192}
193
194fn resolve_cli_overrides(
195    builder: &CliOverrides,
196    patch: &CliOverridesPatch,
197    model: Option<&str>,
198) -> builder::ResolvedCliOverrides {
199    builder::resolve_cli_overrides(builder, patch, model)
200}
201
202fn apply_cli_overrides(
203    command: &mut Command,
204    resolved: &builder::ResolvedCliOverrides,
205    include_search: bool,
206) {
207    builder::apply_cli_overrides(command, resolved, include_search);
208}
209
210#[cfg(test)]
211fn bundled_binary_filename(platform: &str) -> &'static str {
212    bundled_binary::bundled_binary_filename(platform)
213}
214
215mod capabilities;
216mod version;
217pub use capabilities::*;
218pub use version::update_advisory_from_capabilities;
219
220/// High-level client for interacting with `codex exec`.
221///
222/// Spawns the CLI with safe defaults (`--skip-git-repo-check`, temp working dirs unless
223/// `working_dir` is set, 120s timeout unless zero, ANSI colors off, `RUST_LOG=error` if unset),
224/// mirrors stdout by default, and returns whatever the CLI printed. See the crate docs for
225/// streaming/log tee/server patterns and example links.
226#[derive(Clone, Debug)]
227pub struct CodexClient {
228    command_env: CommandEnvironment,
229    model: Option<String>,
230    timeout: Duration,
231    color_mode: ColorMode,
232    working_dir: Option<PathBuf>,
233    add_dirs: Vec<PathBuf>,
234    images: Vec<PathBuf>,
235    json_output: bool,
236    output_schema: bool,
237    quiet: bool,
238    mirror_stdout: bool,
239    json_event_log: Option<PathBuf>,
240    cli_overrides: CliOverrides,
241    capability_overrides: CapabilityOverrides,
242    capability_cache_policy: CapabilityCachePolicy,
243}
244
245impl CodexClient {
246    /// Returns a [`CodexClientBuilder`] preloaded with safe defaults.
247    pub fn builder() -> CodexClientBuilder {
248        CodexClientBuilder::default()
249    }
250
251    /// Returns the configured `CODEX_HOME` layout, if one was provided.
252    /// This does not create any directories on disk; pair with
253    /// [`CodexClientBuilder::create_home_dirs`] to control materialization.
254    pub fn codex_home_layout(&self) -> Option<CodexHomeLayout> {
255        self.command_env.codex_home_layout()
256    }
257
258    /// Probes the configured binary for version/build metadata and supported feature flags.
259    ///
260    /// Results are cached per canonical binary path and invalidated when file metadata changes.
261    /// Caller-supplied overrides (see [`CodexClientBuilder::capability_overrides`]) can
262    /// short-circuit probes or layer hints; snapshots are still cached against the current
263    /// binary fingerprint so changes on disk trigger revalidation. Missing fingerprints skip
264    /// cache reuse to force a re-probe. Cache interaction follows the policy configured on
265    /// the builder (see [`CodexClientBuilder::capability_cache_policy`]).
266    /// Failures are logged and return conservative defaults so callers can gate optional flags.
267    pub async fn probe_capabilities(&self) -> CodexCapabilities {
268        self.probe_capabilities_internal(self.capability_cache_policy, &[], None)
269            .await
270    }
271
272    /// Probes capabilities using per-invocation environment overrides.
273    ///
274    /// Env overrides are applied after the wrapper's internal environment injection so the probe
275    /// observes the same effective environment as `stream_*_with_env_overrides_control`.
276    /// Non-empty overrides bypass the process-wide capability cache to avoid polluting cached
277    /// snapshots keyed only by binary path.
278    pub async fn probe_capabilities_with_env_overrides(
279        &self,
280        env_overrides: &BTreeMap<String, String>,
281    ) -> CodexCapabilities {
282        if env_overrides.is_empty() {
283            return self.probe_capabilities().await;
284        }
285
286        let env_overrides: Vec<(String, String)> = env_overrides
287            .iter()
288            .map(|(key, value)| (key.clone(), value.clone()))
289            .collect();
290
291        self.probe_capabilities_internal(CapabilityCachePolicy::Bypass, &env_overrides, None)
292            .await
293    }
294
295    /// Probes capabilities with an explicit cache policy.
296    pub async fn probe_capabilities_with_policy(
297        &self,
298        cache_policy: CapabilityCachePolicy,
299    ) -> CodexCapabilities {
300        self.probe_capabilities_internal(cache_policy, &[], None)
301            .await
302    }
303
304    pub(crate) async fn probe_capabilities_for_current_dir(
305        &self,
306        current_dir: &Path,
307    ) -> CodexCapabilities {
308        self.probe_capabilities_internal(self.capability_cache_policy, &[], Some(current_dir))
309            .await
310    }
311
312    pub(crate) async fn probe_capabilities_with_env_overrides_for_current_dir(
313        &self,
314        env_overrides: &BTreeMap<String, String>,
315        current_dir: &Path,
316    ) -> CodexCapabilities {
317        if env_overrides.is_empty() {
318            return self.probe_capabilities_for_current_dir(current_dir).await;
319        }
320
321        let env_overrides: Vec<(String, String)> = env_overrides
322            .iter()
323            .map(|(key, value)| (key.clone(), value.clone()))
324            .collect();
325
326        self.probe_capabilities_internal(
327            CapabilityCachePolicy::Bypass,
328            &env_overrides,
329            Some(current_dir),
330        )
331        .await
332    }
333
334    async fn probe_capabilities_internal(
335        &self,
336        cache_policy: CapabilityCachePolicy,
337        env_overrides: &[(String, String)],
338        current_dir: Option<&Path>,
339    ) -> CodexCapabilities {
340        let cache_key = capability_cache_key_for_current_dir_with_env(
341            self.command_env.binary_path(),
342            current_dir,
343            env_overrides,
344        );
345        let fingerprint = current_fingerprint(&cache_key);
346        let overrides = &self.capability_overrides;
347
348        let cache_reads_enabled = matches!(cache_policy, CapabilityCachePolicy::PreferCache)
349            && has_fingerprint_metadata(&fingerprint);
350        let cache_writes_enabled = !matches!(cache_policy, CapabilityCachePolicy::Bypass)
351            && has_fingerprint_metadata(&fingerprint);
352
353        if let Some(snapshot) = overrides.snapshot.clone() {
354            let capabilities = finalize_capabilities_with_overrides(
355                snapshot,
356                overrides,
357                cache_key.clone(),
358                fingerprint.clone(),
359                true,
360            );
361            if cache_writes_enabled {
362                update_capability_cache(capabilities.clone());
363            }
364            return capabilities;
365        }
366
367        if cache_reads_enabled {
368            if let Some(cached) = cached_capabilities(&cache_key, &fingerprint) {
369                if overrides.is_empty() {
370                    return cached;
371                }
372                let merged = finalize_capabilities_with_overrides(
373                    cached,
374                    overrides,
375                    cache_key.clone(),
376                    fingerprint.clone(),
377                    false,
378                );
379                if cache_writes_enabled {
380                    update_capability_cache(merged.clone());
381                }
382                return merged;
383            }
384        }
385
386        let probed = self
387            .probe_capabilities_uncached(
388                &cache_key,
389                fingerprint.clone(),
390                env_overrides,
391                current_dir,
392            )
393            .await;
394
395        let capabilities =
396            finalize_capabilities_with_overrides(probed, overrides, cache_key, fingerprint, false);
397
398        if cache_writes_enabled {
399            update_capability_cache(capabilities.clone());
400        }
401
402        capabilities
403    }
404
405    async fn probe_capabilities_uncached(
406        &self,
407        cache_key: &CapabilityCacheKey,
408        fingerprint: Option<BinaryFingerprint>,
409        env_overrides: &[(String, String)],
410        current_dir: Option<&Path>,
411    ) -> CodexCapabilities {
412        let mut plan = CapabilityProbePlan::default();
413        let mut features = CodexFeatureFlags::default();
414        let mut version = None;
415
416        plan.steps.push(CapabilityProbeStep::VersionFlag);
417        match self
418            .run_basic_command_with_env_overrides_and_current_dir(
419                ["--version"],
420                env_overrides,
421                current_dir,
422            )
423            .await
424        {
425            Ok(output) => {
426                if !output.status.success() {
427                    warn!(
428                        status = ?output.status,
429                        binary = ?cache_key.binary_path,
430                        "codex --version exited non-zero"
431                    );
432                }
433                let text = command_output_text(&output);
434                if !text.trim().is_empty() {
435                    version = Some(version::parse_version_output(&text));
436                }
437            }
438            Err(error) => warn!(
439                ?error,
440                binary = ?cache_key.binary_path,
441                "codex --version probe failed"
442            ),
443        }
444
445        let mut parsed_features = false;
446
447        plan.steps.push(CapabilityProbeStep::FeaturesListJson);
448        match self
449            .run_basic_command_with_env_overrides_and_current_dir(
450                ["features", "list", "--json"],
451                env_overrides,
452                current_dir,
453            )
454            .await
455        {
456            Ok(output) => {
457                if !output.status.success() {
458                    warn!(
459                        status = ?output.status,
460                        binary = ?cache_key.binary_path,
461                        "codex features list --json exited non-zero"
462                    );
463                }
464                if output.status.success() {
465                    features.supports_features_list = true;
466                }
467                let text = command_output_text(&output);
468                if let Some(parsed) = version::parse_features_from_json(&text) {
469                    version::merge_feature_flags(&mut features, parsed);
470                    parsed_features = version::detected_feature_flags(&features);
471                } else if !text.is_empty() {
472                    let parsed = version::parse_features_from_text(&text);
473                    version::merge_feature_flags(&mut features, parsed);
474                    parsed_features = version::detected_feature_flags(&features);
475                }
476            }
477            Err(error) => warn!(
478                ?error,
479                binary = ?cache_key.binary_path,
480                "codex features list --json probe failed"
481            ),
482        }
483
484        if !parsed_features {
485            plan.steps.push(CapabilityProbeStep::FeaturesListText);
486            match self
487                .run_basic_command_with_env_overrides_and_current_dir(
488                    ["features", "list"],
489                    env_overrides,
490                    current_dir,
491                )
492                .await
493            {
494                Ok(output) => {
495                    if !output.status.success() {
496                        warn!(
497                            status = ?output.status,
498                            binary = ?cache_key.binary_path,
499                            "codex features list exited non-zero"
500                        );
501                    }
502                    if output.status.success() {
503                        features.supports_features_list = true;
504                    }
505                    let text = command_output_text(&output);
506                    let parsed = version::parse_features_from_text(&text);
507                    version::merge_feature_flags(&mut features, parsed);
508                }
509                Err(error) => warn!(
510                    ?error,
511                    binary = ?cache_key.binary_path,
512                    "codex features list probe failed"
513                ),
514            }
515        }
516
517        if version::should_run_help_fallback(&features) {
518            plan.steps.push(CapabilityProbeStep::HelpFallback);
519            match self
520                .run_basic_command_with_env_overrides_and_current_dir(
521                    ["--help"],
522                    env_overrides,
523                    current_dir,
524                )
525                .await
526            {
527                Ok(output) => {
528                    if !output.status.success() {
529                        warn!(
530                            status = ?output.status,
531                            binary = ?cache_key.binary_path,
532                            "codex --help exited non-zero"
533                        );
534                    }
535                    let text = command_output_text(&output);
536                    let parsed = version::parse_help_output(&text);
537                    version::merge_feature_flags(&mut features, parsed);
538                }
539                Err(error) => warn!(
540                    ?error,
541                    binary = ?cache_key.binary_path,
542                    "codex --help probe failed"
543                ),
544            }
545        }
546
547        CodexCapabilities {
548            cache_key: cache_key.clone(),
549            fingerprint,
550            version,
551            features,
552            probe_plan: plan,
553            collected_at: SystemTime::now(),
554        }
555    }
556
557    /// Computes an update advisory by comparing the probed Codex version against
558    /// caller-supplied latest releases.
559    ///
560    /// The crate does not fetch release metadata itself; hosts should populate
561    /// [`CodexLatestReleases`] using their preferred update channel (npm,
562    /// Homebrew, GitHub releases) and then call this helper. Results leverage
563    /// the capability probe cache; callers with an existing
564    /// [`CodexCapabilities`] snapshot can skip the probe by invoking
565    /// [`update_advisory_from_capabilities`].
566    pub async fn update_advisory(
567        &self,
568        latest_releases: &CodexLatestReleases,
569    ) -> CodexUpdateAdvisory {
570        let capabilities = self.probe_capabilities().await;
571        update_advisory_from_capabilities(&capabilities, latest_releases)
572    }
573}
574
575impl Default for CodexClient {
576    fn default() -> Self {
577        CodexClient::builder().build()
578    }
579}
580
581#[cfg(all(test, unix))]
582mod tests;