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;