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;