codex-wrapper 0.4.1

A type-safe Codex CLI wrapper for Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
# codex-wrapper

A type-safe Codex CLI wrapper for Rust.

[![Crates.io](https://img.shields.io/crates/v/codex-wrapper.svg)](https://crates.io/crates/codex-wrapper)
[![Documentation](https://docs.rs/codex-wrapper/badge.svg)](https://docs.rs/codex-wrapper)
[![CI](https://github.com/joshrotenberg/codex-wrapper/actions/workflows/ci.yml/badge.svg)](https://github.com/joshrotenberg/codex-wrapper/actions/workflows/ci.yml)
[![License](https://img.shields.io/crates/l/codex-wrapper.svg)](LICENSE-MIT)

## Overview

`codex-wrapper` provides a builder-pattern interface for invoking the
[Codex CLI](https://github.com/openai/codex) programmatically. It follows
the same design philosophy as
[`claude-wrapper`](https://crates.io/crates/claude-wrapper) and
[`docker-wrapper`](https://crates.io/crates/docker-wrapper): each CLI
subcommand is a builder struct that produces typed output.

## Installation

```bash
cargo add codex-wrapper
```

Requires the `codex` CLI to be installed and available in `PATH` (or
configured via `Codex::builder().binary()`).

## Quick Start

```rust
use codex_wrapper::{Codex, CodexCommand, ExecCommand, SandboxMode};

#[tokio::main]
async fn main() -> codex_wrapper::Result<()> {
    let codex = Codex::builder().build()?;
    let output = ExecCommand::new("explain this error")
        .model("o3")
        .sandbox(SandboxMode::WorkspaceWrite)
        .ephemeral()
        .execute(&codex)
        .await?;
    println!("{}", output.stdout);
    Ok(())
}
```

## Two-Layer Builder Architecture

The `Codex` client holds shared configuration (binary path, environment,
timeout, retry policy). Command builders hold per-invocation options and call
`execute(&codex)`.

### Codex Client

Configure once, reuse across commands:

```rust
use codex_wrapper::{Codex, RetryPolicy};

let codex = Codex::builder()
    .env("OPENAI_API_KEY", "sk-...")
    .timeout_secs(300)
    .retry(RetryPolicy::new().max_attempts(3).exponential())
    .build()?;
```

Options:
- `binary()` -- path to `codex` binary (auto-detected via `PATH` by default)
- `working_dir()` -- working directory for commands
- `env()` / `envs()` -- environment variables
- `clear_env()` -- clear inherited variables before applying `env()` / `envs()`
- `timeout_secs()` / `timeout()` -- command timeout
- `config()` -- global config overrides (`-c key=value`)
- `enable()` / `disable()` -- global feature flags
- `retry()` -- default retry policy

### Child Environment Policy

Children inherit the wrapper process's environment by default for backwards
compatibility. Use `clear_env()` to construct the direct Codex child's
environment explicitly:

```rust
use codex_wrapper::Codex;

let codex = Codex::builder()
    .clear_env()
    .envs([
        ("PATH", "/usr/local/bin:/usr/bin:/bin"),
        ("CODEX_HOME", "/srv/codex/agent-home"),
    ])
    .build()?;
```

`clear_env()` is call-order independent, so explicit entries survive whether
they are added before or after it. `config()` and `auth_status()` use the same
effective environment as the child and do not fall back to ambient variables
for a cleared client. Debug output reports explicit variable names but never
their values.

This is direct-child environment control, not OS or same-user isolation. It
does not prevent Codex from reading files, process metadata, sockets, or other
resources available to its user and sandbox.

### Command Builders

Each CLI subcommand is a separate builder:

| Command | CLI Equivalent | Description |
|---------|---------------|-------------|
| `ExecCommand` | `codex exec` | Run Codex non-interactively |
| `ExecResumeCommand` | `codex exec resume` | Resume a non-interactive session |
| `ReviewCommand` | `codex exec review` | Code review with git integration |
| `ResumeCommand` | `codex resume` | Resume an interactive session |
| `ForkCommand` | `codex fork` | Fork an interactive session |
| `LoginCommand` | `codex login` | Authenticate |
| `LoginStatusCommand` | `codex login status` | Check auth status |
| `LogoutCommand` | `codex logout` | Remove credentials |
| `McpListCommand` | `codex mcp list` | List MCP servers |
| `McpGetCommand` | `codex mcp get` | Get MCP server details |
| `McpAddCommand` | `codex mcp add` | Add stdio or HTTP MCP server |
| `McpRemoveCommand` | `codex mcp remove` | Remove MCP server |
| `McpLoginCommand` | `codex mcp login` | Auth to MCP server |
| `McpLogoutCommand` | `codex mcp logout` | Deauth from MCP server |
| `McpServerCommand` | `codex mcp-server` | Start Codex as MCP server |
| `SandboxCommand` | `codex sandbox` | Run command in sandbox |
| `ApplyCommand` | `codex apply` | Apply agent diff |
| `ArchiveCommand` | `codex archive` | Archive a saved session |
| `DeleteCommand` | `codex delete` | Permanently delete a session |
| `UnarchiveCommand` | `codex unarchive` | Restore an archived session |
| `DoctorCommand` | `codex doctor` | Diagnose local install health |
| `UpdateCommand` | `codex update` | Update Codex to the latest version |
| `PluginAddCommand` | `codex plugin add` | Install a plugin |
| `PluginListCommand` | `codex plugin list` | List available plugins |
| `PluginRemoveCommand` | `codex plugin remove` | Remove an installed plugin |
| `PluginMarketplaceAddCommand` | `codex plugin marketplace add` | Add a marketplace source |
| `PluginMarketplaceListCommand` | `codex plugin marketplace list` | List marketplace sources |
| `PluginMarketplaceUpgradeCommand` | `codex plugin marketplace upgrade` | Refresh Git marketplaces |
| `PluginMarketplaceRemoveCommand` | `codex plugin marketplace remove` | Remove a marketplace source |
| `CompletionCommand` | `codex completion` | Generate shell completions |
| `FeaturesListCommand` | `codex features list` | List feature flags |
| `FeaturesEnableCommand` | `codex features enable` | Enable a feature |
| `FeaturesDisableCommand` | `codex features disable` | Disable a feature |
| `VersionCommand` | `codex --version` | Get CLI version |
| `RawCommand` | *(any)* | Escape hatch for arbitrary args |

## ExecCommand

Full coverage of `codex exec` options:

```rust
use codex_wrapper::{ExecCommand, SandboxMode};

let output = ExecCommand::new("fix the failing tests")
    .model("o3")
    .sandbox(SandboxMode::WorkspaceWrite)
    .skip_git_repo_check()
    .ephemeral()
    .json()
    .execute(&codex)
    .await?;
```

| Method | CLI Flag | Description |
|--------|----------|-------------|
| `model()` | `--model` | Model to use |
| `sandbox()` | `--sandbox` | Sandbox policy |
| `strict_config()` | `--strict-config` | Error on unknown config keys |
| `profile()` | `--profile` | Config profile |
| `full_auto()` | `--sandbox workspace-write` | Deprecated shim; `sandbox()` wins |
| `approval_policy()` | `-c approval_policy=` | When the model asks for approval |
| `search()` / `search_mode()` | `-c web_search=` | Web search mode |
| `cd()` | `--cd` | Working directory |
| `skip_git_repo_check()` | `--skip-git-repo-check` | Run outside git repo |
| `add_dir()` | `--add-dir` | Additional writable dirs |
| `ignore_user_config()` | `--ignore-user-config` | Ignore user-level config |
| `ignore_rules()` | `--ignore-rules` | Ignore project rules files |
| *(see Dangerous Operations)* | `--dangerously-bypass-hook-trust` | Skip the hook trust prompt |
| `ephemeral()` | `--ephemeral` | Don't persist session |
| `output_schema()` | `--output-schema` | JSON Schema for response |
| `color()` | `--color` | Color output mode |
| `json()` | `--json` | JSONL event output |
| `output_last_message()` | `--output-last-message` | Write last message to file |
| `image()` | `--image` | Attach image(s) |
| `config()` | `-c` | Config override |
| `enable()` / `disable()` | `--enable` / `--disable` | Feature flags |
| `oss()` | `--oss` | Use local OSS provider |
| `local_provider()` | `--local-provider` | Specify lmstudio/ollama |
| `approve_for_me()` | `--approve-for-me` | Route approvals through automatic review (needs 0.147.0) |
| `prompt_via_stdin()` | *(prompt becomes `-`)* | Send the prompt on stdin |
| `retry()` | *(client-side)* | Per-command retry policy |

### Prompts on stdin

For prompts too large or awkward for argv, `ExecCommand::from_stdin` and
`ExecResumeCommand::from_stdin` send the prompt on the child's stdin and emit `-` in its argv:

```rust
use codex_wrapper::{CodexCommand, ExecCommand, ExecResumeCommand};

let patch = std::fs::read_to_string("huge.patch")?;
let output = ExecCommand::from_stdin(format!("Review this patch:\n{patch}"))
    .execute(&codex)
    .await?;

let resumed = ExecResumeCommand::from_stdin("Now summarize the risky parts")
    .session_id("thread-id")
    .execute(&codex)
    .await?;
```

Retry does not apply to a stdin prompt. Any policy set on the command or the client is ignored
for it: a second attempt would need to write the prompt into a pipe the first has already
consumed, and retrying with an empty stdin would be worse than not retrying.

## Typed Result

Use `execute_json()` for a typed `QueryResult` summarizing the run, assembled
from the JSONL event stream. This mirrors `claude-wrapper`'s `QueryResult` so a
downstream abstraction can treat both wrappers uniformly:

```rust
use codex_wrapper::ExecCommand;

let result = ExecCommand::new("what is 2+2?")
    .ephemeral()
    .execute_json(&codex)
    .await?;

println!("{}", result.result);
println!("thread: {:?}", result.thread_id);
println!("tokens: {:?}", result.usage.and_then(|u| u.total()));
```

`QueryResult` fields: `result`, `session_id`, `thread_id`, `usage`, and the
full `events` stream as an escape hatch.

### Typed accessors on events

`JsonLineEvent` keeps every field in `extra`, with accessors for the ones worth naming:
`session_id()`, `thread_id()`, `is_turn_completed()`, `is_turn_failed()`, `usage()`,
`agent_message_text()`, `role()`, `content_text()`, `item_type()`, and `command_execution()`.

**There are no incremental text deltas to consume.** Three captured runs, a one-word exec, a
four-sentence exec, and a review, each delivered the whole assistant message in a single
`item.completed`. The CLI emits no `item.updated` and no partial fields, and no flag changes
that. So this crate has no equivalent of `claude-wrapper`'s `PartialMessageEvent`: streaming here
delivers whole events as they arrive, not partial sentences.

The CLI reports **token counts, not money**. There is no cost field to read.
Converting tokens to dollars needs a per-model price table the CLI does not
provide, so this crate does not guess at one.

## JSONL Output Parsing

Use `execute_json_lines()` to parse the raw structured events from `--json`
mode. Available on both `ExecCommand` and `ExecResumeCommand`:

```rust
use codex_wrapper::ExecCommand;

let events = ExecCommand::new("what is 2+2?")
    .ephemeral()
    .execute_json_lines(&codex)
    .await?;

for event in &events {
    println!("{}: {:?}", event.event_type, event.extra);
}
```

### Typed Accessors

`JsonLineEvent` provides convenience methods for common fields:

```rust
for event in &events {
    if let Some(id) = event.thread_id() {
        println!("thread: {id}");
    }
    if event.is_turn_completed() {
        println!("tokens: {:?}", event.usage().and_then(|u| u.total()));
    }
    if let Some(text) = event.agent_message_text() {
        println!("assistant: {text}");
    }
}
```

Available accessors: `session_id()`, `thread_id()`, `is_turn_completed()`,
`is_turn_failed()`, `usage()`, `agent_message_text()`, `role()`,
`content_text()`.

The event vocabulary is `thread.started`, `turn.started`, `turn.completed`,
`turn.failed`, `item.started`, `item.updated`, `item.completed`. See the
schema notes in the `types` module docs for which parts of the payload layout
are verified against the CLI and which are assumed.

## Streaming

Stream JSONL events via a callback as they arrive, instead of buffering
all output:

```rust
use codex_wrapper::{Codex, ExecCommand, JsonLineEvent};

let codex = Codex::builder().build()?;

ExecCommand::new("explain this codebase")
    .ephemeral()
    .stream(&codex, |event: JsonLineEvent| {
        println!("{}: {:?}", event.event_type, event.extra);
    })
    .await?;
```

Also available on `ExecResumeCommand::stream()`. The child process's stderr
is drained concurrently; timeout handling mirrors the buffered exec path.

## Multi-Turn Sessions

`Session` manages conversation state across turns automatically. The first
call dispatches via `ExecCommand`; subsequent calls use `ExecResumeCommand`
with the captured `thread_id`:

```rust
use std::sync::Arc;
use codex_wrapper::{Codex, Session};

let codex = Arc::new(Codex::builder().build()?);
let mut session = Session::new(codex);

let events = session.send("create a hello world program").await?;
println!("turn 1: {} events", events.len());

let events = session.send("now add error handling").await?;
println!("turn 2: {} events, thread_id={:?}", events.len(), session.id());
```

You can also resume an existing session by thread ID:

```rust
let mut session = Session::resume(codex, "thread_abc123");
let events = session.send("continue where we left off").await?;
```

The `thread_id` is preserved even on error paths, as long as at least one
event carried it.

### Token usage

Each turn records a typed `QueryResult`, so cost accumulates across the
session:

```rust
session.send("first").await?;
session.send("second").await?;

println!("{} turns, {} tokens", session.total_turns(), session.total_tokens());

if let Some(result) = session.last_result() {
    println!("last turn: {:?}", result.usage);
}
```

The CLI does not always report usage. `total_tokens()` sums what was reported,
so a total of `0` can mean either "nothing was used" or "nothing was
reported". `turns_missing_usage()` tells those apart:

```rust
if session.turns_missing_usage() > 0 {
    eprintln!("token total is an undercount: {} turns reported none",
              session.turns_missing_usage());
}
```

### Streaming turns

`stream()` is the streaming equivalent of `send()`. Events reach the handler as
the CLI emits them, and the session still captures `thread_id`, history, and
cost, so a streaming turn and a buffered turn leave identical state:

```rust
session.stream("summarize this repo", |event| {
    println!("{}", event.event_type);
}).await?;

assert_eq!(session.total_turns(), 1);
```

`stream_execute()` and `stream_execute_resume()` take a fully configured
command, mirroring `execute()` and `execute_resume()`.

## Code Review

```rust
use codex_wrapper::ReviewCommand;

// Review uncommitted changes
let output = ReviewCommand::new()
    .uncommitted()
    .model("o3")
    .execute(&codex)
    .await?;

// Review against a base branch
let output = ReviewCommand::new()
    .base("main")
    .json()
    .execute(&codex)
    .await?;

// Typed result, same shape ExecCommand returns
let result = ReviewCommand::new()
    .uncommitted()
    .execute_json(&codex)
    .await?;
println!("{}", result.result);
```

Review emits the same event vocabulary as `codex exec`, so `execute_json()` assembles the
review comments into `QueryResult::result`. One difference: a review's `turn.completed`
reports a usage object of all zeros, so `result.usage` carries no counts.

## MCP Server Management

```rust
use codex_wrapper::{McpListCommand, McpAddCommand, McpRemoveCommand};

// List servers
let output = McpListCommand::new().execute(&codex).await?;

// List as JSON
let servers = McpListCommand::new().execute_json(&codex).await?;

// Add stdio server
McpAddCommand::stdio("my-tool", "npx")
    .arg("my-mcp-server")
    .env("API_KEY", "secret")
    .execute(&codex)
    .await?;

// Add HTTP server
McpAddCommand::http("sentry", "https://mcp.sentry.dev/mcp")
    .bearer_token_env_var("SENTRY_TOKEN")
    .execute(&codex)
    .await?;

// Remove server
McpRemoveCommand::new("old-server").execute(&codex).await?;
```

## Per-run MCP Servers

`mcp add` mutates persistent config, which is the wrong tool for a host running isolated
invocations: a cancelled run leaves residue and concurrent runs race. `McpConfigBuilder` scopes a
server set to one run instead:

```rust
use codex_wrapper::{ExecCommand, McpConfigBuilder, McpServerConfig};

let mcp = McpConfigBuilder::new()
    .server("files", McpServerConfig::stdio("npx").arg("-y").arg("server"))
    .server("docs", McpServerConfig::http("https://example.com/mcp")
        .env_http_header("X-Identity", "IDENTITY_TOKEN")
        .required());

let mut cmd = ExecCommand::new("summarize the docs");
for override_ in mcp.config_overrides() {
    cmd = cmd.config(override_);
}
```

**These are `-c` overrides, not a config file.** codex has no `--mcp-config` flag; the only
config-bearing options are `-c`, `--profile`, and `--ignore-user-config`. Overrides suit the
purpose better anyway: nothing is written, nothing survives a cancelled run, and two runs cannot
collide. A contract check feeds the builder's real output to the CLI so the generated forms
cannot drift.

For the cases that do want a file, `to_toml()` and `write_profile()` produce a
`$CODEX_HOME/<name>.config.toml` that `--profile` layers. That one is persistent.

`bearer_token_env_var()` and `env_http_header()` record only environment-variable names, never
the secret values. `required()` makes an unavailable server fail the run instead of silently
continuing without a capability the caller expected.

## Sandbox Execution

Run commands inside the Codex sandbox:

The platform is auto-detected (Seatbelt on macOS, and so on); as of
`codex-cli` 0.145.0 the old `<macos|linux|windows>` positional was removed.

```rust
use codex_wrapper::SandboxCommand;

let output = SandboxCommand::new("ls")
    .arg("-la")
    .execute(&codex)
    .await?;
```

## Session Resume and Fork

```rust
use codex_wrapper::{ResumeCommand, ForkCommand};

// Resume the most recent interactive session
ResumeCommand::new()
    .last()
    .model("o3")
    .execute(&codex)
    .await?;

// Fork a session to try a different approach
ForkCommand::new()
    .session_id("abc-123")
    .prompt("try a different approach")
    .execute(&codex)
    .await?;
```

## Shell Completions

```rust
use codex_wrapper::{CompletionCommand, Shell};

let output = CompletionCommand::new()
    .shell(Shell::Zsh)
    .execute(&codex)
    .await?;
std::fs::write("_codex", &output.stdout)?;
```

## Feature Flags

```rust
use codex_wrapper::{FeaturesListCommand, FeaturesEnableCommand, FeaturesDisableCommand};

// List all feature flags
FeaturesListCommand::new().execute(&codex).await?;

// Enable/disable features persistently
FeaturesEnableCommand::new("web-search").execute(&codex).await?;
FeaturesDisableCommand::new("web-search").execute(&codex).await?;
```

## CLI Version

This wrapper is tested against a declared range of `codex-cli` versions. Both
ends of the range run the flag-contract check in CI, so the range reflects what
is actually verified rather than what is hoped.

```rust
use codex_wrapper::{CliVersionStatus, TESTED_CLI_VERSION_MIN, TESTED_CLI_VERSION_MAX};

// Report, do not fail. Warns via `tracing` when outside the range.
match codex.cli_version_status().await? {
    CliVersionStatus::Tested => {}
    CliVersionStatus::NewerUntested { found, tested_max } => {
        eprintln!("codex {found} is newer than tested ({tested_max})");
    }
    CliVersionStatus::OlderThanMinimum { found, minimum } => {
        eprintln!("codex {found} is older than tested ({minimum})");
    }
}
```

Most CLI releases break nothing, so this reports by default rather than
refusing to run. When you do want a hard gate:

```rust
// Returns Error::UntestedCliVersion when outside the range.
let version = codex.ensure_tested_cli_version().await?;
```

This is a method rather than a builder option because `build()` is synchronous
and never spawns the binary.

Override the range with `CodexBuilder::tested_cli_version_range(min, max)` if
you have validated a different one yourself. `check_version(&minimum)` remains
available for a plain minimum-version gate.

## Error Handling

Failed commands are classified at the error boundary, so a caller branches on the class instead
of substring-matching stderr:

```rust
use codex_wrapper::{ExecCommand, CodexCommand, Error, FailureKind};

match ExecCommand::new("test").execute(&codex).await {
    Ok(output) => println!("{}", output.stdout),
    Err(e) => match e.failure_kind() {
        Some(FailureKind::Auth) => eprintln!("re-authenticate and retry"),
        Some(FailureKind::NotTrustedDirectory) => eprintln!("run in a git repo, or skip the check"),
        Some(FailureKind::Config) => eprintln!("fix the config: {e}"),
        Some(FailureKind::SessionNotFound) => eprintln!("start a new session"),
        _ => eprintln!("{e}"),
    },
}
```

Classification reads stderr, because it has to: every failure observed on `codex-cli` 0.145.0
exits 1, so the exit code carries no information. Each signature comes from a captured failing
run. A failure matching none of them stays `Error::CommandFailed` with its output intact, so
classification never loses anything.

The classified failures are deterministic, so they are never retried even when their exit code is
on a retry policy's list. Re-running gets the same rejection, and the CLI has already retried the
auth case internally before it surfaces.

All commands return `Result<T>`, with errors typed via `thiserror`:

```rust
use codex_wrapper::{ExecCommand, Error};

match ExecCommand::new("test").execute(&codex).await {
    Ok(output) => println!("{}", output.stdout),
    Err(Error::CommandFailed { stderr, exit_code, .. }) => {
        eprintln!("failed (exit {}): {}", exit_code, stderr);
    }
    Err(Error::Timeout { .. }) => eprintln!("timed out"),
    Err(Error::NotFound) => eprintln!("codex binary not in PATH"),
    Err(e) => eprintln!("{e}"),
}
```

## Dangerous Operations

`--dangerously-bypass-approvals-and-sandbox` turns off every approval prompt and the sandbox;
`--dangerously-bypass-hook-trust` lets configured hooks run unconfirmed. Neither is a plain
builder method, because a method name is not a barrier: it can be reached by autocomplete, by a
copied snippet, or by an agent editing a call site.

Both need two things that cannot happen by accident together:

```rust
use codex_wrapper::{CodexCommand, ExecCommand};
use codex_wrapper::dangerous::{Dangerous, DangerousClient};

// Errors unless CODEX_WRAPPER_ALLOW_DANGEROUS is set in the environment.
let allow = DangerousClient::new()?;

let output = ExecCommand::new("rewrite everything")
    .bypass_approvals_and_sandbox(&allow)?   // re-checks the variable here
    .execute(&codex)
    .await?;
```

The second check is not redundant: a client built while the variable was set stops working the
moment it is unset, so permission reflects the environment when the bypass is applied rather than
whenever the client happened to be created. Without it, the call returns
`Error::DangerousNotAllowed`.

The variable name matches `claude-wrapper`'s `CLAUDE_WRAPPER_ALLOW_DANGEROUS`.

## Previewing the Command

Every builder can render the exact command line it will spawn, without spawning it:

```rust
use codex_wrapper::{Codex, CodexCommand, ExecCommand};

let codex = Codex::builder().config("model=\"gpt-5\"").build()?;
let cmd = ExecCommand::new("fix the failing tests").ephemeral();

println!("{}", cmd.to_command_string(&codex));
// codex -c 'model="gpt-5"' exec --ephemeral 'fix the failing tests'
```

Global args precede the subcommand, the same order the spawn uses, because the preview and both
spawn paths share one assembly function. The output is quoted for a POSIX shell so it can be
pasted, but no shell is involved at spawn time: args go to the process directly.

## Reading config.toml

Behind the optional `config` feature, since it pulls in a TOML parser:

```toml
codex-wrapper = { version = "0.2", features = ["config"] }
```

```rust
if let Some(config) = codex.config()? {
    println!("model:    {:?}", config.model);
    println!("profiles: {:?}", config.profiles);
    println!("anything else: {:?}", config.raw.get("personality"));
}
```

This matters more than it used to: `approval_policy` and `web_search` moved from `exec` flags to
config keys in 0.145.0, so config is where some behavior is now decided.

Only the keys the wrapper has a reason to know about are typed (`model`, `approval_policy`,
`sandbox_mode`, `web_search`, `[features]`, `[projects]` trust levels). Everything else stays in
`raw`, because modelling the whole file would mean tracking a schema that changes every release.

**Profiles are files, not a table.** `--profile <name>` layers `$CODEX_HOME/<name>.config.toml`
over the base config, so `profiles` lists those files. A `[profiles]` table is the legacy
mechanism the CLI no longer writes; it is reported separately as `legacy_profiles` rather than
mixed in.

A missing `config.toml` is `Ok(None)`, not an error. A malformed one is `Error::ConfigParse`.

## Session History

Read-side access to the logs the CLI writes under `$CODEX_HOME/sessions`:

```rust
use codex_wrapper::history::{self, SessionQuery};

for session in history::list(&SessionQuery::new().after(2026, 8, 1))? {
    let log = history::read(&session.path)?;
    println!("{} in {:?}", session.id, log.meta.and_then(|m| m.cwd));
}
```

Read-only; `ArchiveCommand` and `DeleteCommand` cover mutation through the CLI. Date filters are
cheap because the layout is `sessions/<YYYY>/<MM>/<DD>/`, so they narrow directories without
opening files. A `cwd` filter has to read each candidate's first line.

**Two envelope generations.** Modern files wrap every line as
`{timestamp, type, payload}`. Older ones have no envelope at all: the first line *is* the
metadata and later lines are bare records. Both were present on the machine this was written on,
205 files with 7 still carrying legacy lines, so a reader that assumed the current shape would
return nothing for part of a real history. `SessionEntry::entry_type` is `None` for a legacy
line, and every `SessionMeta` field is optional because older files record no `cwd` and no
`cli_version`.

Unknown entry types are kept rather than dropped, with the payload available as raw JSON.

## Auth Pre-flight

Check which credential the CLI would use, synchronously, without spawning it:

```rust
use codex_wrapper::{Codex, AuthStrategy};

let codex = Codex::builder().build()?;
let status = codex.auth_status();

match &status.strategy {
    AuthStrategy::None => eprintln!("no credentials; run `codex login`"),
    AuthStrategy::Mixed { .. } => eprintln!("stored login and env key both set; the env key wins"),
    other => println!("will authenticate via {other:?}"),
}
```

Useful for health endpoints and for failing fast instead of on an opaque non-zero exit. It answers
a different question from `LoginStatusCommand`, which asks the CLI whether a stored credential is
still valid; this asks which one the CLI would pick. Keep both.

Nothing here reads or returns a credential value. Environment variables are reported by name and
stored credentials by mode.

The resolution was read off `codex doctor` on 0.145.0 rather than assumed, including the case
where both a stored login and an env key are present, which the CLI itself flags as "mixed auth
signals" and resolves in favour of the environment key.

## Native Rollout Budgets

Bound one `codex exec` process with Codex's native rollout budget:

```rust
use codex_wrapper::{ExecCommand, ExecResumeCommand, RolloutBudgetConfig};

let budget = RolloutBudgetConfig::builder(200_000)
    .reminder_at_remaining_tokens([100_000, 25_000])
    .sampling_token_weight(1.0)
    .prefill_token_weight(0.25)
    .build()?;

let opening = ExecCommand::new("implement the change")
    .rollout_budget(budget.clone());
let resumed = ExecResumeCommand::new()
    .session_id("thread-id")
    .prompt("continue")
    .rollout_budget(budget);
```

The limit is native rollout-budget units, not portable total tokens. Codex 0.145 and 0.146 always
count output tokens times `sampling_token_weight` plus non-cached input tokens times
`prefill_token_weight`. Starting with Codex 0.147, a provider-reported
`codex_rollout_budget_units` value takes precedence when available; the weighted formula remains
the fallback. Cached input is excluded from that fallback. Provider-reported units may be opaque,
so hosts must not assume that a CLI upgrade preserves identical meter semantics. Enforcement
happens after each completed response, so one response can overshoot the configured limit.
Subagents within that CLI execution share the same budget.

The wrapper emits the typed table after raw config and suppresses conflicting `rollout_budget`
feature toggles from both the command and its client. Codex applies feature toggles after every
config override regardless of argv order, so retaining one would silently disable or replace the
table. A separate CLI process, including a later resume, needs the config again. This is why both
exec builders expose the same method.

## Post-turn Token Budgets

Cap what a session may consume. The ceiling is in tokens, not dollars, because the CLI reports
token counts and no monetary cost:

```rust
use std::sync::Arc;
use codex_wrapper::{Codex, Session, TokenBudget};

let budget = TokenBudget::builder()
    .max_tokens(200_000)
    .warn_at_tokens(150_000)
    .on_warning(|total| eprintln!("at {total} tokens"))
    .build();

let mut session = Session::new(Arc::new(codex)).with_budget(budget.clone());
```

Each turn's reported usage is added to the budget, and a turn is refused with
`Error::TokenBudgetExceeded` once the ceiling is reached. The check runs before a turn starts, so
the ceiling can be overshot by at most the turn that crosses it: usage is only known once spent.
`TokenBudget` is `Clone` over shared state, so one budget can span several sessions.

Two things a budget cannot see, both worth knowing before relying on one:

- A `turn.completed` with no `usage` object contributes nothing. `turns_missing_usage()` counts
  those separately, so an unmeasured turn is distinguishable from a genuinely cheap one.
- A review reports usage as all zeros, so a session of reviews never advances the budget.

Treat the total as a floor on consumption rather than an exact measure.

## Tracing

Every invocation is wrapped in a `tracing` span. Nothing is emitted unless the host installs a
subscriber, and there is no separate metrics abstraction: `tracing` is the seam, and hosts that
want metrics bridge it themselves.

| Span | Raised by |
|---|---|
| `codex.exec` | any buffered run, one per attempt |
| `codex.stream` | a streaming run, closing when the stream ends |
| `codex.retry` | a retried run, parent to each attempt's span |

Fields on open: `subcommand`, `binary`, `working_dir`. On close: `outcome`, `duration_ms`, and
`exit_code` where the process produced one.

`outcome` distinguishes four endings, including the two that are easy to miss:

| `outcome` | Meaning |
|---|---|
| `ok` | exited zero |
| `failed` | exited non-zero |
| `timeout` | hit the client's timeout |
| `cancelled` | explicit cancellation settled, or the future was dropped and the run abandoned |

**The prompt is never recorded.** It travels in argv, so recording the arguments would put it in
the host's logs; the same goes for the environment. Note that the existing `debug!` line does log
the full argv, prompt included, as a debugging aid at that level.

## Cancellation

Use the cancellable exec methods when terminal settlement matters. They accept a future that
resolves when cancellation is requested. The wrapper sends SIGTERM to the Unix process group,
waits for `termination_grace`, sends SIGKILL, and awaits the direct child before returning
`Error::Cancelled`. Buffered client timeouts use the same cleanup path.

```rust
use codex_wrapper::{Codex, ExecCommand};
use std::time::Duration;

let codex = Codex::builder()
    .termination_grace(Duration::from_secs(5))
    .build()?;

// SIGTERM to the group, then five seconds, then SIGKILL.
// The direct child has been reaped when this future returns.
let result = ExecCommand::new("long task")
    .execute_json_cancellable(&codex, async { shutdown.await })
    .await;
```

`ExecCommand` and `ExecResumeCommand` both provide `execute_cancellable()`,
`execute_json_lines_cancellable()`, and `execute_json_cancellable()`. Stdin prompts use the same
path. Retry does not apply because a cancelled attempt is a caller decision, not a transient
failure.

Dropping an ordinary command future remains an abrupt safety net. `kill_on_drop` stops the direct
child and a drop guard kills the Unix process group, but `Drop` cannot await reaping. A supervisor
that needs settlement should signal the cancellable method and keep polling it until it returns.

Groups are on by default, and can be turned off:

```rust
let codex = Codex::builder().process_group(false).build()?;
```

Off, the child shares the parent's group, so a terminal Ctrl-C reaches the whole run directly and
a wrapper-side cancel reaches only the direct child. That is the right contract for a
terminal-attached host that shells out synchronously and treats the terminal as the supervisor;
the default suits a supervisor that cancels programmatically. `claude-wrapper` has the same
option under the same name.

Process groups are Unix-only. Elsewhere explicit cancellation kills and awaits the direct child,
but the wrapper cannot make the same descendant-process guarantee. Reaping also needs the Tokio
runtime to remain alive; the abrupt dropped-future fallback cannot settle during runtime teardown.

## Retry Policy

Configure automatic retries for transient failures:

```rust
use codex_wrapper::{Codex, ExecCommand, RetryPolicy};
use std::time::Duration;

let policy = RetryPolicy::new()
    .max_attempts(5)
    .initial_backoff(Duration::from_secs(2))
    .exponential()
    .retry_on_timeout(true)
    .retry_on_exit_codes([1, 2]);

// Set on the client (applies to all commands)
let codex = Codex::builder().retry(policy).build()?;

// Or override per-command
let output = ExecCommand::new("flaky task")
    .retry(RetryPolicy::new().max_attempts(10))
    .execute(&codex)
    .await?;
```

## Escape Hatch: RawCommand

For subcommands or flags not yet covered by typed builders:

```rust
use codex_wrapper::RawCommand;

let output = RawCommand::new("cloud")
    .arg("--json")
    .execute(&codex)
    .await?;
```

## Cargo Features

| Feature | Default | Description |
|---------|---------|-------------|
| `json` | Yes | JSONL output parsing via `serde_json` -- enables `execute_json_lines()`, `execute_json()`, `stream()`, `Session`, `QueryResult`, `JsonLineEvent` and typed accessors |
| `config` | No | Read `~/.codex/config.toml` via the `toml` crate -- enables `codex.config()` and the `config` module |

To disable default features:

```toml
[dependencies]
codex-wrapper = { version = "0.2", default-features = false }
```

## Examples

Runnable programs live in
[`crates/codex-wrapper/examples`](https://github.com/joshrotenberg/codex-wrapper/tree/main/crates/codex-wrapper/examples).
Each needs a working `codex` on `PATH`:

```bash
cargo run --example oneshot        # one prompt, raw output
cargo run --example json_output    # JSONL events and the typed QueryResult
cargo run --example stream_exec    # events delivered as they arrive
cargo run --example session        # multi-turn via exec resume
cargo run --example review         # code review, text and typed
cargo run --example mcp_servers    # add / list / inspect / remove MCP servers
cargo run --example health_check   # installed CLI vs the tested version range
```

All but `oneshot` and `health_check` need the `json` feature, which is on by default. Each is
declared with its `required-features` in the manifest, so a reduced-feature build skips it
rather than failing.

## Testing

```bash
cargo test --lib --all-features           # Unit tests (no CLI required)
cargo test --test integration -- --ignored # Integration tests (requires codex in PATH)
```

## CI and Release

GitHub Actions workflows handle CI (Linux, macOS, Windows), dependency
audits, changelog automation, and `release-plz`-driven crates.io releases.

## License

MIT OR Apache-2.0