Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions codex-rs/app-server/src/mcp_refresh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ mod tests {
use codex_core::thread_store_from_config;
use codex_exec_server::EnvironmentManager;
use codex_extension_api::NoopExtensionEventSink;
use codex_home::CodexHomeUserInstructionsProvider;
use codex_home::user_instructions_provider;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_protocol::protocol::SessionSource;
Expand Down Expand Up @@ -490,9 +490,11 @@ enabled = false
queue_service: None,
turn_start_admission: None,
}),
Arc::new(CodexHomeUserInstructionsProvider::new(
user_instructions_provider(
good_config.codex_home.clone(),
)),
good_config.claude_compat_instructions,
good_config.project_doc_max_bytes,
),
/*analytics_events_client*/ None,
codex_core::passthrough_image_store(),
Arc::clone(&thread_store),
Expand Down
17 changes: 9 additions & 8 deletions codex-rs/app-server/src/message_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ use codex_exec_server::EnvironmentManager;
use codex_extension_api::TurnStartAdmission;
use codex_feedback::CodexFeedback;
use codex_goal_extension::GoalService;
use codex_home::CodexHomeUserInstructionsProvider;
use codex_home::user_instructions_provider;
use codex_login::AuthManager;
use codex_otel::auth_storage::AuthStorageOriginator;
use codex_protocol::ThreadId;
Expand Down Expand Up @@ -354,9 +354,11 @@ impl MessageProcessor {
queue_service: queue_service.clone(),
turn_start_admission: Some(Arc::clone(&turn_start_admission)),
}),
Arc::new(CodexHomeUserInstructionsProvider::new(
user_instructions_provider(
config.codex_home.clone(),
)),
config.claude_compat_instructions,
config.project_doc_max_bytes,
),
Some(analytics_events_client.clone()),
codex_core::passthrough_image_store(),
Arc::clone(&thread_store),
Expand All @@ -366,11 +368,10 @@ impl MessageProcessor {
outgoing.clone(),
thread_state_manager.clone(),
)),
Some({
let time_provider =
app_server_time_provider(outgoing.clone(), thread_state_manager.clone());
time_provider
}),
Some(app_server_time_provider(
outgoing.clone(),
thread_state_manager.clone(),
)),
);
match code_mode_session_provider {
Some(provider) => manager.with_code_mode_session_provider(provider),
Expand Down
8 changes: 5 additions & 3 deletions codex-rs/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ use codex_core::config::resolve_profile_v2_config_path;
use codex_features::FEATURES;
use codex_features::Stage;
use codex_features::is_known_feature_key;
use codex_home::CodexHomeUserInstructionsProvider;
use codex_home::user_instructions_provider;
use codex_login::AuthManager;
use codex_login::is_workload_identity_selected;
use codex_memories_write::clear_memory_roots_contents;
Expand Down Expand Up @@ -2054,9 +2054,11 @@ async fn run_debug_prompt_input_command(
});
}

let user_instructions_provider = Arc::new(CodexHomeUserInstructionsProvider::new(
let user_instructions_provider = user_instructions_provider(
config.codex_home.clone(),
));
config.claude_compat_instructions,
config.project_doc_max_bytes,
);
let auth_manager =
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await?;
let mut extensions = codex_extension_api::ExtensionRegistryBuilder::new();
Expand Down
4 changes: 3 additions & 1 deletion codex-rs/codex-home/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ workspace = true
[dependencies]
codex-extension-api = { workspace = true }
codex-utils-absolute-path = { workspace = true }
tokio = { workspace = true, features = ["fs"] }
dirs = { workspace = true }
sha2 = { workspace = true }
tokio = { workspace = true, features = ["fs", "macros", "rt"] }
tracing = { workspace = true }

[dev-dependencies]
Expand Down
123 changes: 123 additions & 0 deletions codex-rs/codex-home/src/instructions/claude_compat.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
//! Global user instructions with Claude Code compatibility.
//!
//! Fork-owned (WI-1131). Wraps [`CodexHomeUserInstructionsProvider`] and, when
//! enabled, appends `~/.claude/CLAUDE.md` and then `~/.claude/rules/*.md` in
//! sorted order. Both go through the shared `@import` expander, so a line that
//! is only `@path` is replaced by that file, to depth 5, deduplicated by path,
//! symlink and content, and counted against `project_doc_max_bytes`.

use std::path::PathBuf;
use std::sync::Arc;

use codex_extension_api::Instructions;
use codex_extension_api::LoadInstructionsFuture;
use codex_extension_api::LoadedUserInstructions;
use codex_extension_api::UserInstructionsProvider;
use codex_utils_absolute_path::AbsolutePathBuf;

use super::CodexHomeUserInstructionsProvider;
use super::imports::expand_imports_all;

const CLAUDE_DIR: &str = ".claude";
const CLAUDE_MD: &str = "CLAUDE.md";
const RULES_DIR: &str = "rules";

/// Loads the Codex home instructions plus the user's Claude Code files.
#[derive(Clone, Debug)]
pub struct ClaudeCompatUserInstructionsProvider {
inner: CodexHomeUserInstructionsProvider,
/// The user's home directory, where `~/.claude` lives.
user_home: Option<PathBuf>,
/// Bytes the appended Claude files may consume in total.
max_bytes: usize,
}

impl ClaudeCompatUserInstructionsProvider {
/// Creates a provider. `user_home` is the directory holding `.claude`; pass
/// `None` when it cannot be determined and only the Codex home is loaded.
pub fn new(codex_home: AbsolutePathBuf, user_home: Option<PathBuf>, max_bytes: usize) -> Self {
Self {
inner: CodexHomeUserInstructionsProvider::new(codex_home),
user_home,
max_bytes,
}
}

async fn load(&self) -> LoadedUserInstructions {
let mut loaded = self.inner.load_user_instructions().await;
let Some(user_home) = &self.user_home else {
return loaded;
};
let claude_dir = user_home.join(CLAUDE_DIR);
let mut paths = vec![claude_dir.join(CLAUDE_MD)];
if let Some(rules) = rule_files(&claude_dir.join(RULES_DIR)).await {
paths.extend(rules);
}
let expanded = expand_imports_all(&paths, Some(user_home), self.max_bytes, None).await;
loaded.warnings.extend(expanded.warnings);
if expanded.files.is_empty() {
return loaded;
}
let claude_text = expanded
.files
.iter()
.map(|file| format!("Contents of {}:\n\n{}", file.path.display(), file.text))
.collect::<Vec<_>>()
.join("\n\n");
let text = match loaded.instructions.take() {
Some(existing) => format!("{}\n\n{claude_text}", existing.text),
None => claude_text,
};
loaded.instructions = Some(Instructions {
text,
// The snapshot now spans several files, so no single source path
// describes it.
source: None,
});
loaded
}
}

impl UserInstructionsProvider for ClaudeCompatUserInstructionsProvider {
fn load_user_instructions(&self) -> LoadInstructionsFuture<'_> {
Box::pin(async move { self.load().await })
}
}

/// `*.md` files directly inside `dir`, sorted by name. `None` when the
/// directory does not exist.
async fn rule_files(dir: &std::path::Path) -> Option<Vec<PathBuf>> {
let mut entries = match tokio::fs::read_dir(dir).await {
Ok(entries) => entries,
Err(_) => return None,
};
let mut files = Vec::new();
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) == Some("md") {
files.push(path);
}
}
files.sort();
Some(files)
}

/// Selects the global user-instructions provider for a runtime.
///
/// `claude_compat` is the fork's config switch: off keeps upstream behaviour
/// exactly, on appends the user's `~/.claude` files.
pub fn user_instructions_provider(
codex_home: AbsolutePathBuf,
claude_compat: bool,
max_bytes: usize,
) -> Arc<dyn UserInstructionsProvider> {
if claude_compat {
Arc::new(ClaudeCompatUserInstructionsProvider::new(
codex_home,
dirs::home_dir(),
max_bytes,
))
} else {
Arc::new(CodexHomeUserInstructionsProvider::new(codex_home))
}
}
80 changes: 80 additions & 0 deletions codex-rs/codex-home/src/instructions/claude_compat_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
use std::fs;

use codex_extension_api::UserInstructionsProvider;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;

use super::ClaudeCompatUserInstructionsProvider;

fn provider(user_home: &std::path::Path, max_bytes: usize) -> ClaudeCompatUserInstructionsProvider {
let codex_home = tempfile::tempdir().expect("codex home");
// Leak: the provider only stores the path, and the dir lives until process exit.
let codex_home = Box::leak(Box::new(codex_home));
ClaudeCompatUserInstructionsProvider::new(
AbsolutePathBuf::try_from(codex_home.path().to_path_buf()).expect("absolute"),
Some(user_home.to_path_buf()),
max_bytes,
)
}

#[tokio::test]
async fn claude_md_then_rules_in_sorted_order() {
let home = tempfile::tempdir().expect("home");
let claude = home.path().join(".claude");
fs::create_dir_all(claude.join("rules")).expect("rules dir");
fs::write(claude.join("CLAUDE.md"), "global claude\n").expect("claude md");
fs::write(claude.join("rules").join("b.md"), "rule b\n").expect("rule b");
fs::write(claude.join("rules").join("a.md"), "rule a\n").expect("rule a");
fs::write(claude.join("rules").join("notes.txt"), "not a rule").expect("txt");

let loaded = provider(home.path(), 32 * 1024)
.load_user_instructions()
.await;

let text = loaded.instructions.expect("instructions").text;
let claude_at = text.find("global claude").expect("claude text");
let a_at = text.find("rule a").expect("rule a");
let b_at = text.find("rule b").expect("rule b");
assert!(claude_at < a_at && a_at < b_at, "order was {text:?}");
assert!(!text.contains("not a rule"));
assert!(loaded.warnings.is_empty());
}

#[tokio::test]
async fn rules_import_is_expanded_and_deduplicated() {
let home = tempfile::tempdir().expect("home");
let claude = home.path().join(".claude");
fs::create_dir_all(claude.join("rules")).expect("rules dir");
fs::write(claude.join("shared.md"), "shared once\n").expect("shared");
fs::write(claude.join("CLAUDE.md"), "@shared.md\n").expect("claude md");
fs::write(claude.join("rules").join("r.md"), "@../shared.md\n").expect("rule");

let loaded = provider(home.path(), 32 * 1024)
.load_user_instructions()
.await;

let text = loaded.instructions.expect("instructions").text;
assert_eq!(text.matches("shared once").count(), 1, "{text:?}");
}

#[tokio::test]
async fn the_budget_covers_claude_files_and_rules_together() {
let home = tempfile::tempdir().expect("home");
let claude = home.path().join(".claude");
fs::create_dir_all(claude.join("rules")).expect("rules dir");
let claude_md = "0123456789abcdef";
fs::write(claude.join("CLAUDE.md"), claude_md).expect("claude md");
fs::write(claude.join("rules").join("r.md"), "rule body").expect("rule");

let loaded = provider(home.path(), claude_md.len())
.load_user_instructions()
.await;

let text = loaded.instructions.expect("instructions").text;
assert!(text.contains(claude_md));
assert!(
!text.contains("rule body"),
"rule must be past the budget: {text:?}"
);
assert_eq!(loaded.warnings.len(), 1);
}
Loading
Loading