diff --git a/codex-rs/README.md b/codex-rs/README.md index e7dfadb789..ea725a8fb5 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -26,6 +26,8 @@ The Rust implementation is now the maintained Codex CLI and serves as the defaul Codex supports a rich set of configuration options. Note that the Rust CLI uses `config.toml` instead of `config.json`. See [`docs/config.md`](../docs/config.md) for details. +Project-local overrides: When running Codex inside a project that contains a `.codex/` directory at the repository root (current working directory), Codex will use that directory as `CODEX_HOME`. This means files like `config.toml`, `config.json`, `auth.json`, and history will be read from and written to the project’s `.codex/` instead of the global `~/.codex/`. If no project-local `.codex/` exists, Codex falls back to `CODEX_HOME` (when set) or `~/.codex/`. + ### Model Context Protocol Support #### MCP client diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 0af01f6857..fbf35d8d76 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -1497,14 +1497,23 @@ fn default_review_model() -> String { /// - If `CODEX_HOME` is not set, this function does not verify that the /// directory exists. pub fn find_codex_home() -> std::io::Result { - // Honor the `CODEX_HOME` environment variable when it is set to allow users - // (and tests) to override the default location. + // First preference: project-local ./.codex directory, if it exists. + // This enables per-project overrides without requiring --config or env vars. + let cwd = std::env::current_dir()?; + let project_codex = cwd.join(".codex"); + if project_codex.is_dir() { + return Ok(project_codex); + } + + // Next: honor the `CODEX_HOME` environment variable when it is set to allow + // users (and tests) to override the default location. if let Ok(val) = std::env::var("CODEX_HOME") && !val.is_empty() { return PathBuf::from(val).canonicalize(); } + // Fallback: ~/.codex (do not require it to exist). let mut p = home_dir().ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::NotFound, diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index a077a92661..6a1b6b4c4e 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -49,6 +49,7 @@ struct StatusHistoryCell { model_name: String, model_details: Vec, directory: PathBuf, + home: PathBuf, approval: String, sandbox: String, agents_summary: String, @@ -114,6 +115,7 @@ impl StatusHistoryCell { model_name, model_details, directory: config.cwd.clone(), + home: config.codex_home.clone(), approval, sandbox, agents_summary, @@ -256,11 +258,17 @@ impl HistoryCell for StatusHistoryCell { } }); - let mut labels: Vec = - vec!["Model", "Directory", "Approval", "Sandbox", "Agents.md"] - .into_iter() - .map(str::to_string) - .collect(); + let mut labels: Vec = vec![ + "Model", + "Home", + "Directory", + "Approval", + "Sandbox", + "Agents.md", + ] + .into_iter() + .map(str::to_string) + .collect(); let mut seen: BTreeSet = labels.iter().cloned().collect(); if account_value.is_some() { @@ -286,8 +294,10 @@ impl HistoryCell for StatusHistoryCell { } let directory_value = format_directory_display(&self.directory, Some(value_width)); + let home_value = format_directory_display(&self.home, Some(value_width)); lines.push(formatter.line("Model", model_spans)); + lines.push(formatter.line("Home", vec![Span::from(home_value)])); lines.push(formatter.line("Directory", vec![Span::from(directory_value)])); lines.push(formatter.line("Approval", vec![Span::from(self.approval.clone())])); lines.push(formatter.line("Sandbox", vec![Span::from(self.sandbox.clone())])); diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap index cf3f492eef..c91025a259 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap @@ -8,7 +8,8 @@ expression: sanitized │ >_ OpenAI Codex (v0.0.0) │ │ │ │ Model: gpt-5-codex (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ +│ Home: [[codex_home]] │ +│ Directory: [[workspace]] │ │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap index 505375eae5..92621c1831 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap @@ -8,7 +8,8 @@ expression: sanitized │ >_ OpenAI Codex (v0.0.0) │ │ │ │ Model: gpt-5-codex (reasoning high, summaries detailed) │ -│ Directory: [[workspace]] │ +│ Home: [[codex_home]] │ +│ Directory: [[workspace]] │ │ Approval: on-request │ │ Sandbox: workspace-write │ │ Agents.md: │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap index 472df543d6..5d1cf79cfd 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap @@ -8,7 +8,8 @@ expression: sanitized │ >_ OpenAI Codex (v0.0.0) │ │ │ │ Model: gpt-5-codex (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ +│ Home: [[codex_home]] │ +│ Directory: [[workspace]] │ │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap index 66bcfa3e21..2ffb539f36 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap @@ -8,7 +8,8 @@ expression: sanitized │ >_ OpenAI Codex (v0.0.0) │ │ │ │ Model: gpt-5-codex (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ +│ Home: [[codex_home]] │ +│ Directory: [[workspace]] │ │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap index b7c595340d..5ea39b4be3 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap @@ -8,7 +8,8 @@ expression: sanitized │ >_ OpenAI Codex (v0.0.0) │ │ │ │ Model: gpt-5-codex (reasoning │ -│ Directory: [[workspace]] │ +│ Home: [[codex_home]] │ +│ Directory: [[workspace]] │ │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index b8c945eeec..9cb53f8702 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -39,25 +39,65 @@ fn render_lines(lines: &[Line<'static>]) -> Vec { .collect() } -fn sanitize_directory(lines: Vec) -> Vec { +fn sanitize_field( + line: String, + label: &str, + replacement: &str, + target_pipe_idx: Option, +) -> String { + let needle = format!("{label}:"); + let Some(label_pos) = line.find(&needle) else { + return line; + }; + let Some(pipe_idx) = line.rfind('│') else { + return line; + }; + + let search_start = label_pos + needle.len(); + let value_start = line[search_start..pipe_idx] + .char_indices() + .find(|(_, ch)| !ch.is_whitespace()) + .map(|(offset, _)| search_start + offset) + .unwrap_or(pipe_idx); + + let desired_pipe_idx = target_pipe_idx.unwrap_or(pipe_idx); + if value_start >= desired_pipe_idx { + return line; + } + + let value_space = desired_pipe_idx.saturating_sub(value_start); + if value_space == 0 { + return line; + } + + let mut replacement_segment = replacement.to_string(); + if replacement_segment.len() < value_space { + replacement_segment.push_str(&" ".repeat(value_space - replacement_segment.len())); + } else if replacement_segment.len() > value_space { + replacement_segment.truncate(value_space); + } + + let mut rebuilt = String::with_capacity( + desired_pipe_idx + line.len().saturating_sub(pipe_idx) + replacement_segment.len(), + ); + rebuilt.push_str(&line[..value_start]); + rebuilt.push_str(&replacement_segment); + rebuilt.push('│'); + let pipe_char_len = '│'.len_utf8(); + if pipe_idx + pipe_char_len <= line.len() { + rebuilt.push_str(&line[pipe_idx + pipe_char_len..]); + } + rebuilt +} + +fn sanitize_paths(lines: Vec) -> Vec { + let target_pipe_idx = lines.iter().filter_map(|line| line.rfind('│')).min(); + lines .into_iter() .map(|line| { - if let (Some(dir_pos), Some(pipe_idx)) = (line.find("Directory: "), line.rfind('│')) { - let prefix = &line[..dir_pos + "Directory: ".len()]; - let suffix = &line[pipe_idx..]; - let content_width = pipe_idx.saturating_sub(dir_pos + "Directory: ".len()); - let replacement = "[[workspace]]"; - let mut rebuilt = prefix.to_string(); - rebuilt.push_str(replacement); - if content_width > replacement.len() { - rebuilt.push_str(&" ".repeat(content_width - replacement.len())); - } - rebuilt.push_str(suffix); - rebuilt - } else { - line - } + let home = sanitize_field(line, "Home", "[[codex_home]]", target_pipe_idx); + sanitize_field(home, "Directory", "[[workspace]]", target_pipe_idx) }) .collect() } @@ -118,7 +158,7 @@ fn status_snapshot_includes_reasoning_details() { *line = line.replace('\\', "/"); } } - let sanitized = sanitize_directory(rendered_lines).join("\n"); + let sanitized = sanitize_paths(rendered_lines).join("\n"); assert_snapshot!(sanitized); } @@ -159,7 +199,7 @@ fn status_snapshot_includes_monthly_limit() { *line = line.replace('\\', "/"); } } - let sanitized = sanitize_directory(rendered_lines).join("\n"); + let sanitized = sanitize_paths(rendered_lines).join("\n"); assert_snapshot!(sanitized); } @@ -226,7 +266,7 @@ fn status_snapshot_truncates_in_narrow_terminal() { *line = line.replace('\\', "/"); } } - let sanitized = sanitize_directory(rendered_lines).join("\n"); + let sanitized = sanitize_paths(rendered_lines).join("\n"); assert_snapshot!(sanitized); } @@ -253,7 +293,7 @@ fn status_snapshot_shows_missing_limits_message() { *line = line.replace('\\', "/"); } } - let sanitized = sanitize_directory(rendered_lines).join("\n"); + let sanitized = sanitize_paths(rendered_lines).join("\n"); assert_snapshot!(sanitized); } @@ -289,7 +329,7 @@ fn status_snapshot_shows_empty_limits_message() { *line = line.replace('\\', "/"); } } - let sanitized = sanitize_directory(rendered_lines).join("\n"); + let sanitized = sanitize_paths(rendered_lines).join("\n"); assert_snapshot!(sanitized); } diff --git a/docs/authentication.md b/docs/authentication.md index 617161f648..3b707b02e4 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -23,7 +23,11 @@ This key must, at minimum, have write access to the Responses API. If you've used the Codex CLI before with usage-based billing via an API key and want to switch to using your ChatGPT plan, follow these steps: 1. Update the CLI and ensure `codex --version` is `0.20.0` or later -2. Delete `~/.codex/auth.json` (on Windows: `C:\\Users\\USERNAME\\.codex\\auth.json`) +2. Delete `$CODEX_HOME/auth.json`. + - `CODEX_HOME` resolution order: + 1) Project-local `.codex/` in the current working directory, if present. + 2) `CODEX_HOME` environment variable, if set. + 3) Default `~/.codex/` (on Windows: `C:\\Users\\USERNAME\\.codex\\auth.json`). 3. Run `codex login` again ## Connecting on a "Headless" Machine @@ -32,7 +36,8 @@ Today, the login process entails running a server on `localhost:1455`. If you ar ### Authenticate locally and copy your credentials to the "headless" machine -The easiest solution is likely to run through the `codex login` process on your local machine such that `localhost:1455` _is_ accessible in your web browser. When you complete the authentication process, an `auth.json` file should be available at `$CODEX_HOME/auth.json` (on Mac/Linux, `$CODEX_HOME` defaults to `~/.codex` whereas on Windows, it defaults to `%USERPROFILE%\\.codex`). +The easiest solution is likely to run through the `codex login` process on your local machine such that `localhost:1455` _is_ accessible in your web browser. When you complete the authentication process, an `auth.json` file should be available at `$CODEX_HOME/auth.json`. +Recall `CODEX_HOME` resolves to the project-local `.codex/` if present; otherwise to the `CODEX_HOME` env var; otherwise to the default user directory (on Mac/Linux: `~/.codex`, on Windows: `%USERPROFILE%\\.codex`). Because the `auth.json` file is not tied to a specific host, once you complete the authentication flow locally, you can copy the `$CODEX_HOME/auth.json` file to the headless machine and then `codex` should "just work" on that machine. Note to copy a file to a Docker container, you can do: diff --git a/docs/config.md b/docs/config.md index 4186c4ff34..a7718086e5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -22,7 +22,11 @@ Codex supports several mechanisms for setting config values: - If `value` cannot be parsed as a valid TOML value, it is treated as a string value. This means that `-c model='"o3"'` and `-c model=o3` are equivalent. - In the first case, the value is the TOML string `"o3"`, while in the second the value is `o3`, which is not valid TOML and therefore treated as the TOML string `"o3"`. - Because quotes are interpreted by one's shell, `-c key="true"` will be correctly interpreted in TOML as `key = true` (a boolean) and not `key = "true"` (a string). If for some reason you needed the string `"true"`, you would need to use `-c key='"true"'` (note the two sets of quotes). -- The `$CODEX_HOME/config.toml` configuration file where the `CODEX_HOME` environment value defaults to `~/.codex`. (Note `CODEX_HOME` will also be where logs and other Codex-related information are stored.) +- The `$CODEX_HOME/config.toml` configuration file. `CODEX_HOME` is resolved in this order: + 1) A project-local `.codex/` directory in the current working directory, if present. + 2) The `CODEX_HOME` environment variable, if set. + 3) The default `~/.codex/` directory. + (Note `CODEX_HOME` will also be where logs and other Codex-related information are stored.) Both the `--config` flag and the `config.toml` file support the following options: