From 5acf1e6ed9c543a1c4399a086bc778464307aa11 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 22:22:56 +0800 Subject: [PATCH 01/32] feat(prompt): add system prompt builder with CLAUDE.md injection Move system prompt ownership out of the transport layer into a dedicated prompt module. The prompt assembles static guidance sections (identity, task, tool, style), runtime environment detection (platform, git, date), and discovered CLAUDE.md files (global + project). --- CLAUDE.md | 4 + crates/oxide-code/src/client/anthropic.rs | 14 +- crates/oxide-code/src/main.rs | 20 +- crates/oxide-code/src/prompt.rs | 131 ++++++++++++ crates/oxide-code/src/prompt/claude_md.rs | 152 ++++++++++++++ crates/oxide-code/src/prompt/environment.rs | 211 ++++++++++++++++++++ 6 files changed, 515 insertions(+), 17 deletions(-) create mode 100644 crates/oxide-code/src/prompt.rs create mode 100644 crates/oxide-code/src/prompt/claude_md.rs create mode 100644 crates/oxide-code/src/prompt/environment.rs diff --git a/CLAUDE.md b/CLAUDE.md index f0962673..3842eaab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,10 @@ ox # Start an interactive session │ └── oauth.rs # Claude Code OAuth credentials (macOS Keychain + file), token refresh, file locking ├── main.rs # CLI entry point, agent loop, async REPL ├── message.rs # Conversation message types +├── prompt.rs # System prompt builder (section assembly, static content) +├── prompt/ +│ ├── claude_md.rs # CLAUDE.md discovery and loading (global + project) +│ └── environment.rs # Runtime environment detection (platform, git, date) ├── tool.rs # Tool trait, registry, definitions └── tool/ ├── bash.rs # Shell command execution with timeout diff --git a/crates/oxide-code/src/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index 99c1a458..a63be2ae 100644 --- a/crates/oxide-code/src/client/anthropic.rs +++ b/crates/oxide-code/src/client/anthropic.rs @@ -17,11 +17,6 @@ const OAUTH_BETA_HEADER: &str = "oauth-2025-04-20"; /// Matches the referenced Claude Code version. const CLAUDE_CLI_VERSION: &str = "2.1.87"; -/// System prompt prefix that identifies the client to the Anthropic API. Required -/// for OAuth tokens — without it, non-Haiku models return 429. Always sent -/// regardless of auth method for simplicity. -const SYSTEM_PROMPT_PREFIX: &str = "You are Claude Code, Anthropic's official CLI for Claude."; - // ── Request types ── #[derive(Serialize)] @@ -227,20 +222,15 @@ impl Client { pub fn stream_message( &self, messages: &[Message], - system: Option<&str>, + system: &str, tools: &[ToolDefinition], ) -> Result>> { - let system_prompt = match system { - Some(s) => format!("{SYSTEM_PROMPT_PREFIX}\n{s}"), - None => SYSTEM_PROMPT_PREFIX.to_owned(), - }; - let url = format!("{}/v1/messages", self.config.base_url); let body = serde_json::to_value(CreateMessageRequest { model: &self.config.model, max_tokens: self.config.max_tokens, messages, - system: &system_prompt, + system, stream: true, tools: (!tools.is_empty()).then_some(tools), thinking: self.config.thinking.as_ref(), diff --git a/crates/oxide-code/src/main.rs b/crates/oxide-code/src/main.rs index 346dc3ae..86aa8c32 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -1,6 +1,7 @@ mod client; mod config; mod message; +mod prompt; mod tool; use std::io::Write; @@ -34,6 +35,7 @@ async fn main() -> Result<()> { let config = Config::load().await?; let show_thinking = config.show_thinking; + let system_prompt = prompt::build_system_prompt(&config.model).await; let client = Client::new(config)?; let tools = ToolRegistry::new(vec![ Box::new(BashTool), @@ -44,10 +46,15 @@ async fn main() -> Result<()> { Box::new(GrepTool), ]); - repl(&client, &tools, show_thinking).await + repl(&client, &tools, &system_prompt, show_thinking).await } -async fn repl(client: &Client, tools: &ToolRegistry, show_thinking: bool) -> Result<()> { +async fn repl( + client: &Client, + tools: &ToolRegistry, + system_prompt: &str, + show_thinking: bool, +) -> Result<()> { let stdin = BufReader::new(tokio::io::stdin()); let mut lines = stdin.lines(); let mut messages: Vec = Vec::new(); @@ -66,7 +73,7 @@ async fn repl(client: &Client, tools: &ToolRegistry, show_thinking: bool) -> Res } messages.push(Message::user(&input)); - agent_turn(client, tools, &mut messages, show_thinking).await?; + agent_turn(client, tools, &mut messages, system_prompt, show_thinking).await?; } Ok(()) @@ -76,13 +83,15 @@ async fn agent_turn( client: &Client, tools: &ToolRegistry, messages: &mut Vec, + system_prompt: &str, show_thinking: bool, ) -> Result<()> { let tool_defs = tools.definitions(); for _ in 0..MAX_TOOL_ROUNDS { strip_trailing_thinking(messages); - let blocks = stream_response(client, messages, &tool_defs, show_thinking).await?; + let blocks = + stream_response(client, messages, &tool_defs, system_prompt, show_thinking).await?; let tool_uses: Vec<_> = blocks .iter() @@ -205,9 +214,10 @@ async fn stream_response( client: &Client, messages: &[Message], tools: &[ToolDefinition], + system_prompt: &str, show_thinking: bool, ) -> Result> { - let mut rx = client.stream_message(messages, None, tools)?; + let mut rx = client.stream_message(messages, system_prompt, tools)?; let mut blocks: Vec> = Vec::new(); let mut stdout = std::io::stdout(); diff --git a/crates/oxide-code/src/prompt.rs b/crates/oxide-code/src/prompt.rs new file mode 100644 index 00000000..e8e72fd0 --- /dev/null +++ b/crates/oxide-code/src/prompt.rs @@ -0,0 +1,131 @@ +mod claude_md; +mod environment; + +use std::path::{Path, PathBuf}; + +use tokio::process::Command; + +use environment::Environment; + +/// OAuth-required identity prefix. The Anthropic API returns 429 for non-Haiku +/// models with OAuth tokens unless the system prompt starts with this string. +const IDENTITY_PREFIX: &str = "You are Claude Code, Anthropic's official CLI for Claude."; + +const IDENTITY: &str = "\ +You are an interactive AI assistant that helps with software engineering tasks. \ +Use the tools available to you to assist the user. + +Output text to communicate with the user. Use GitHub-flavored Markdown for formatting."; + +const TASK_GUIDANCE: &str = "\ +# Doing tasks + +- Read and understand existing code before suggesting modifications. +- Prefer editing existing files over creating new ones. +- Do not add features, refactor code, or make improvements beyond what was asked. +- Be careful not to introduce security vulnerabilities. +- If a task is ambiguous, ask for clarification instead of guessing. +- If an approach fails, diagnose why before retrying or switching tactics."; + +const TOOL_GUIDANCE: &str = "\ +# Using your tools + +Use dedicated tools instead of running equivalent shell commands: +- Read files: use `read`, not `cat` / `head` / `tail` +- Edit files: use `edit`, not `sed` / `awk` +- Write files: use `write`, not `echo` / `cat` with redirection +- Search files: use `glob`, not `find` / `ls` +- Search content: use `grep`, not shell `grep` / `rg` +- Reserve `bash` for commands that genuinely require shell execution. + +When multiple tool calls are independent of each other, make them in parallel."; + +const STYLE: &str = "\ +# Tone and style + +- Be concise. Lead with the answer or action, not the reasoning. +- When referencing code, include `file_path:line_number` for easy navigation. +- Skip filler words and preamble. Go straight to the point."; + +/// Build the complete system prompt for the agent. +/// +/// The prompt always begins with [`IDENTITY_PREFIX`] (required for OAuth) +/// followed by static guidance sections, a detected environment section, and +/// any discovered CLAUDE.md user instructions. +pub(crate) async fn build_system_prompt(model: &str) -> String { + let cwd = std::env::current_dir().ok(); + let git_root = match &cwd { + Some(cwd) => find_git_root(cwd).await, + None => None, + }; + + let env = Environment::detect(model, cwd.as_deref(), git_root.as_deref()).await; + let claude_md = claude_md::load(cwd.as_deref(), git_root.as_deref()).await; + + let mut sections = vec![ + format!("{IDENTITY_PREFIX}\n{IDENTITY}"), + TASK_GUIDANCE.to_owned(), + TOOL_GUIDANCE.to_owned(), + STYLE.to_owned(), + env.render(), + ]; + + if !claude_md.is_empty() { + sections.push(claude_md); + } + + sections.join("\n\n") +} + +/// Find the git repository root from a working directory. +/// +/// Returns `None` when not inside a git repository or when `git` is not +/// available. +async fn find_git_root(cwd: &Path) -> Option { + let output = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(cwd) + .stderr(std::process::Stdio::null()) + .output() + .await + .ok()?; + + if !output.status.success() { + return None; + } + + let root = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + if root.is_empty() { + return None; + } + + Some(PathBuf::from(root)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── build_system_prompt ── + + #[tokio::test] + async fn build_system_prompt_starts_with_identity_prefix() { + let prompt = build_system_prompt("test-model").await; + assert!(prompt.starts_with(IDENTITY_PREFIX)); + } + + #[tokio::test] + async fn build_system_prompt_contains_all_static_sections() { + let prompt = build_system_prompt("test-model").await; + assert!(prompt.contains("# Doing tasks")); + assert!(prompt.contains("# Using your tools")); + assert!(prompt.contains("# Tone and style")); + assert!(prompt.contains("# Environment")); + } + + #[tokio::test] + async fn build_system_prompt_includes_model_name() { + let prompt = build_system_prompt("claude-opus-4-6").await; + assert!(prompt.contains("Model: claude-opus-4-6")); + } +} diff --git a/crates/oxide-code/src/prompt/claude_md.rs b/crates/oxide-code/src/prompt/claude_md.rs new file mode 100644 index 00000000..b77e82bf --- /dev/null +++ b/crates/oxide-code/src/prompt/claude_md.rs @@ -0,0 +1,152 @@ +use std::path::{Path, PathBuf}; + +use tokio::fs; + +/// A discovered CLAUDE.md file with its content and a human-readable label. +struct MemoryFile { + path: PathBuf, + content: String, + label: &'static str, +} + +/// Discover and load CLAUDE.md files, returning the formatted section for the +/// system prompt. +/// +/// Discovery order: +/// 1. User global: `~/.claude/CLAUDE.md` +/// 2. Project root: `CLAUDE.md` +/// 3. Project `.claude/`: `.claude/CLAUDE.md` +/// +/// The project root is the git repository root when available, otherwise the +/// current working directory. +/// +/// Returns an empty string when no files are found. +pub(super) async fn load(cwd: Option<&Path>, git_root: Option<&Path>) -> String { + let project_root = git_root.or(cwd); + + let Some(project_root) = project_root else { + return String::new(); + }; + + let candidates = candidate_paths(project_root); + let files = load_files(candidates).await; + + if files.is_empty() { + return String::new(); + } + + render(&files) +} + +/// Build the list of candidate CLAUDE.md paths to check. +fn candidate_paths(project_root: &Path) -> Vec<(PathBuf, &'static str)> { + let mut paths = Vec::new(); + + if let Some(home) = dirs::home_dir() { + paths.push(( + home.join(".claude").join("CLAUDE.md"), + "user's global instructions", + )); + } + + paths.push((project_root.join("CLAUDE.md"), "project instructions")); + + paths.push(( + project_root.join(".claude").join("CLAUDE.md"), + "project instructions (.claude/)", + )); + + paths +} + +/// Load files that exist and have non-empty content. +async fn load_files(candidates: Vec<(PathBuf, &'static str)>) -> Vec { + let mut files = Vec::new(); + + for (path, label) in candidates { + if let Ok(content) = fs::read_to_string(&path).await { + let content = content.trim().to_owned(); + if !content.is_empty() { + files.push(MemoryFile { + path, + content, + label, + }); + } + } + } + + files +} + +/// Render memory files into a system prompt section. +fn render(files: &[MemoryFile]) -> String { + use std::fmt::Write; + + let mut out = String::from( + "# User instructions\n\n\ + Codebase and user instructions are shown below. \ + Be sure to adhere to these instructions.", + ); + + for file in files { + let _ = write!( + out, + "\n\nContents of {} ({}):\n\n{}", + file.path.display(), + file.label, + file.content, + ); + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── candidate_paths ── + + #[test] + fn candidate_paths_includes_project_and_dotclaude() { + let root = PathBuf::from("/home/user/project"); + let paths = candidate_paths(&root); + + let targets: Vec<_> = paths.iter().map(|(p, _)| p.clone()).collect(); + assert!(targets.contains(&root.join("CLAUDE.md"))); + assert!(targets.contains(&root.join(".claude").join("CLAUDE.md"))); + assert!(paths.len() >= 2); + } + + // ── render ── + + #[test] + fn render_formats_files_with_header_and_preserves_order() { + let files = vec![ + MemoryFile { + path: PathBuf::from("/home/.claude/CLAUDE.md"), + content: "Global rules.".to_owned(), + label: "user's global instructions", + }, + MemoryFile { + path: PathBuf::from("/project/CLAUDE.md"), + content: "Project rules.".to_owned(), + label: "project instructions", + }, + ]; + let out = render(&files); + + assert!(out.starts_with("# User instructions")); + assert!(out.contains("Be sure to adhere to these instructions.")); + assert!(out.contains("Contents of /home/.claude/CLAUDE.md (user's global instructions):")); + assert!(out.contains("Contents of /project/CLAUDE.md (project instructions):")); + + let global_pos = out.find("Global rules.").expect("global content missing"); + let project_pos = out.find("Project rules.").expect("project content missing"); + assert!( + global_pos < project_pos, + "global should come before project" + ); + } +} diff --git a/crates/oxide-code/src/prompt/environment.rs b/crates/oxide-code/src/prompt/environment.rs new file mode 100644 index 00000000..74787ac3 --- /dev/null +++ b/crates/oxide-code/src/prompt/environment.rs @@ -0,0 +1,211 @@ +use std::path::Path; + +use tokio::process::Command; + +/// Detected runtime environment for the system prompt. +pub(super) struct Environment { + cwd: String, + platform: String, + shell: String, + git: Option, + date: String, + model: String, +} + +struct GitInfo { + branch: String, + is_clean: bool, +} + +impl Environment { + /// Detect the current runtime environment. + /// + /// All detection is best-effort: failures produce fallback values rather + /// than errors, so the system prompt is always constructible. + pub(super) async fn detect(model: &str, cwd: Option<&Path>, git_root: Option<&Path>) -> Self { + let cwd_str = cwd.map_or_else( + || "(unknown)".to_owned(), + |p| p.to_string_lossy().into_owned(), + ); + + let git = match cwd { + Some(cwd) if git_root.is_some() => detect_git_info(cwd).await, + _ => None, + }; + + let platform = format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH); + + let shell = std::env::var("SHELL").unwrap_or_else(|_| "(unknown)".to_owned()); + + let date = current_date().await; + + Self { + cwd: cwd_str, + platform, + shell, + git, + date, + model: model.to_owned(), + } + } + + /// Render the environment section for the system prompt. + pub(super) fn render(&self) -> String { + let mut lines = vec![ + "# Environment".to_owned(), + format!("- Working directory: {}", self.cwd), + ]; + + match &self.git { + Some(git) => { + lines.push(" - Is a git repository: true".to_owned()); + if !git.branch.is_empty() { + lines.push(format!(" - Branch: {}", git.branch)); + } + let status = if git.is_clean { "clean" } else { "dirty" }; + lines.push(format!(" - Status: {status}")); + } + None => { + lines.push(" - Is a git repository: false".to_owned()); + } + } + + lines.push(format!("- Platform: {}", self.platform)); + lines.push(format!("- Shell: {}", self.shell)); + lines.push(format!("- Date: {}", self.date)); + lines.push(format!("- Model: {}", self.model)); + + lines.join("\n") + } +} + +// ── Git Detection ── + +async fn detect_git_info(cwd: &Path) -> Option { + let (branch_result, status_result) = tokio::join!( + Command::new("git") + .args(["branch", "--show-current"]) + .current_dir(cwd) + .stderr(std::process::Stdio::null()) + .output(), + Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(cwd) + .stderr(std::process::Stdio::null()) + .output(), + ); + + let branch = String::from_utf8_lossy(&branch_result.ok()?.stdout) + .trim() + .to_owned(); + let is_clean = String::from_utf8_lossy(&status_result.ok()?.stdout) + .trim() + .is_empty(); + + Some(GitInfo { branch, is_clean }) +} + +// ── Date Detection ── + +async fn current_date() -> String { + Command::new("date") + .arg("+%Y-%m-%d") + .output() + .await + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "(unknown)".to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Environment::render ── + + #[test] + fn render_with_git_shows_branch_and_status() { + let env = Environment { + cwd: "/home/user/project".to_owned(), + platform: "linux (x86_64)".to_owned(), + shell: "/bin/bash".to_owned(), + git: Some(GitInfo { + branch: "main".to_owned(), + is_clean: true, + }), + date: "2026-04-05".to_owned(), + model: "claude-opus-4-6".to_owned(), + }; + let rendered = env.render(); + assert!(rendered.contains("Working directory: /home/user/project")); + assert!(rendered.contains("Is a git repository: true")); + assert!(rendered.contains("Branch: main")); + assert!(rendered.contains("Status: clean")); + assert!(rendered.contains("Platform: linux (x86_64)")); + assert!(rendered.contains("Shell: /bin/bash")); + assert!(rendered.contains("Date: 2026-04-05")); + assert!(rendered.contains("Model: claude-opus-4-6")); + } + + #[test] + fn render_without_git_shows_not_a_repo() { + let env = Environment { + cwd: "/tmp".to_owned(), + platform: "macos (aarch64)".to_owned(), + shell: "/bin/zsh".to_owned(), + git: None, + date: "2026-04-05".to_owned(), + model: "test-model".to_owned(), + }; + let rendered = env.render(); + assert!(rendered.contains("Is a git repository: false")); + assert!(!rendered.contains("Branch:")); + assert!(!rendered.contains("Status:")); + } + + #[test] + fn render_dirty_repo_shows_dirty() { + let env = Environment { + cwd: "/home/user/project".to_owned(), + platform: "linux (x86_64)".to_owned(), + shell: "/bin/bash".to_owned(), + git: Some(GitInfo { + branch: "feat/wip".to_owned(), + is_clean: false, + }), + date: "2026-04-05".to_owned(), + model: "test-model".to_owned(), + }; + let rendered = env.render(); + assert!(rendered.contains("Status: dirty")); + } + + #[test] + fn render_detached_head_omits_branch() { + let env = Environment { + cwd: "/home/user/project".to_owned(), + platform: "linux (x86_64)".to_owned(), + shell: "/bin/bash".to_owned(), + git: Some(GitInfo { + branch: String::new(), + is_clean: true, + }), + date: "2026-04-05".to_owned(), + model: "test-model".to_owned(), + }; + let rendered = env.render(); + assert!(rendered.contains("Is a git repository: true")); + assert!(!rendered.contains("Branch:")); + } + + // ── current_date ── + + #[tokio::test] + async fn current_date_matches_iso_format() { + let date = current_date().await; + assert_eq!(date.len(), 10, "expected YYYY-MM-DD, got: {date}"); + assert_eq!(&date[4..5], "-"); + assert_eq!(&date[7..8], "-"); + } +} From ecb648af5ee54ecdab22b1822d19e88be000ee20 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 22:23:01 +0800 Subject: [PATCH 02/32] docs(roadmap): move system prompt to working, advance current focus --- docs/roadmap.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 8efa4268..18aa236f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -30,15 +30,13 @@ The project direction is simple: - Tool definitions sent via the Anthropic `tools` API parameter. - Tool output with structured metadata — title and tool-specific fields for TUI rendering, separate from model-facing content. -## Current Focus - ### System Prompt -- System prompt construction with tool definitions and project context. -- Load and inject `CLAUDE.md` files (global + project). -- Conversation context management (token counting, message history). +- Section-based system prompt builder: identity (OAuth-required prefix), task guidance, tool usage guidance, tone / style. +- CLAUDE.md discovery and injection — user global (`~/.claude/CLAUDE.md`), project root (`CLAUDE.md`), project `.claude/` directory (`.claude/CLAUDE.md`). +- Runtime environment detection — working directory, platform, shell, git info (branch, clean / dirty status), date, model name. -## Next Phase +## Current Focus ### Terminal UI From 8ea7dc65a1fdd250bd5772eae4cf92dc9e861a51 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 22:28:12 +0800 Subject: [PATCH 03/32] fix(prompt): address review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix global CLAUDE.md skipped when cwd is None (candidate_paths now takes Option<&Path> and always includes the global path). - Handle git command failures independently — default to empty branch and assume dirty rather than discarding all git info. - Run environment detection and CLAUDE.md loading concurrently. - Tighten test assertions (identity prefix boundary, candidate_paths length). --- crates/oxide-code/src/prompt.rs | 8 ++-- crates/oxide-code/src/prompt/claude_md.rs | 49 ++++++++++++++------- crates/oxide-code/src/prompt/environment.rs | 15 ++++--- 3 files changed, 48 insertions(+), 24 deletions(-) diff --git a/crates/oxide-code/src/prompt.rs b/crates/oxide-code/src/prompt.rs index e8e72fd0..e9814915 100644 --- a/crates/oxide-code/src/prompt.rs +++ b/crates/oxide-code/src/prompt.rs @@ -59,8 +59,10 @@ pub(crate) async fn build_system_prompt(model: &str) -> String { None => None, }; - let env = Environment::detect(model, cwd.as_deref(), git_root.as_deref()).await; - let claude_md = claude_md::load(cwd.as_deref(), git_root.as_deref()).await; + let (env, claude_md) = tokio::join!( + Environment::detect(model, cwd.as_deref(), git_root.as_deref()), + claude_md::load(cwd.as_deref(), git_root.as_deref()), + ); let mut sections = vec![ format!("{IDENTITY_PREFIX}\n{IDENTITY}"), @@ -111,7 +113,7 @@ mod tests { #[tokio::test] async fn build_system_prompt_starts_with_identity_prefix() { let prompt = build_system_prompt("test-model").await; - assert!(prompt.starts_with(IDENTITY_PREFIX)); + assert!(prompt.starts_with(&format!("{IDENTITY_PREFIX}\n"))); } #[tokio::test] diff --git a/crates/oxide-code/src/prompt/claude_md.rs b/crates/oxide-code/src/prompt/claude_md.rs index b77e82bf..c91ed40e 100644 --- a/crates/oxide-code/src/prompt/claude_md.rs +++ b/crates/oxide-code/src/prompt/claude_md.rs @@ -18,16 +18,12 @@ struct MemoryFile { /// 3. Project `.claude/`: `.claude/CLAUDE.md` /// /// The project root is the git repository root when available, otherwise the -/// current working directory. +/// current working directory. The global file is always checked regardless of +/// whether a project root exists. /// /// Returns an empty string when no files are found. pub(super) async fn load(cwd: Option<&Path>, git_root: Option<&Path>) -> String { let project_root = git_root.or(cwd); - - let Some(project_root) = project_root else { - return String::new(); - }; - let candidates = candidate_paths(project_root); let files = load_files(candidates).await; @@ -39,7 +35,11 @@ pub(super) async fn load(cwd: Option<&Path>, git_root: Option<&Path>) -> String } /// Build the list of candidate CLAUDE.md paths to check. -fn candidate_paths(project_root: &Path) -> Vec<(PathBuf, &'static str)> { +/// +/// The global path (`~/.claude/CLAUDE.md`) is always included when a home +/// directory exists. Project paths are only included when `project_root` is +/// available. +fn candidate_paths(project_root: Option<&Path>) -> Vec<(PathBuf, &'static str)> { let mut paths = Vec::new(); if let Some(home) = dirs::home_dir() { @@ -49,12 +49,14 @@ fn candidate_paths(project_root: &Path) -> Vec<(PathBuf, &'static str)> { )); } - paths.push((project_root.join("CLAUDE.md"), "project instructions")); + if let Some(root) = project_root { + paths.push((root.join("CLAUDE.md"), "project instructions")); - paths.push(( - project_root.join(".claude").join("CLAUDE.md"), - "project instructions (.claude/)", - )); + paths.push(( + root.join(".claude").join("CLAUDE.md"), + "project instructions (.claude/)", + )); + } paths } @@ -109,14 +111,31 @@ mod tests { // ── candidate_paths ── #[test] - fn candidate_paths_includes_project_and_dotclaude() { + fn candidate_paths_with_project_root() { let root = PathBuf::from("/home/user/project"); - let paths = candidate_paths(&root); + let paths = candidate_paths(Some(&root)); let targets: Vec<_> = paths.iter().map(|(p, _)| p.clone()).collect(); assert!(targets.contains(&root.join("CLAUDE.md"))); assert!(targets.contains(&root.join(".claude").join("CLAUDE.md"))); - assert!(paths.len() >= 2); + + if dirs::home_dir().is_some() { + assert_eq!(paths.len(), 3); + } else { + assert_eq!(paths.len(), 2); + } + } + + #[test] + fn candidate_paths_without_project_root_still_includes_global() { + let paths = candidate_paths(None); + + if dirs::home_dir().is_some() { + assert_eq!(paths.len(), 1); + assert!(paths[0].0.ends_with(".claude/CLAUDE.md")); + } else { + assert!(paths.is_empty()); + } } // ── render ── diff --git a/crates/oxide-code/src/prompt/environment.rs b/crates/oxide-code/src/prompt/environment.rs index 74787ac3..4243236d 100644 --- a/crates/oxide-code/src/prompt/environment.rs +++ b/crates/oxide-code/src/prompt/environment.rs @@ -95,12 +95,15 @@ async fn detect_git_info(cwd: &Path) -> Option { .output(), ); - let branch = String::from_utf8_lossy(&branch_result.ok()?.stdout) - .trim() - .to_owned(); - let is_clean = String::from_utf8_lossy(&status_result.ok()?.stdout) - .trim() - .is_empty(); + // Handle each result independently: default to empty branch and assume + // dirty when a command fails, rather than discarding all git info. + let branch = branch_result + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()) + .unwrap_or_default(); + let is_clean = status_result + .ok() + .is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim().is_empty()); Some(GitInfo { branch, is_clean }) } From 8f0bbf571d9ae1cd32479e0026123957f297343d Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 22:28:15 +0800 Subject: [PATCH 04/32] docs(research): add system prompt architecture notes --- .cspell/words.txt | 1 + docs/README.md | 10 +++-- docs/research/system-prompt.md | 74 ++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 docs/research/system-prompt.md diff --git a/.cspell/words.txt b/.cspell/words.txt index 0995b759..d9b6cecc 100644 --- a/.cspell/words.txt +++ b/.cspell/words.txt @@ -1,5 +1,6 @@ anthropic anyhow +claudemd clippy codex creds diff --git a/docs/README.md b/docs/README.md index 65814edf..93ef1a8e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,9 @@ Internal docs for oxide-code development: research findings, architecture notes, and project status. -| Document | Description | -| ------------------------------------------------------ | ---------------------------------------------------------------------- | -| [roadmap.md](roadmap.md) | Project status: working features, current focus, planned phases | -| [research/anthropic-api.md](research/anthropic-api.md) | Anthropic API auth: OAuth flow, required headers, system prompt prefix | +| Document | Description | +| -------------------------------------------------------------- | ---------------------------------------------------------------------- | +| [roadmap.md](roadmap.md) | Project status: working features, current focus, planned phases | +| [research/anthropic-api.md](research/anthropic-api.md) | Anthropic API auth: OAuth flow, required headers, system prompt prefix | +| [research/extended-thinking.md](research/extended-thinking.md) | Extended thinking: content block types, signatures, round-tripping | +| [research/system-prompt.md](research/system-prompt.md) | System prompt architecture: section assembly, CLAUDE.md, caching | diff --git a/docs/research/system-prompt.md b/docs/research/system-prompt.md new file mode 100644 index 00000000..8130894d --- /dev/null +++ b/docs/research/system-prompt.md @@ -0,0 +1,74 @@ +# System Prompt Architecture + +Research notes on how Claude Code constructs its system prompt. Based on [`claude-code`](https://github.com/hakula139/claude-code) (v2.1.87) and [`opencode`](https://github.com/anomalyco/opencode). + +## Section-Based Assembly + +Claude Code builds the system prompt from **sections** — discrete units with lazy, memoized resolution. Sections are split into two categories by a `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` marker: + +- **Static sections** (before the boundary): identity, system guidance, task guidance, tool usage, tone / style. Globally cacheable via prompt caching. +- **Dynamic sections** (after the boundary): session-specific guidance, CLAUDE.md memory, environment info, MCP instructions, language preference, output style, token budget. Not cacheable. + +Resolution pipeline: + +1. `getSystemPrompt()` collects section definitions (static + dynamic). +2. Static sections resolve immediately; dynamic sections are promises with memoization. +3. `resolveSystemPromptSections()` awaits all section promises. +4. `buildEffectiveSystemPrompt()` applies priority logic — override > coordinator > agent > custom > default. +5. `splitSysPromptPrefix()` splits by cache boundaries and assigns `cacheScope` (`global` / `org` / `null`). +6. `buildSystemPromptBlocks()` wraps in `TextBlockParam` with `cache_control` for the API. + +Each block carries a `cacheScope` so the API can reuse cached prefixes across sessions. + +## CLAUDE.md Loading Hierarchy + +Files are loaded in priority order (latest = highest priority): + +| Order | Type | Path | Description | +| ----- | ------- | ------------------------------------- | ------------------------------------- | +| 1 | Managed | `/etc/claude-code/CLAUDE.md` | Organization-level instructions | +| 2 | User | `~/.claude/CLAUDE.md` | User's global instructions | +| 3 | Project | `CLAUDE.md`, `.claude/CLAUDE.md` | Checked-in project instructions | +| 4 | Rules | `.claude/rules/*.md` | Conditional rules with path globs | +| 5 | Local | `CLAUDE.local.md` | Private project-specific (gitignored) | +| 6 | AutoMem | `~/.claude/projects//MEMORY.md` | Auto-accumulated memory | + +Features: + +- **`@include` directives**: `@./relative/path`, `@~/home`, `@/absolute` — recursive include with max depth 5. +- **Conditional rules**: `.md` files with `paths:` frontmatter — glob-matched to decide inclusion. +- **HTML comment stripping**: Block-level only, via marked lexer. +- **MEMORY.md truncation**: Lines after 200 are truncated. + +## Tool Definitions + +Tool schemas are sent via the API `tools` parameter, **not** in the system prompt. The system prompt contains only tool _guidance_ (how / when to use tools) and availability info (which tools are enabled). This is consistent across both Claude Code and opencode. + +## Prompt Caching + +The API supports prompt caching via `cache_control` on `TextBlockParam` blocks. Claude Code assigns cache scopes: + +- `global` — static instructions identical across all sessions (first-party only). +- `org` — organization-scoped prefix. +- `null` — dynamic content, not cached. + +The `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` marker separates cacheable from non-cacheable content. Effective caching requires the static prefix to be identical across requests. + +## opencode Patterns + +opencode (Go) uses a similar hierarchical approach: + +- **Agent-specific base prompts**: Each agent type (build, plan, explore) has its own prompt template. +- **Config-driven instructions**: `instructions: string[]` in config, concatenated into the system prompt. +- **8-level config precedence**: Managed → account → inline → `.opencode/` → `opencode.json` → custom path → global → remote. +- **Three-phase compaction**: Pruning (erase old tool outputs) → summarization (compaction agent) → truncation (replace with summary). + +## Sources + +- `claude-code/src/constants/prompts.ts` — section content, `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` +- `claude-code/src/constants/systemPromptSections.ts` — section caching system +- `claude-code/src/utils/systemPrompt.ts` — `buildEffectiveSystemPrompt`, priority logic +- `claude-code/src/utils/claudemd.ts` — `getMemoryFiles`, `@include`, conditional rules +- `claude-code/src/utils/context.ts` — token budgeting, `getUserContext` +- `claude-code/src/utils/api.ts` — `splitSysPromptPrefix`, cache scope assignment +- `claude-code/src/services/api/claude.ts` — `queryModel`, `buildSystemPromptBlocks` From 2fd7e25a5049746b21abbaec9abf39831e5b463a Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 22:30:14 +0800 Subject: [PATCH 05/32] feat(prompt): add AGENTS.md as instruction file fallback At each project location, check CLAUDE.md first, then AGENTS.md as a fallback. The first file found at each location wins. The global ~/.claude/ directory stays CLAUDE.md only. --- crates/oxide-code/src/prompt/claude_md.rs | 124 +++++++++++++--------- 1 file changed, 76 insertions(+), 48 deletions(-) diff --git a/crates/oxide-code/src/prompt/claude_md.rs b/crates/oxide-code/src/prompt/claude_md.rs index c91ed40e..a9ea48c0 100644 --- a/crates/oxide-code/src/prompt/claude_md.rs +++ b/crates/oxide-code/src/prompt/claude_md.rs @@ -2,20 +2,27 @@ use std::path::{Path, PathBuf}; use tokio::fs; -/// A discovered CLAUDE.md file with its content and a human-readable label. +/// Instruction filenames to check at each project location, in priority order. +/// At each location, the first file found is used. +const INSTRUCTION_FILENAMES: &[&str] = &["CLAUDE.md", "AGENTS.md"]; + +/// A discovered instruction file with its content and a human-readable label. struct MemoryFile { path: PathBuf, content: String, label: &'static str, } -/// Discover and load CLAUDE.md files, returning the formatted section for the +/// Discover and load instruction files, returning the formatted section for the /// system prompt. /// -/// Discovery order: +/// At each project location, filenames are checked in +/// [`INSTRUCTION_FILENAMES`] order — the first file found wins. Discovery +/// locations: +/// /// 1. User global: `~/.claude/CLAUDE.md` -/// 2. Project root: `CLAUDE.md` -/// 3. Project `.claude/`: `.claude/CLAUDE.md` +/// 2. Project root: `CLAUDE.md` or `AGENTS.md` +/// 3. Project `.claude/`: `.claude/CLAUDE.md` or `.claude/AGENTS.md` /// /// The project root is the git repository root when available, otherwise the /// current working directory. The global file is always checked regardless of @@ -24,8 +31,8 @@ struct MemoryFile { /// Returns an empty string when no files are found. pub(super) async fn load(cwd: Option<&Path>, git_root: Option<&Path>) -> String { let project_root = git_root.or(cwd); - let candidates = candidate_paths(project_root); - let files = load_files(candidates).await; + let slots = candidate_slots(project_root); + let files = load_files(slots).await; if files.is_empty() { return String::new(); @@ -34,46 +41,55 @@ pub(super) async fn load(cwd: Option<&Path>, git_root: Option<&Path>) -> String render(&files) } -/// Build the list of candidate CLAUDE.md paths to check. +/// Build candidate slots — groups of paths to try at each location. /// -/// The global path (`~/.claude/CLAUDE.md`) is always included when a home -/// directory exists. Project paths are only included when `project_root` is -/// available. -fn candidate_paths(project_root: Option<&Path>) -> Vec<(PathBuf, &'static str)> { - let mut paths = Vec::new(); +/// The global slot (`~/.claude/CLAUDE.md`) is always included when a home +/// directory exists. Project slots are only included when `project_root` is +/// available, and each slot lists [`INSTRUCTION_FILENAMES`] in priority order. +fn candidate_slots(project_root: Option<&Path>) -> Vec<(Vec, &'static str)> { + let mut slots = Vec::new(); + // Global: only CLAUDE.md (the ~/.claude/ directory is Claude-specific) if let Some(home) = dirs::home_dir() { - paths.push(( - home.join(".claude").join("CLAUDE.md"), + slots.push(( + vec![home.join(".claude").join("CLAUDE.md")], "user's global instructions", )); } if let Some(root) = project_root { - paths.push((root.join("CLAUDE.md"), "project instructions")); - - paths.push(( - root.join(".claude").join("CLAUDE.md"), + slots.push(( + INSTRUCTION_FILENAMES.iter().map(|f| root.join(f)).collect(), + "project instructions", + )); + slots.push(( + INSTRUCTION_FILENAMES + .iter() + .map(|f| root.join(".claude").join(f)) + .collect(), "project instructions (.claude/)", )); } - paths + slots } -/// Load files that exist and have non-empty content. -async fn load_files(candidates: Vec<(PathBuf, &'static str)>) -> Vec { +/// Try each slot's candidates in order, loading the first file found per slot. +async fn load_files(slots: Vec<(Vec, &'static str)>) -> Vec { let mut files = Vec::new(); - for (path, label) in candidates { - if let Ok(content) = fs::read_to_string(&path).await { - let content = content.trim().to_owned(); - if !content.is_empty() { - files.push(MemoryFile { - path, - content, - label, - }); + for (candidates, label) in slots { + for path in candidates { + if let Ok(content) = fs::read_to_string(&path).await { + let content = content.trim().to_owned(); + if !content.is_empty() { + files.push(MemoryFile { + path, + content, + label, + }); + break; + } } } } @@ -108,33 +124,45 @@ fn render(files: &[MemoryFile]) -> String { mod tests { use super::*; - // ── candidate_paths ── + // ── candidate_slots ── #[test] - fn candidate_paths_with_project_root() { + fn candidate_slots_with_project_root() { let root = PathBuf::from("/home/user/project"); - let paths = candidate_paths(Some(&root)); - - let targets: Vec<_> = paths.iter().map(|(p, _)| p.clone()).collect(); - assert!(targets.contains(&root.join("CLAUDE.md"))); - assert!(targets.contains(&root.join(".claude").join("CLAUDE.md"))); + let slots = candidate_slots(Some(&root)); + + let project = slots + .iter() + .find(|(_, l)| *l == "project instructions") + .expect("project instructions slot missing"); + assert_eq!( + project.0, + vec![root.join("CLAUDE.md"), root.join("AGENTS.md")] + ); - if dirs::home_dir().is_some() { - assert_eq!(paths.len(), 3); - } else { - assert_eq!(paths.len(), 2); - } + let dotclaude = slots + .iter() + .find(|(_, l)| *l == "project instructions (.claude/)") + .expect(".claude/ slot missing"); + assert_eq!( + dotclaude.0, + vec![ + root.join(".claude").join("CLAUDE.md"), + root.join(".claude").join("AGENTS.md"), + ] + ); } #[test] - fn candidate_paths_without_project_root_still_includes_global() { - let paths = candidate_paths(None); + fn candidate_slots_without_project_root_still_includes_global() { + let slots = candidate_slots(None); if dirs::home_dir().is_some() { - assert_eq!(paths.len(), 1); - assert!(paths[0].0.ends_with(".claude/CLAUDE.md")); + assert_eq!(slots.len(), 1); + assert_eq!(slots[0].1, "user's global instructions"); + assert_eq!(slots[0].0.len(), 1); } else { - assert!(paths.is_empty()); + assert!(slots.is_empty()); } } From 876bd0905ef702b38e26c66a2d8b4e680492cd75 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 22:31:45 +0800 Subject: [PATCH 06/32] refactor(prompt): rename claude_md to instructions, fix research doc --- CLAUDE.md | 4 ++-- crates/oxide-code/src/prompt.rs | 4 ++-- .../src/prompt/{claude_md.rs => instructions.rs} | 0 docs/research/system-prompt.md | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) rename crates/oxide-code/src/prompt/{claude_md.rs => instructions.rs} (100%) diff --git a/CLAUDE.md b/CLAUDE.md index 3842eaab..a5c6d1ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,8 +33,8 @@ ox # Start an interactive session ├── message.rs # Conversation message types ├── prompt.rs # System prompt builder (section assembly, static content) ├── prompt/ -│ ├── claude_md.rs # CLAUDE.md discovery and loading (global + project) -│ └── environment.rs # Runtime environment detection (platform, git, date) +│ ├── environment.rs # Runtime environment detection (platform, git, date) +│ └── instructions.rs # Instruction file discovery and loading (CLAUDE.md, AGENTS.md) ├── tool.rs # Tool trait, registry, definitions └── tool/ ├── bash.rs # Shell command execution with timeout diff --git a/crates/oxide-code/src/prompt.rs b/crates/oxide-code/src/prompt.rs index e9814915..5b9cd922 100644 --- a/crates/oxide-code/src/prompt.rs +++ b/crates/oxide-code/src/prompt.rs @@ -1,5 +1,5 @@ -mod claude_md; mod environment; +mod instructions; use std::path::{Path, PathBuf}; @@ -61,7 +61,7 @@ pub(crate) async fn build_system_prompt(model: &str) -> String { let (env, claude_md) = tokio::join!( Environment::detect(model, cwd.as_deref(), git_root.as_deref()), - claude_md::load(cwd.as_deref(), git_root.as_deref()), + instructions::load(cwd.as_deref(), git_root.as_deref()), ); let mut sections = vec![ diff --git a/crates/oxide-code/src/prompt/claude_md.rs b/crates/oxide-code/src/prompt/instructions.rs similarity index 100% rename from crates/oxide-code/src/prompt/claude_md.rs rename to crates/oxide-code/src/prompt/instructions.rs diff --git a/docs/research/system-prompt.md b/docs/research/system-prompt.md index 8130894d..4cd8b51e 100644 --- a/docs/research/system-prompt.md +++ b/docs/research/system-prompt.md @@ -1,6 +1,6 @@ # System Prompt Architecture -Research notes on how Claude Code constructs its system prompt. Based on [`claude-code`](https://github.com/hakula139/claude-code) (v2.1.87) and [`opencode`](https://github.com/anomalyco/opencode). +Research notes on how Claude Code and opencode construct their system prompts. Based on [`claude-code`](https://github.com/hakula139/claude-code) (v2.1.87) and [`opencode`](https://github.com/anomalyco/opencode). ## Section-Based Assembly @@ -67,8 +67,8 @@ opencode (Go) uses a similar hierarchical approach: - `claude-code/src/constants/prompts.ts` — section content, `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` - `claude-code/src/constants/systemPromptSections.ts` — section caching system -- `claude-code/src/utils/systemPrompt.ts` — `buildEffectiveSystemPrompt`, priority logic +- `claude-code/src/services/api/claude.ts` — `queryModel`, `buildSystemPromptBlocks` +- `claude-code/src/utils/api.ts` — `splitSysPromptPrefix`, cache scope assignment - `claude-code/src/utils/claudemd.ts` — `getMemoryFiles`, `@include`, conditional rules - `claude-code/src/utils/context.ts` — token budgeting, `getUserContext` -- `claude-code/src/utils/api.ts` — `splitSysPromptPrefix`, cache scope assignment -- `claude-code/src/services/api/claude.ts` — `queryModel`, `buildSystemPromptBlocks` +- `claude-code/src/utils/systemPrompt.ts` — `buildEffectiveSystemPrompt`, priority logic From 3ffb6543550a82aab7f9190bb2136acb77b25806 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 22:33:48 +0800 Subject: [PATCH 07/32] fix(prompt): include AGENTS.md fallback in global slot --- crates/oxide-code/src/prompt/instructions.rs | 24 +++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/oxide-code/src/prompt/instructions.rs b/crates/oxide-code/src/prompt/instructions.rs index a9ea48c0..aba09ef3 100644 --- a/crates/oxide-code/src/prompt/instructions.rs +++ b/crates/oxide-code/src/prompt/instructions.rs @@ -20,7 +20,7 @@ struct MemoryFile { /// [`INSTRUCTION_FILENAMES`] order — the first file found wins. Discovery /// locations: /// -/// 1. User global: `~/.claude/CLAUDE.md` +/// 1. User global: `~/.claude/CLAUDE.md` or `~/.claude/AGENTS.md` /// 2. Project root: `CLAUDE.md` or `AGENTS.md` /// 3. Project `.claude/`: `.claude/CLAUDE.md` or `.claude/AGENTS.md` /// @@ -43,16 +43,18 @@ pub(super) async fn load(cwd: Option<&Path>, git_root: Option<&Path>) -> String /// Build candidate slots — groups of paths to try at each location. /// -/// The global slot (`~/.claude/CLAUDE.md`) is always included when a home -/// directory exists. Project slots are only included when `project_root` is -/// available, and each slot lists [`INSTRUCTION_FILENAMES`] in priority order. +/// Each slot lists [`INSTRUCTION_FILENAMES`] in priority order. The global +/// slot is always included when a home directory exists. Project slots are +/// only included when `project_root` is available. fn candidate_slots(project_root: Option<&Path>) -> Vec<(Vec, &'static str)> { let mut slots = Vec::new(); - // Global: only CLAUDE.md (the ~/.claude/ directory is Claude-specific) if let Some(home) = dirs::home_dir() { slots.push(( - vec![home.join(".claude").join("CLAUDE.md")], + INSTRUCTION_FILENAMES + .iter() + .map(|f| home.join(".claude").join(f)) + .collect(), "user's global instructions", )); } @@ -157,10 +159,16 @@ mod tests { fn candidate_slots_without_project_root_still_includes_global() { let slots = candidate_slots(None); - if dirs::home_dir().is_some() { + if let Some(home) = dirs::home_dir() { assert_eq!(slots.len(), 1); assert_eq!(slots[0].1, "user's global instructions"); - assert_eq!(slots[0].0.len(), 1); + assert_eq!( + slots[0].0, + vec![ + home.join(".claude").join("CLAUDE.md"), + home.join(".claude").join("AGENTS.md"), + ] + ); } else { assert!(slots.is_empty()); } From a503d063c5597af3e4d18dcce70e25915089047e Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 22:35:46 +0800 Subject: [PATCH 08/32] docs(roadmap): note configurable instruction directories under config --- docs/roadmap.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/roadmap.md b/docs/roadmap.md index 18aa236f..6f3df2a4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -50,6 +50,7 @@ The project direction is simple: - TOML config file (`~/.config/ox/config.toml` or `ox.toml` in project root) to replace env-var-only configuration. - Layered loading: global defaults → user config → project config → env var overrides. - All current env vars (`ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL`, `OX_SHOW_THINKING`, etc.) become config keys, with env vars still taking precedence. +- Configurable instruction directories — allow users to specify additional directories to scan for instruction files (e.g., `.codex/`, `.opencode/`) beyond the hardcoded `.claude/`. ### Tool Enhancements From 7d194a4b01bee9f103b255ceeb030b01203f59f5 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 22:39:27 +0800 Subject: [PATCH 09/32] feat(prompt): walk project root to CWD for instruction files Discover instruction files at every directory level from the project root down to the working directory, not just at the root. Subdirectory-specific instructions appear later in the prompt (higher priority), matching Claude Code's hierarchical discovery behavior. --- crates/oxide-code/src/prompt/instructions.rs | 151 ++++++++++++++----- 1 file changed, 115 insertions(+), 36 deletions(-) diff --git a/crates/oxide-code/src/prompt/instructions.rs b/crates/oxide-code/src/prompt/instructions.rs index aba09ef3..2047127b 100644 --- a/crates/oxide-code/src/prompt/instructions.rs +++ b/crates/oxide-code/src/prompt/instructions.rs @@ -16,13 +16,17 @@ struct MemoryFile { /// Discover and load instruction files, returning the formatted section for the /// system prompt. /// -/// At each project location, filenames are checked in +/// At each directory level, filenames are checked in /// [`INSTRUCTION_FILENAMES`] order — the first file found wins. Discovery -/// locations: +/// walks from the project root down to the working directory so that +/// subdirectory-specific instructions appear later (higher priority). +/// +/// Discovery locations: /// /// 1. User global: `~/.claude/CLAUDE.md` or `~/.claude/AGENTS.md` -/// 2. Project root: `CLAUDE.md` or `AGENTS.md` -/// 3. Project `.claude/`: `.claude/CLAUDE.md` or `.claude/AGENTS.md` +/// 2. Each directory from project root to CWD (inclusive): +/// - `/CLAUDE.md` or `/AGENTS.md` +/// - `/.claude/CLAUDE.md` or `/.claude/AGENTS.md` /// /// The project root is the git repository root when available, otherwise the /// current working directory. The global file is always checked regardless of @@ -31,7 +35,7 @@ struct MemoryFile { /// Returns an empty string when no files are found. pub(super) async fn load(cwd: Option<&Path>, git_root: Option<&Path>) -> String { let project_root = git_root.or(cwd); - let slots = candidate_slots(project_root); + let slots = candidate_slots(cwd, project_root); let files = load_files(slots).await; if files.is_empty() { @@ -44,9 +48,13 @@ pub(super) async fn load(cwd: Option<&Path>, git_root: Option<&Path>) -> String /// Build candidate slots — groups of paths to try at each location. /// /// Each slot lists [`INSTRUCTION_FILENAMES`] in priority order. The global -/// slot is always included when a home directory exists. Project slots are -/// only included when `project_root` is available. -fn candidate_slots(project_root: Option<&Path>) -> Vec<(Vec, &'static str)> { +/// slot is always included when a home directory exists. Project slots walk +/// from the root to the working directory, generating two slots per directory +/// level (root-level and `.claude/`). +fn candidate_slots( + cwd: Option<&Path>, + project_root: Option<&Path>, +) -> Vec<(Vec, &'static str)> { let mut slots = Vec::new(); if let Some(home) = dirs::home_dir() { @@ -60,22 +68,47 @@ fn candidate_slots(project_root: Option<&Path>) -> Vec<(Vec, &'static s } if let Some(root) = project_root { - slots.push(( - INSTRUCTION_FILENAMES.iter().map(|f| root.join(f)).collect(), - "project instructions", - )); - slots.push(( - INSTRUCTION_FILENAMES - .iter() - .map(|f| root.join(".claude").join(f)) - .collect(), - "project instructions (.claude/)", - )); + for dir in walk_root_to_cwd(root, cwd) { + slots.push(( + INSTRUCTION_FILENAMES.iter().map(|f| dir.join(f)).collect(), + "project instructions", + )); + slots.push(( + INSTRUCTION_FILENAMES + .iter() + .map(|f| dir.join(".claude").join(f)) + .collect(), + "project instructions (.claude/)", + )); + } } slots } +/// Return every directory from `root` down to `cwd` (inclusive). +/// +/// If `cwd` is not a subdirectory of `root`, or `cwd` is `None`, returns +/// just `[root]`. +fn walk_root_to_cwd(root: &Path, cwd: Option<&Path>) -> Vec { + let Some(cwd) = cwd else { + return vec![root.to_path_buf()]; + }; + + let Ok(relative) = cwd.strip_prefix(root) else { + return vec![root.to_path_buf()]; + }; + + let mut dirs = vec![root.to_path_buf()]; + let mut current = root.to_path_buf(); + for component in relative.components() { + current.push(component); + dirs.push(current.clone()); + } + + dirs +} + /// Try each slot's candidates in order, loading the first file found per slot. async fn load_files(slots: Vec<(Vec, &'static str)>) -> Vec { let mut files = Vec::new(); @@ -129,35 +162,42 @@ mod tests { // ── candidate_slots ── #[test] - fn candidate_slots_with_project_root() { + fn candidate_slots_cwd_equals_root() { let root = PathBuf::from("/home/user/project"); - let slots = candidate_slots(Some(&root)); + let slots = candidate_slots(Some(&root), Some(&root)); - let project = slots + // 1 global + 2 project (root-level + .claude/) + let project: Vec<_> = slots .iter() - .find(|(_, l)| *l == "project instructions") - .expect("project instructions slot missing"); + .filter(|(_, l)| *l == "project instructions") + .collect(); + assert_eq!(project.len(), 1); assert_eq!( - project.0, + project[0].0, vec![root.join("CLAUDE.md"), root.join("AGENTS.md")] ); + } - let dotclaude = slots + #[test] + fn candidate_slots_walks_root_to_cwd() { + let root = PathBuf::from("/repo"); + let cwd = PathBuf::from("/repo/crates/core"); + let slots = candidate_slots(Some(&cwd), Some(&root)); + + let project: Vec<_> = slots .iter() - .find(|(_, l)| *l == "project instructions (.claude/)") - .expect(".claude/ slot missing"); - assert_eq!( - dotclaude.0, - vec![ - root.join(".claude").join("CLAUDE.md"), - root.join(".claude").join("AGENTS.md"), - ] - ); + .filter(|(_, l)| *l == "project instructions") + .collect(); + // 3 levels: /repo, /repo/crates, /repo/crates/core + assert_eq!(project.len(), 3); + assert_eq!(project[0].0[0], root.join("CLAUDE.md")); + assert_eq!(project[1].0[0], root.join("crates").join("CLAUDE.md")); + assert_eq!(project[2].0[0], cwd.join("CLAUDE.md")); } #[test] fn candidate_slots_without_project_root_still_includes_global() { - let slots = candidate_slots(None); + let slots = candidate_slots(None, None); if let Some(home) = dirs::home_dir() { assert_eq!(slots.len(), 1); @@ -174,6 +214,45 @@ mod tests { } } + // ── walk_root_to_cwd ── + + #[test] + fn walk_root_to_cwd_same_directory() { + let root = PathBuf::from("/repo"); + let dirs = walk_root_to_cwd(&root, Some(&root)); + assert_eq!(dirs, vec![PathBuf::from("/repo")]); + } + + #[test] + fn walk_root_to_cwd_nested() { + let root = PathBuf::from("/repo"); + let cwd = PathBuf::from("/repo/a/b"); + let dirs = walk_root_to_cwd(&root, Some(&cwd)); + assert_eq!( + dirs, + vec![ + PathBuf::from("/repo"), + PathBuf::from("/repo/a"), + PathBuf::from("/repo/a/b"), + ] + ); + } + + #[test] + fn walk_root_to_cwd_outside_root_returns_root_only() { + let root = PathBuf::from("/repo"); + let cwd = PathBuf::from("/other/dir"); + let dirs = walk_root_to_cwd(&root, Some(&cwd)); + assert_eq!(dirs, vec![PathBuf::from("/repo")]); + } + + #[test] + fn walk_root_to_cwd_none_returns_root_only() { + let root = PathBuf::from("/repo"); + let dirs = walk_root_to_cwd(&root, None); + assert_eq!(dirs, vec![PathBuf::from("/repo")]); + } + // ── render ── #[test] From 7572ec08b3b0757e8e23165d63532d4e2f0136e4 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 23:11:07 +0800 Subject: [PATCH 10/32] refactor(prompt): simplify environment detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Return GitInfo directly from detect_git_info instead of wrapping in Option — the function never returns None due to fallback logic, so the Option was misleading. The caller already gates on git_root.is_some(). - Replace date subprocess (date +%Y-%m-%d) with the time crate for portability and no subprocess overhead. Uses local time with UTC fallback when local offset detection fails. - Improve render tests to verify exact line ordering and structure instead of loose contains() checks. - Add tests for Environment::detect with various cwd/git_root combinations and strengthen current_date year validation. --- Cargo.lock | 52 ++++++++++++ Cargo.toml | 1 + crates/oxide-code/Cargo.toml | 1 + crates/oxide-code/src/prompt/environment.rs | 94 +++++++++++++++------ 4 files changed, 120 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0d10ab88..d0619106 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -216,6 +216,15 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "dirs" version = "6.0.0" @@ -759,6 +768,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -794,6 +818,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "time", "tokio", "tracing", "tracing-subscriber", @@ -820,6 +845,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1323,6 +1354,27 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + [[package]] name = "tinystr" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 0e91b185..1b93c1d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ security-framework = "3" serde = { version = "1", features = ["derive"] } serde_json = "1" tempfile = "3" +time = { version = "0.3", features = ["local-offset"] } tokio = { version = "1", features = [ "io-std", "io-util", diff --git a/crates/oxide-code/Cargo.toml b/crates/oxide-code/Cargo.toml index c9686efa..11841267 100644 --- a/crates/oxide-code/Cargo.toml +++ b/crates/oxide-code/Cargo.toml @@ -23,6 +23,7 @@ regex.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true +time.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/crates/oxide-code/src/prompt/environment.rs b/crates/oxide-code/src/prompt/environment.rs index 4243236d..bbc2ba28 100644 --- a/crates/oxide-code/src/prompt/environment.rs +++ b/crates/oxide-code/src/prompt/environment.rs @@ -29,7 +29,7 @@ impl Environment { ); let git = match cwd { - Some(cwd) if git_root.is_some() => detect_git_info(cwd).await, + Some(cwd) if git_root.is_some() => Some(detect_git_info(cwd).await), _ => None, }; @@ -37,7 +37,7 @@ impl Environment { let shell = std::env::var("SHELL").unwrap_or_else(|_| "(unknown)".to_owned()); - let date = current_date().await; + let date = current_date(); Self { cwd: cwd_str, @@ -81,7 +81,7 @@ impl Environment { // ── Git Detection ── -async fn detect_git_info(cwd: &Path) -> Option { +async fn detect_git_info(cwd: &Path) -> GitInfo { let (branch_result, status_result) = tokio::join!( Command::new("git") .args(["branch", "--show-current"]) @@ -105,20 +105,21 @@ async fn detect_git_info(cwd: &Path) -> Option { .ok() .is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim().is_empty()); - Some(GitInfo { branch, is_clean }) + GitInfo { branch, is_clean } } // ── Date Detection ── -async fn current_date() -> String { - Command::new("date") - .arg("+%Y-%m-%d") - .output() - .await - .ok() - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "(unknown)".to_owned()) +fn current_date() -> String { + let date = time::OffsetDateTime::now_local() + .unwrap_or_else(|_| time::OffsetDateTime::now_utc()) + .date(); + format!( + "{}-{:02}-{:02}", + date.year(), + date.month() as u8, + date.day() + ) } #[cfg(test)] @@ -128,7 +129,7 @@ mod tests { // ── Environment::render ── #[test] - fn render_with_git_shows_branch_and_status() { + fn render_with_git_shows_all_fields_in_order() { let env = Environment { cwd: "/home/user/project".to_owned(), platform: "linux (x86_64)".to_owned(), @@ -141,14 +142,18 @@ mod tests { model: "claude-opus-4-6".to_owned(), }; let rendered = env.render(); - assert!(rendered.contains("Working directory: /home/user/project")); - assert!(rendered.contains("Is a git repository: true")); - assert!(rendered.contains("Branch: main")); - assert!(rendered.contains("Status: clean")); - assert!(rendered.contains("Platform: linux (x86_64)")); - assert!(rendered.contains("Shell: /bin/bash")); - assert!(rendered.contains("Date: 2026-04-05")); - assert!(rendered.contains("Model: claude-opus-4-6")); + let lines: Vec<&str> = rendered.lines().collect(); + + assert_eq!(lines[0], "# Environment"); + assert_eq!(lines[1], "- Working directory: /home/user/project"); + assert_eq!(lines[2], " - Is a git repository: true"); + assert_eq!(lines[3], " - Branch: main"); + assert_eq!(lines[4], " - Status: clean"); + assert_eq!(lines[5], "- Platform: linux (x86_64)"); + assert_eq!(lines[6], "- Shell: /bin/bash"); + assert_eq!(lines[7], "- Date: 2026-04-05"); + assert_eq!(lines[8], "- Model: claude-opus-4-6"); + assert_eq!(lines.len(), 9); } #[test] @@ -162,9 +167,12 @@ mod tests { model: "test-model".to_owned(), }; let rendered = env.render(); - assert!(rendered.contains("Is a git repository: false")); - assert!(!rendered.contains("Branch:")); - assert!(!rendered.contains("Status:")); + let lines: Vec<&str> = rendered.lines().collect(); + + assert_eq!(lines[2], " - Is a git repository: false"); + // No branch or status lines — jump straight to platform. + assert_eq!(lines[3], "- Platform: macos (aarch64)"); + assert_eq!(lines.len(), 7); } #[test] @@ -202,13 +210,43 @@ mod tests { assert!(!rendered.contains("Branch:")); } - // ── current_date ── + // ── Environment::detect ── + + #[tokio::test] + async fn detect_without_cwd_uses_unknown_and_skips_git() { + let env = Environment::detect("test-model", None, None).await; + assert_eq!(env.cwd, "(unknown)"); + assert!(env.git.is_none()); + } #[tokio::test] - async fn current_date_matches_iso_format() { - let date = current_date().await; + async fn detect_with_cwd_but_no_git_root_skips_git() { + let tmp = tempfile::tempdir().expect("failed to create tempdir"); + let env = Environment::detect("test-model", Some(tmp.path()), None).await; + assert!(env.git.is_none()); + assert!( + env.cwd + .ends_with(tmp.path().file_name().unwrap().to_str().unwrap()) + ); + } + + #[tokio::test] + async fn detect_inside_repo_populates_git_info() { + let cwd = std::env::current_dir().expect("cwd should be available"); + let env = Environment::detect("test-model", Some(&cwd), Some(&cwd)).await; + assert!(env.git.is_some()); + } + + // ── current_date ── + + #[test] + fn current_date_matches_iso_format() { + let date = current_date(); assert_eq!(date.len(), 10, "expected YYYY-MM-DD, got: {date}"); assert_eq!(&date[4..5], "-"); assert_eq!(&date[7..8], "-"); + // Verify it represents a plausible year. + let year: u32 = date[..4].parse().expect("year should be numeric"); + assert!((2025..=2100).contains(&year), "unexpected year: {year}"); } } From be0726b02278d2f5e97c70d0c3a4511e15e1b100 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 23:11:13 +0800 Subject: [PATCH 11/32] fix(prompt): rebuild system prompt per user message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the system prompt at the start of each user interaction instead of once at startup. This keeps dynamic data (git branch, dirty status, working directory, date) fresh during long sessions. The rebuild happens per user message, not per tool round — one set of git subprocess calls per interaction is acceptable overhead. --- crates/oxide-code/src/main.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/oxide-code/src/main.rs b/crates/oxide-code/src/main.rs index 86aa8c32..f18dd324 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -35,7 +35,7 @@ async fn main() -> Result<()> { let config = Config::load().await?; let show_thinking = config.show_thinking; - let system_prompt = prompt::build_system_prompt(&config.model).await; + let model = config.model.clone(); let client = Client::new(config)?; let tools = ToolRegistry::new(vec![ Box::new(BashTool), @@ -46,13 +46,13 @@ async fn main() -> Result<()> { Box::new(GrepTool), ]); - repl(&client, &tools, &system_prompt, show_thinking).await + repl(&client, &tools, &model, show_thinking).await } async fn repl( client: &Client, tools: &ToolRegistry, - system_prompt: &str, + model: &str, show_thinking: bool, ) -> Result<()> { let stdin = BufReader::new(tokio::io::stdin()); @@ -73,7 +73,8 @@ async fn repl( } messages.push(Message::user(&input)); - agent_turn(client, tools, &mut messages, system_prompt, show_thinking).await?; + let system_prompt = prompt::build_system_prompt(model).await; + agent_turn(client, tools, &mut messages, &system_prompt, show_thinking).await?; } Ok(()) From 9c56644b4fbf0ed9883d6ec59e7c6e90335f61c3 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 23:11:21 +0800 Subject: [PATCH 12/32] refactor(prompt): introduce Slot type for instruction discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Vec<(Vec, &'static str)> with a named Slot struct for readability — slot.candidates and slot.label are self-documenting compared to tuple field access. Add comment in walk_root_to_cwd explaining the strip_prefix behavior when cwd equals root (empty relative path, loop body never executes). Add tests for load_files: empty results, first-candidate preference, AGENTS.md fallback, whitespace-only skip, multi-slot collection. Extend candidate_slots test to verify .claude/ companion slots. Add render_single_file test. --- crates/oxide-code/src/prompt/instructions.rs | 183 ++++++++++++++++--- 1 file changed, 156 insertions(+), 27 deletions(-) diff --git a/crates/oxide-code/src/prompt/instructions.rs b/crates/oxide-code/src/prompt/instructions.rs index 2047127b..2c4d1968 100644 --- a/crates/oxide-code/src/prompt/instructions.rs +++ b/crates/oxide-code/src/prompt/instructions.rs @@ -6,6 +6,14 @@ use tokio::fs; /// At each location, the first file found is used. const INSTRUCTION_FILENAMES: &[&str] = &["CLAUDE.md", "AGENTS.md"]; +/// A group of candidate paths to try at a single discovery location. +/// +/// Candidates are tried in order; the first file found wins for this slot. +struct Slot { + candidates: Vec, + label: &'static str, +} + /// A discovered instruction file with its content and a human-readable label. struct MemoryFile { path: PathBuf, @@ -51,35 +59,32 @@ pub(super) async fn load(cwd: Option<&Path>, git_root: Option<&Path>) -> String /// slot is always included when a home directory exists. Project slots walk /// from the root to the working directory, generating two slots per directory /// level (root-level and `.claude/`). -fn candidate_slots( - cwd: Option<&Path>, - project_root: Option<&Path>, -) -> Vec<(Vec, &'static str)> { +fn candidate_slots(cwd: Option<&Path>, project_root: Option<&Path>) -> Vec { let mut slots = Vec::new(); if let Some(home) = dirs::home_dir() { - slots.push(( - INSTRUCTION_FILENAMES + slots.push(Slot { + candidates: INSTRUCTION_FILENAMES .iter() .map(|f| home.join(".claude").join(f)) .collect(), - "user's global instructions", - )); + label: "user's global instructions", + }); } if let Some(root) = project_root { for dir in walk_root_to_cwd(root, cwd) { - slots.push(( - INSTRUCTION_FILENAMES.iter().map(|f| dir.join(f)).collect(), - "project instructions", - )); - slots.push(( - INSTRUCTION_FILENAMES + slots.push(Slot { + candidates: INSTRUCTION_FILENAMES.iter().map(|f| dir.join(f)).collect(), + label: "project instructions", + }); + slots.push(Slot { + candidates: INSTRUCTION_FILENAMES .iter() .map(|f| dir.join(".claude").join(f)) .collect(), - "project instructions (.claude/)", - )); + label: "project instructions (.claude/)", + }); } } @@ -99,6 +104,9 @@ fn walk_root_to_cwd(root: &Path, cwd: Option<&Path>) -> Vec { return vec![root.to_path_buf()]; }; + // When cwd == root, strip_prefix returns an empty path whose + // components() iterator yields nothing, so the loop is skipped and + // we correctly return just [root]. let mut dirs = vec![root.to_path_buf()]; let mut current = root.to_path_buf(); for component in relative.components() { @@ -110,10 +118,10 @@ fn walk_root_to_cwd(root: &Path, cwd: Option<&Path>) -> Vec { } /// Try each slot's candidates in order, loading the first file found per slot. -async fn load_files(slots: Vec<(Vec, &'static str)>) -> Vec { +async fn load_files(slots: Vec) -> Vec { let mut files = Vec::new(); - for (candidates, label) in slots { + for Slot { candidates, label } in slots { for path in candidates { if let Ok(content) = fs::read_to_string(&path).await { let content = content.trim().to_owned(); @@ -166,16 +174,29 @@ mod tests { let root = PathBuf::from("/home/user/project"); let slots = candidate_slots(Some(&root), Some(&root)); - // 1 global + 2 project (root-level + .claude/) let project: Vec<_> = slots .iter() - .filter(|(_, l)| *l == "project instructions") + .filter(|s| s.label == "project instructions") .collect(); assert_eq!(project.len(), 1); assert_eq!( - project[0].0, + project[0].candidates, vec![root.join("CLAUDE.md"), root.join("AGENTS.md")] ); + + // Verify the .claude/ companion slot is also present. + let claude_dir: Vec<_> = slots + .iter() + .filter(|s| s.label == "project instructions (.claude/)") + .collect(); + assert_eq!(claude_dir.len(), 1); + assert_eq!( + claude_dir[0].candidates, + vec![ + root.join(".claude").join("CLAUDE.md"), + root.join(".claude").join("AGENTS.md"), + ] + ); } #[test] @@ -186,13 +207,16 @@ mod tests { let project: Vec<_> = slots .iter() - .filter(|(_, l)| *l == "project instructions") + .filter(|s| s.label == "project instructions") .collect(); // 3 levels: /repo, /repo/crates, /repo/crates/core assert_eq!(project.len(), 3); - assert_eq!(project[0].0[0], root.join("CLAUDE.md")); - assert_eq!(project[1].0[0], root.join("crates").join("CLAUDE.md")); - assert_eq!(project[2].0[0], cwd.join("CLAUDE.md")); + assert_eq!(project[0].candidates[0], root.join("CLAUDE.md")); + assert_eq!( + project[1].candidates[0], + root.join("crates").join("CLAUDE.md") + ); + assert_eq!(project[2].candidates[0], cwd.join("CLAUDE.md")); } #[test] @@ -201,9 +225,9 @@ mod tests { if let Some(home) = dirs::home_dir() { assert_eq!(slots.len(), 1); - assert_eq!(slots[0].1, "user's global instructions"); + assert_eq!(slots[0].label, "user's global instructions"); assert_eq!( - slots[0].0, + slots[0].candidates, vec![ home.join(".claude").join("CLAUDE.md"), home.join(".claude").join("AGENTS.md"), @@ -253,6 +277,96 @@ mod tests { assert_eq!(dirs, vec![PathBuf::from("/repo")]); } + // ── load_files ── + + #[tokio::test] + async fn load_files_returns_empty_when_no_files_exist() { + let slots = vec![Slot { + candidates: vec![ + PathBuf::from("/nonexistent/CLAUDE.md"), + PathBuf::from("/nonexistent/AGENTS.md"), + ], + label: "test", + }]; + let files = load_files(slots).await; + assert!(files.is_empty()); + } + + #[tokio::test] + async fn load_files_prefers_first_candidate() { + let dir = tempfile::tempdir().expect("failed to create tempdir"); + let claude_path = dir.path().join("CLAUDE.md"); + let agents_path = dir.path().join("AGENTS.md"); + fs::write(&claude_path, "claude content").await.unwrap(); + fs::write(&agents_path, "agents content").await.unwrap(); + + let slots = vec![Slot { + candidates: vec![claude_path.clone(), agents_path], + label: "test", + }]; + let files = load_files(slots).await; + assert_eq!(files.len(), 1); + assert_eq!(files[0].path, claude_path); + assert_eq!(files[0].content, "claude content"); + } + + #[tokio::test] + async fn load_files_falls_back_to_second_candidate() { + let dir = tempfile::tempdir().expect("failed to create tempdir"); + let agents_path = dir.path().join("AGENTS.md"); + fs::write(&agents_path, "agents content").await.unwrap(); + + let slots = vec![Slot { + candidates: vec![dir.path().join("CLAUDE.md"), agents_path.clone()], + label: "test", + }]; + let files = load_files(slots).await; + assert_eq!(files.len(), 1); + assert_eq!(files[0].path, agents_path); + assert_eq!(files[0].content, "agents content"); + } + + #[tokio::test] + async fn load_files_skips_whitespace_only_files() { + let dir = tempfile::tempdir().expect("failed to create tempdir"); + let empty_path = dir.path().join("CLAUDE.md"); + let agents_path = dir.path().join("AGENTS.md"); + fs::write(&empty_path, " \n ").await.unwrap(); + fs::write(&agents_path, "real content").await.unwrap(); + + let slots = vec![Slot { + candidates: vec![empty_path, agents_path.clone()], + label: "test", + }]; + let files = load_files(slots).await; + assert_eq!(files.len(), 1); + assert_eq!(files[0].path, agents_path); + } + + #[tokio::test] + async fn load_files_collects_one_file_per_slot() { + let dir = tempfile::tempdir().expect("failed to create tempdir"); + let a_path = dir.path().join("a.md"); + let b_path = dir.path().join("b.md"); + fs::write(&a_path, "slot 1").await.unwrap(); + fs::write(&b_path, "slot 2").await.unwrap(); + + let slots = vec![ + Slot { + candidates: vec![a_path.clone()], + label: "first", + }, + Slot { + candidates: vec![b_path.clone()], + label: "second", + }, + ]; + let files = load_files(slots).await; + assert_eq!(files.len(), 2); + assert_eq!(files[0].path, a_path); + assert_eq!(files[1].path, b_path); + } + // ── render ── #[test] @@ -283,4 +397,19 @@ mod tests { "global should come before project" ); } + + #[test] + fn render_single_file() { + let files = vec![MemoryFile { + path: PathBuf::from("/project/CLAUDE.md"), + content: "Only file.".to_owned(), + label: "project instructions", + }]; + let out = render(&files); + + assert!(out.starts_with("# User instructions")); + // Exactly one "Contents of" block. + assert_eq!(out.matches("Contents of").count(), 1); + assert!(out.contains("Only file.")); + } } From fa5d4604b1308cb516385f37c3494748ab128cfe Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 23:11:28 +0800 Subject: [PATCH 13/32] test(prompt): add coverage for find_git_root and build_system_prompt Add find_git_root tests: success inside a git repo (verifies .git exists), returns None in a temp dir outside any repo. Add build_system_prompt tests: user instructions branch (verifies project CLAUDE.md injection), section join boundary (double newline separator between sections). --- crates/oxide-code/src/prompt.rs | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/oxide-code/src/prompt.rs b/crates/oxide-code/src/prompt.rs index 5b9cd922..c195cb01 100644 --- a/crates/oxide-code/src/prompt.rs +++ b/crates/oxide-code/src/prompt.rs @@ -31,6 +31,7 @@ const TOOL_GUIDANCE: &str = "\ # Using your tools Use dedicated tools instead of running equivalent shell commands: + - Read files: use `read`, not `cat` / `head` / `tail` - Edit files: use `edit`, not `sed` / `awk` - Write files: use `write`, not `echo` / `cat` with redirection @@ -130,4 +131,48 @@ mod tests { let prompt = build_system_prompt("claude-opus-4-6").await; assert!(prompt.contains("Model: claude-opus-4-6")); } + + /// This test runs inside the oxide-code repo which has CLAUDE.md, so the + /// non-empty instructions branch should be exercised. + #[tokio::test] + async fn build_system_prompt_includes_user_instructions() { + let prompt = build_system_prompt("test-model").await; + assert!( + prompt.contains("# User instructions"), + "expected user instructions from project CLAUDE.md" + ); + } + + #[tokio::test] + async fn build_system_prompt_sections_joined_with_double_newline() { + let prompt = build_system_prompt("test-model").await; + // Each section boundary is a double newline. Verify the identity + // section is separated from the next by exactly "\n\n". + let identity_end = prompt.find("# Doing tasks").expect("task guidance missing"); + let before = &prompt[..identity_end]; + assert!( + before.ends_with("\n\n"), + "sections should be joined with double newline" + ); + } + + // ── find_git_root ── + + #[tokio::test] + async fn find_git_root_inside_repo() { + let cwd = std::env::current_dir().expect("cwd should be available"); + let root = find_git_root(&cwd).await; + assert!(root.is_some(), "test must run inside a git repo"); + assert!( + root.as_ref().unwrap().join(".git").exists(), + "root should contain .git" + ); + } + + #[tokio::test] + async fn find_git_root_outside_repo() { + let tmp = tempfile::tempdir().expect("failed to create tempdir"); + let root = find_git_root(tmp.path()).await; + assert!(root.is_none()); + } } From c58706849b5ecf103f0ce0446fa6d815c21a0704 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 23:14:00 +0800 Subject: [PATCH 14/32] docs(research): update system prompt research with walk behavior and opencode findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add root-to-CWD walk pattern to CLAUDE.md Loading Hierarchy section with a concrete example showing intermediate directory discovery. Fix opencode section: TypeScript / Bun (not Go), correct the patterns based on actual source investigation — provider-specific templates, AGENTS.md / CLAUDE.md / CONTEXT.md hierarchy with walk-up semantics, per-turn rebuild, 4-level config (not 8), two-phase compaction. Add opencode source paths to the Sources section. --- docs/research/system-prompt.md | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/docs/research/system-prompt.md b/docs/research/system-prompt.md index 4cd8b51e..b0312b9a 100644 --- a/docs/research/system-prompt.md +++ b/docs/research/system-prompt.md @@ -33,6 +33,16 @@ Files are loaded in priority order (latest = highest priority): | 5 | Local | `CLAUDE.local.md` | Private project-specific (gitignored) | | 6 | AutoMem | `~/.claude/projects//MEMORY.md` | Auto-accumulated memory | +Project files (Order 3) are discovered by walking from the git root down to CWD, checking at each intermediate directory. For a CWD of `/repo/crates/core`: + +```text +/repo/CLAUDE.md /repo/.claude/CLAUDE.md +/repo/crates/CLAUDE.md /repo/crates/.claude/CLAUDE.md +/repo/crates/core/CLAUDE.md /repo/crates/core/.claude/CLAUDE.md +``` + +This walk ensures subdirectory-specific instructions appear later (higher priority) than root-level ones. + Features: - **`@include` directives**: `@./relative/path`, `@~/home`, `@/absolute` — recursive include with max depth 5. @@ -56,15 +66,18 @@ The `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` marker separates cacheable from non-cacheab ## opencode Patterns -opencode (Go) uses a similar hierarchical approach: +opencode (TypeScript / Bun) uses a similar hierarchical approach: -- **Agent-specific base prompts**: Each agent type (build, plan, explore) has its own prompt template. -- **Config-driven instructions**: `instructions: string[]` in config, concatenated into the system prompt. -- **8-level config precedence**: Managed → account → inline → `.opencode/` → `opencode.json` → custom path → global → remote. -- **Three-phase compaction**: Pruning (erase old tool outputs) → summarization (compaction agent) → truncation (replace with summary). +- **Provider-specific prompt templates**: Different base prompts for GPT, Claude, Gemini, etc. — selected by model at prompt assembly time. +- **Instruction file hierarchy**: `AGENTS.md` → `CLAUDE.md` → `CONTEXT.md` (deprecated), first match wins. Walk-up from CWD with per-message claim tracking to prevent duplicate attachment. +- **Per-turn rebuild**: System prompt assembled per user message (not per session), enabling dynamic skill updates and environment refresh. +- **4-level config precedence**: Managed (macOS MDM) → global (`~/.opencode/`) → instance (project) → plugins. Instructions arrays are concatenated with deduplication, not replaced. +- **Two-phase compaction**: Backward-walk pruning (truncate older tool outputs, protect recent 40K tokens) → manual summarization. Messages are never removed, only tool output is truncated. ## Sources +**Claude Code:** + - `claude-code/src/constants/prompts.ts` — section content, `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` - `claude-code/src/constants/systemPromptSections.ts` — section caching system - `claude-code/src/services/api/claude.ts` — `queryModel`, `buildSystemPromptBlocks` @@ -72,3 +85,11 @@ opencode (Go) uses a similar hierarchical approach: - `claude-code/src/utils/claudemd.ts` — `getMemoryFiles`, `@include`, conditional rules - `claude-code/src/utils/context.ts` — token budgeting, `getUserContext` - `claude-code/src/utils/systemPrompt.ts` — `buildEffectiveSystemPrompt`, priority logic + +**opencode:** + +- `opencode/packages/opencode/src/config/config.ts` — 4-level config layering, MDM support +- `opencode/packages/opencode/src/session/compaction.ts` — pruning + summarization strategies +- `opencode/packages/opencode/src/session/instruction.ts` — instruction file discovery, walk-up semantics +- `opencode/packages/opencode/src/session/prompt.ts` — per-turn prompt assembly +- `opencode/packages/opencode/src/session/system.ts` — provider-specific templates, environment detection From 7e5c1c7c496e68ec0a77848407ab3321e123f10e Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 23:16:16 +0800 Subject: [PATCH 15/32] feat(prompt): strengthen system prompt with caution and security guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add "Executing actions with care" section — guidance on reversibility, blast radius, and confirmation for destructive/shared-state operations. Inspired by Claude Code's approach but kept concise for token efficiency. Expand task guidance: stronger "read before modify" language, specific security vulnerability classes (command injection, path traversal, OWASP top 10), scope discipline, and "diagnose before retry" detail. Expand style section: output focus guidance and emoji restriction. --- crates/oxide-code/src/prompt.rs | 38 +++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/crates/oxide-code/src/prompt.rs b/crates/oxide-code/src/prompt.rs index c195cb01..0b49335e 100644 --- a/crates/oxide-code/src/prompt.rs +++ b/crates/oxide-code/src/prompt.rs @@ -20,12 +20,34 @@ Output text to communicate with the user. Use GitHub-flavored Markdown for forma const TASK_GUIDANCE: &str = "\ # Doing tasks -- Read and understand existing code before suggesting modifications. -- Prefer editing existing files over creating new ones. -- Do not add features, refactor code, or make improvements beyond what was asked. -- Be careful not to introduce security vulnerabilities. +- Do not propose changes to code you haven't read. If a user asks about or wants to \ +modify a file, read it first. +- Do not create files unless absolutely necessary. Prefer editing existing files over \ +creating new ones. +- Do not add features, refactor code, or make improvements beyond what was asked. Match \ +the scope of changes to what was actually requested. +- Be careful not to introduce security vulnerabilities such as command injection, path \ +traversal, and other OWASP top 10 issues. If you notice insecure code you wrote, fix it \ +immediately. - If a task is ambiguous, ask for clarification instead of guessing. -- If an approach fails, diagnose why before retrying or switching tactics."; +- If an approach fails, diagnose why before retrying or switching tactics — read the error, \ +check assumptions, try a focused fix. Do not retry the identical action blindly."; + +const CAUTION: &str = "\ +# Executing actions with care + +Consider the reversibility and blast radius of actions. Local, reversible actions like \ +editing files or running tests can proceed freely. For actions that are hard to reverse, \ +affect shared systems, or could be destructive, ask the user before proceeding. + +Examples of risky actions that warrant confirmation: +- Destructive: deleting files or branches, `rm -rf`, overwriting uncommitted changes. +- Hard to reverse: force-pushing, `git reset --hard`, amending published commits. +- Visible to others: pushing code, creating or commenting on PRs / issues. + +When encountering unexpected state (unfamiliar files, branches, lock files), investigate \ +before deleting or overwriting — it may be the user's in-progress work. Prefer fixing root \ +causes over bypassing safety checks (e.g., do not use `--no-verify`)."; const TOOL_GUIDANCE: &str = "\ # Using your tools @@ -46,7 +68,9 @@ const STYLE: &str = "\ - Be concise. Lead with the answer or action, not the reasoning. - When referencing code, include `file_path:line_number` for easy navigation. -- Skip filler words and preamble. Go straight to the point."; +- Skip filler words and preamble. Go straight to the point. +- Focus text output on decisions that need user input, progress at milestones, and errors. +- Do not use emojis unless the user requests it."; /// Build the complete system prompt for the agent. /// @@ -68,6 +92,7 @@ pub(crate) async fn build_system_prompt(model: &str) -> String { let mut sections = vec![ format!("{IDENTITY_PREFIX}\n{IDENTITY}"), TASK_GUIDANCE.to_owned(), + CAUTION.to_owned(), TOOL_GUIDANCE.to_owned(), STYLE.to_owned(), env.render(), @@ -121,6 +146,7 @@ mod tests { async fn build_system_prompt_contains_all_static_sections() { let prompt = build_system_prompt("test-model").await; assert!(prompt.contains("# Doing tasks")); + assert!(prompt.contains("# Executing actions with care")); assert!(prompt.contains("# Using your tools")); assert!(prompt.contains("# Tone and style")); assert!(prompt.contains("# Environment")); From 4971f36c15f65b00894feea92106d60cb693f797 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 23:16:21 +0800 Subject: [PATCH 16/32] docs(research): use h3 headings for sources subsections --- docs/research/system-prompt.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/research/system-prompt.md b/docs/research/system-prompt.md index b0312b9a..722b2e66 100644 --- a/docs/research/system-prompt.md +++ b/docs/research/system-prompt.md @@ -76,7 +76,7 @@ opencode (TypeScript / Bun) uses a similar hierarchical approach: ## Sources -**Claude Code:** +### Claude Code - `claude-code/src/constants/prompts.ts` — section content, `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` - `claude-code/src/constants/systemPromptSections.ts` — section caching system @@ -86,7 +86,7 @@ opencode (TypeScript / Bun) uses a similar hierarchical approach: - `claude-code/src/utils/context.ts` — token budgeting, `getUserContext` - `claude-code/src/utils/systemPrompt.ts` — `buildEffectiveSystemPrompt`, priority logic -**opencode:** +### opencode - `opencode/packages/opencode/src/config/config.ts` — 4-level config layering, MDM support - `opencode/packages/opencode/src/session/compaction.ts` — pruning + summarization strategies From bcc09fca5ba31a52e7d07d1d499217d708351fae Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 23:23:51 +0800 Subject: [PATCH 17/32] style(prompt): add blank line before caution bullet list --- crates/oxide-code/src/prompt.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/oxide-code/src/prompt.rs b/crates/oxide-code/src/prompt.rs index 0b49335e..ebfc1444 100644 --- a/crates/oxide-code/src/prompt.rs +++ b/crates/oxide-code/src/prompt.rs @@ -41,6 +41,7 @@ editing files or running tests can proceed freely. For actions that are hard to affect shared systems, or could be destructive, ask the user before proceeding. Examples of risky actions that warrant confirmation: + - Destructive: deleting files or branches, `rm -rf`, overwriting uncommitted changes. - Hard to reverse: force-pushing, `git reset --hard`, amending published commits. - Visible to others: pushing code, creating or commenting on PRs / issues. From 1ea04c0f595eecb3d1d4ed8df8a9d92cc92ae5a7 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 23:23:58 +0800 Subject: [PATCH 18/32] docs: add user-facing guide with quickstart, configuration, and instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create docs/guide/ with three focused pages covering what actually ships today: - quickstart.md — install, first session, tool overview, CLAUDE.md teaser - configuration.md — API key and OAuth auth, env vars, model selection - instructions.md — CLAUDE.md / AGENTS.md discovery hierarchy with walk example, writing tips Slim down the main README: replace inline config details with a documentation table linking to the guide pages. Update docs/README.md with a user guide section above the internal docs index. --- README.md | 20 ++++----- docs/README.md | 8 +++- docs/guide/README.md | 9 ++++ docs/guide/configuration.md | 38 +++++++++++++++++ docs/guide/instructions.md | 83 +++++++++++++++++++++++++++++++++++++ docs/guide/quickstart.md | 62 +++++++++++++++++++++++++++ 6 files changed, 206 insertions(+), 14 deletions(-) create mode 100644 docs/guide/README.md create mode 100644 docs/guide/configuration.md create mode 100644 docs/guide/instructions.md create mode 100644 docs/guide/quickstart.md diff --git a/README.md b/README.md index 45698683..c94d02be 100644 --- a/README.md +++ b/README.md @@ -18,23 +18,17 @@ Early development. See [`docs/roadmap.md`](docs/roadmap.md) for the current road ## Usage ```bash +export ANTHROPIC_API_KEY=sk-ant-... ox ``` -## Configuration +## Documentation -oxide-code needs an Anthropic API credential. It checks two sources in order: - -1. **`ANTHROPIC_API_KEY`** — set this to your Anthropic API key. -2. **Claude Code OAuth** — if no API key is set, oxide-code reads OAuth credentials from the macOS Keychain and `~/.claude/.credentials.json` (created by [Claude Code]), preferring whichever has the later expiry. Falls back to file-only on Linux. - -Optional environment variables: - -| Variable | Default | Description | -| ---------------------- | --------------------------- | ----------------------- | -| `ANTHROPIC_MODEL` | `claude-opus-4-6` | Model to use | -| `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | API base URL | -| `ANTHROPIC_MAX_TOKENS` | `16384` | Max tokens per response | +| Document | Description | +| ----------------------------------------------- | ----------------------------------------------- | +| [Quickstart](docs/guide/quickstart.md) | Install, first run, basic usage | +| [Configuration](docs/guide/configuration.md) | API credentials, model selection, environment | +| [Instruction Files](docs/guide/instructions.md) | CLAUDE.md / AGENTS.md setup and discovery rules | ## Building from Source diff --git a/docs/README.md b/docs/README.md index 93ef1a8e..839ab5c7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,12 @@ # Documentation Index -Internal docs for oxide-code development: research findings, architecture notes, and project status. +## User Guide + +See [`guide/`](guide/) for user-facing documentation: [quickstart](guide/quickstart.md), [configuration](guide/configuration.md), and [instruction files](guide/instructions.md). + +## Internal + +Research findings, architecture notes, and project status. | Document | Description | | -------------------------------------------------------------- | ---------------------------------------------------------------------- | diff --git a/docs/guide/README.md b/docs/guide/README.md new file mode 100644 index 00000000..6fb99e93 --- /dev/null +++ b/docs/guide/README.md @@ -0,0 +1,9 @@ +# User Guide + +Documentation for using oxide-code (`ox`). + +| Document | Description | +| ------------------------------------ | ----------------------------------------------- | +| [Quickstart](quickstart.md) | Install, first run, basic usage | +| [Configuration](configuration.md) | API credentials, model selection, environment | +| [Instruction Files](instructions.md) | CLAUDE.md / AGENTS.md setup and discovery rules | diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md new file mode 100644 index 00000000..8f27e04e --- /dev/null +++ b/docs/guide/configuration.md @@ -0,0 +1,38 @@ +# Configuration + +## Authentication + +oxide-code checks two credential sources in order: + +### API key + +Set the `ANTHROPIC_API_KEY` environment variable: + +```bash +export ANTHROPIC_API_KEY=sk-ant-... +``` + +This is the simplest method. The key is sent directly in the `x-api-key` header. + +### Claude Code OAuth + +If no API key is set, oxide-code reads OAuth credentials created by [Claude Code](https://code.claude.com/docs): + +1. **macOS Keychain** — the `"Claude Code-credentials"` service entry, accessed via the `security-framework` crate. +2. **Credentials file** — `~/.claude/.credentials.json`. + +When both sources exist, the credential with the later expiry is used. Expired tokens are refreshed automatically. On Linux, only the file source is available (no Keychain support). + +You do not need to configure anything — if Claude Code is installed and authenticated, oxide-code picks up its credentials automatically. + +## Environment variables + +| Variable | Default | Description | +| ---------------------- | --------------------------- | ----------------------- | +| `ANTHROPIC_API_KEY` | — | Anthropic API key | +| `ANTHROPIC_MODEL` | `claude-opus-4-6` | Model to use | +| `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | API base URL | +| `ANTHROPIC_MAX_TOKENS` | `16384` | Max tokens per response | +| `OX_SHOW_THINKING` | `false` | Show extended thinking | + +Set `OX_SHOW_THINKING=1` to display the model's thinking process (dimmed text) when extended thinking is enabled for the model. diff --git a/docs/guide/instructions.md b/docs/guide/instructions.md new file mode 100644 index 00000000..1b21fa71 --- /dev/null +++ b/docs/guide/instructions.md @@ -0,0 +1,83 @@ +# Instruction Files + +Instruction files let you customize the assistant's behavior with persistent, project-specific context. They are Markdown files discovered automatically at startup and injected into the system prompt. + +## Supported filenames + +At each location, the following filenames are checked in priority order: + +1. `CLAUDE.md` +2. `AGENTS.md` + +The first file found at each location wins — if `CLAUDE.md` exists, `AGENTS.md` at the same location is skipped. The dual-filename support means you can use whichever convention your project prefers. + +## Discovery hierarchy + +Files are discovered from three scopes, loaded in this order (earlier = lower priority): + +### 1. User global + +```text +~/.claude/CLAUDE.md or ~/.claude/AGENTS.md +``` + +Instructions that apply to all your projects. Useful for personal preferences like coding style, communication tone, or tool usage patterns. + +### 2. Project root-level + +```text +/CLAUDE.md or /AGENTS.md +``` + +Checked at every directory from the project root down to your working directory. The project root is the git repository root when available, otherwise the current working directory. + +### 3. Project `.claude/` directory + +```text +/.claude/CLAUDE.md or /.claude/AGENTS.md +``` + +Same walk as root-level, but inside a `.claude/` subdirectory at each level. Useful for keeping instruction files out of the project root. + +### Walk example + +For a working directory of `/repo/crates/core`, instruction files are checked at: + +| Order | Path | Scope | +| ----- | ------------------------------------- | ------------------------------- | +| 1 | `~/.claude/CLAUDE.md` | User global | +| 2 | `/repo/CLAUDE.md` | Project (root) | +| 3 | `/repo/.claude/CLAUDE.md` | Project .claude/ (root) | +| 4 | `/repo/crates/CLAUDE.md` | Project (intermediate) | +| 5 | `/repo/crates/.claude/CLAUDE.md` | Project .claude/ (intermediate) | +| 6 | `/repo/crates/core/CLAUDE.md` | Project (CWD) | +| 7 | `/repo/crates/core/.claude/CLAUDE.md` | Project .claude/ (CWD) | + +Later entries take higher priority — subdirectory-specific instructions override root-level ones. + +## Writing effective instructions + +Instruction files are injected verbatim into the system prompt. Write them as direct guidance: + +```markdown +# CLAUDE.md + +## Coding conventions + +- Use snake_case for function names and SCREAMING_SNAKE_CASE for constants. +- All public functions must have doc comments. +- Error handling: use `anyhow::Result` in application code, `thiserror` for library errors. + +## Project-specific rules + +- Do not modify files in `vendor/` or `generated/`. +- Run `cargo test` after any code change. +- Commit messages follow conventional commits: `type(scope): description`. +``` + +Tips: + +- Keep instructions concise — they consume tokens on every API call. +- Focus on rules the assistant can't infer from the code itself. +- Use the global file (`~/.claude/CLAUDE.md`) for personal preferences that apply everywhere. +- Use project-level files for project-specific conventions, build commands, and constraints. diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md new file mode 100644 index 00000000..1dd4a99b --- /dev/null +++ b/docs/guide/quickstart.md @@ -0,0 +1,62 @@ +# Quickstart + +## Install + +Requires [Rust](https://www.rust-lang.org/tools/install) 1.91+ (edition 2024). + +```bash +cargo install --path crates/oxide-code +``` + +## Set up credentials + +oxide-code needs an Anthropic API credential. The simplest way is to set your API key: + +```bash +export ANTHROPIC_API_KEY=sk-ant-... +``` + +Alternatively, if you have [Claude Code](https://code.claude.com/docs) installed and authenticated, oxide-code can use its OAuth credentials automatically. See [Configuration](configuration.md) for details. + +## Start a session + +```bash +ox +``` + +This opens an interactive REPL. Type a task and press Enter: + +```text +> Read main.rs and explain the agent loop. +``` + +The assistant reads files, runs commands, and edits code using its built-in tools. It loops — calling tools and feeding results back — until it produces a final text response. + +## What it can do + +oxide-code has six built-in tools: + +| Tool | Purpose | +| ------- | ------------------------------- | +| `bash` | Run shell commands | +| `read` | Read files with line numbers | +| `write` | Create or overwrite files | +| `edit` | Replace exact strings in files | +| `glob` | Find files by pattern | +| `grep` | Search file contents with regex | + +The assistant decides which tools to use based on your request. You can guide it by being specific: "edit the function signature in `src/lib.rs`" is better than "fix the code". + +## Customize behavior + +Drop a `CLAUDE.md` file in your project root to give the assistant project-specific instructions: + +```markdown +# CLAUDE.md + +- Use snake_case for all function names. +- Run `cargo test` after making changes. +- Do not modify files in the `vendor/` directory. +``` + +See [Instruction Files](instructions.md) for the full discovery hierarchy. From 8aa7a502c77e1838f4d95b34d57a67b62e18f717 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 23:25:45 +0800 Subject: [PATCH 19/32] docs: add quickstart next steps --- docs/guide/quickstart.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md index 1dd4a99b..b95172dd 100644 --- a/docs/guide/quickstart.md +++ b/docs/guide/quickstart.md @@ -60,3 +60,8 @@ Drop a `CLAUDE.md` file in your project root to give the assistant project-speci ``` See [Instruction Files](instructions.md) for the full discovery hierarchy. + +## Next steps + +- [Configuration](configuration.md) — API credentials, model selection, and all environment variables. +- [Instruction Files](instructions.md) — discovery hierarchy, global vs. project scope, writing tips. From 29e22362a9515151010da5f3257cddfb1f795e94 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 23:39:24 +0800 Subject: [PATCH 20/32] style(prompt): reorder test sections to match production function order --- crates/oxide-code/src/prompt/environment.rs | 54 ++++++++++----------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/crates/oxide-code/src/prompt/environment.rs b/crates/oxide-code/src/prompt/environment.rs index bbc2ba28..a9678560 100644 --- a/crates/oxide-code/src/prompt/environment.rs +++ b/crates/oxide-code/src/prompt/environment.rs @@ -126,6 +126,33 @@ fn current_date() -> String { mod tests { use super::*; + // ── Environment::detect ── + + #[tokio::test] + async fn detect_without_cwd_uses_unknown_and_skips_git() { + let env = Environment::detect("test-model", None, None).await; + assert_eq!(env.cwd, "(unknown)"); + assert!(env.git.is_none()); + } + + #[tokio::test] + async fn detect_with_cwd_but_no_git_root_skips_git() { + let tmp = tempfile::tempdir().expect("failed to create tempdir"); + let env = Environment::detect("test-model", Some(tmp.path()), None).await; + assert!(env.git.is_none()); + assert!( + env.cwd + .ends_with(tmp.path().file_name().unwrap().to_str().unwrap()) + ); + } + + #[tokio::test] + async fn detect_inside_repo_populates_git_info() { + let cwd = std::env::current_dir().expect("cwd should be available"); + let env = Environment::detect("test-model", Some(&cwd), Some(&cwd)).await; + assert!(env.git.is_some()); + } + // ── Environment::render ── #[test] @@ -210,33 +237,6 @@ mod tests { assert!(!rendered.contains("Branch:")); } - // ── Environment::detect ── - - #[tokio::test] - async fn detect_without_cwd_uses_unknown_and_skips_git() { - let env = Environment::detect("test-model", None, None).await; - assert_eq!(env.cwd, "(unknown)"); - assert!(env.git.is_none()); - } - - #[tokio::test] - async fn detect_with_cwd_but_no_git_root_skips_git() { - let tmp = tempfile::tempdir().expect("failed to create tempdir"); - let env = Environment::detect("test-model", Some(tmp.path()), None).await; - assert!(env.git.is_none()); - assert!( - env.cwd - .ends_with(tmp.path().file_name().unwrap().to_str().unwrap()) - ); - } - - #[tokio::test] - async fn detect_inside_repo_populates_git_info() { - let cwd = std::env::current_dir().expect("cwd should be available"); - let env = Environment::detect("test-model", Some(&cwd), Some(&cwd)).await; - assert!(env.git.is_some()); - } - // ── current_date ── #[test] From 05320b3e398b88f8de7e0c1908761da8f1eca5fb Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 23:39:28 +0800 Subject: [PATCH 21/32] docs(roadmap): mention root-to-CWD walk and AGENTS.md in system prompt entry --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 6f3df2a4..659ea6c0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -33,7 +33,7 @@ The project direction is simple: ### System Prompt - Section-based system prompt builder: identity (OAuth-required prefix), task guidance, tool usage guidance, tone / style. -- CLAUDE.md discovery and injection — user global (`~/.claude/CLAUDE.md`), project root (`CLAUDE.md`), project `.claude/` directory (`.claude/CLAUDE.md`). +- CLAUDE.md / AGENTS.md discovery and injection — user global (`~/.claude/`), project root to CWD walk (root-level and `.claude/` at each directory level). Fallback filename: first found wins per location. - Runtime environment detection — working directory, platform, shell, git info (branch, clean / dirty status), date, model name. ## Current Focus From 8bf04a2adff5ea2f7a68a73316e29fb89bd54412 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 23:39:31 +0800 Subject: [PATCH 22/32] refactor(prompt): extract assemble() for testable prompt construction --- crates/oxide-code/src/prompt.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/oxide-code/src/prompt.rs b/crates/oxide-code/src/prompt.rs index ebfc1444..2d6e0cc7 100644 --- a/crates/oxide-code/src/prompt.rs +++ b/crates/oxide-code/src/prompt.rs @@ -75,9 +75,8 @@ const STYLE: &str = "\ /// Build the complete system prompt for the agent. /// -/// The prompt always begins with [`IDENTITY_PREFIX`] (required for OAuth) -/// followed by static guidance sections, a detected environment section, and -/// any discovered CLAUDE.md user instructions. +/// Resolves the working directory and git root automatically, then delegates +/// to [`assemble`]. pub(crate) async fn build_system_prompt(model: &str) -> String { let cwd = std::env::current_dir().ok(); let git_root = match &cwd { @@ -85,9 +84,18 @@ pub(crate) async fn build_system_prompt(model: &str) -> String { None => None, }; + assemble(model, cwd.as_deref(), git_root.as_deref()).await +} + +/// Assemble the system prompt from explicit path parameters. +/// +/// The prompt always begins with [`IDENTITY_PREFIX`] (required for OAuth) +/// followed by static guidance sections, a detected environment section, and +/// any discovered CLAUDE.md user instructions. +async fn assemble(model: &str, cwd: Option<&Path>, git_root: Option<&Path>) -> String { let (env, claude_md) = tokio::join!( - Environment::detect(model, cwd.as_deref(), git_root.as_deref()), - instructions::load(cwd.as_deref(), git_root.as_deref()), + Environment::detect(model, cwd, git_root), + instructions::load(cwd, git_root), ); let mut sections = vec![ From a893a4bb2d325112310e481244d43f8d0c2ba310 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 23:41:24 +0800 Subject: [PATCH 23/32] test(prompt): add integration tests for assemble with controlled git repos --- crates/oxide-code/src/prompt.rs | 73 +++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/crates/oxide-code/src/prompt.rs b/crates/oxide-code/src/prompt.rs index 2d6e0cc7..482b89b8 100644 --- a/crates/oxide-code/src/prompt.rs +++ b/crates/oxide-code/src/prompt.rs @@ -191,6 +191,67 @@ mod tests { ); } + // ── assemble ── + + #[tokio::test] + async fn assemble_in_git_repo_includes_all_sections_in_order() { + let tmp = tempfile::tempdir().expect("failed to create tempdir"); + init_git_repo(tmp.path()); + std::fs::write(tmp.path().join("CLAUDE.md"), "Test project rules.").unwrap(); + + let prompt = assemble("test-model", Some(tmp.path()), Some(tmp.path())).await; + + let expected_headers = [ + IDENTITY_PREFIX, + "# Doing tasks", + "# Executing actions with care", + "# Using your tools", + "# Tone and style", + "# Environment", + "# User instructions", + ]; + let mut prev_pos = 0; + for header in &expected_headers { + let pos = prompt + .find(header) + .unwrap_or_else(|| panic!("missing section: {header}")); + assert!( + pos >= prev_pos, + "{header} should come after previous section" + ); + prev_pos = pos; + } + + assert!(prompt.contains(&format!("Working directory: {}", tmp.path().display()))); + assert!(prompt.contains("Is a git repository: true")); + assert!(prompt.contains("Model: test-model")); + assert!(prompt.contains("Test project rules.")); + } + + #[tokio::test] + async fn assemble_walks_root_to_cwd_for_instruction_discovery() { + let tmp = tempfile::tempdir().expect("failed to create tempdir"); + let root = tmp.path(); + init_git_repo(root); + + std::fs::write(root.join("CLAUDE.md"), "Root rules.").unwrap(); + let sub = root.join("crates").join("core"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(sub.join("CLAUDE.md"), "Subdir rules.").unwrap(); + + let prompt = assemble("test-model", Some(&sub), Some(root)).await; + + assert!(prompt.contains("Root rules.")); + assert!(prompt.contains("Subdir rules.")); + + let root_pos = prompt.find("Root rules.").unwrap(); + let sub_pos = prompt.find("Subdir rules.").unwrap(); + assert!( + root_pos < sub_pos, + "root instructions should appear before subdirectory" + ); + } + // ── find_git_root ── #[tokio::test] @@ -210,4 +271,16 @@ mod tests { let root = find_git_root(tmp.path()).await; assert!(root.is_none()); } + + // ── helpers ── + + fn init_git_repo(path: &Path) { + std::process::Command::new("git") + .args(["init"]) + .current_dir(path) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .expect("git init failed"); + } } From eae7194b505d098fc831336de39b5d6b957c89c5 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 23:42:43 +0800 Subject: [PATCH 24/32] fix(prompt): check git command exit status before reading stdout --- crates/oxide-code/src/prompt/environment.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/oxide-code/src/prompt/environment.rs b/crates/oxide-code/src/prompt/environment.rs index a9678560..6a799e56 100644 --- a/crates/oxide-code/src/prompt/environment.rs +++ b/crates/oxide-code/src/prompt/environment.rs @@ -99,10 +99,12 @@ async fn detect_git_info(cwd: &Path) -> GitInfo { // dirty when a command fails, rather than discarding all git info. let branch = branch_result .ok() + .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()) .unwrap_or_default(); let is_clean = status_result .ok() + .filter(|o| o.status.success()) .is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim().is_empty()); GitInfo { branch, is_clean } From 3aa6309b32f0aac29c1170808786b76cf8d9a869 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 23:42:46 +0800 Subject: [PATCH 25/32] docs(guide): fix instruction file discovery timing from startup to per-turn --- docs/guide/instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/instructions.md b/docs/guide/instructions.md index 1b21fa71..bd22941c 100644 --- a/docs/guide/instructions.md +++ b/docs/guide/instructions.md @@ -1,6 +1,6 @@ # Instruction Files -Instruction files let you customize the assistant's behavior with persistent, project-specific context. They are Markdown files discovered automatically at startup and injected into the system prompt. +Instruction files let you customize the assistant's behavior with persistent, project-specific context. They are Markdown files discovered automatically each turn and injected into the system prompt. ## Supported filenames From 4d472934f06860f0478f5d8fde3555da234a39b0 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 23:43:33 +0800 Subject: [PATCH 26/32] style(oxide-code): replace let _ = with _ = for consistency --- crates/oxide-code/src/client/anthropic.rs | 2 +- crates/oxide-code/src/config/oauth.rs | 4 ++-- crates/oxide-code/src/prompt/instructions.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/oxide-code/src/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index a63be2ae..8c752bd6 100644 --- a/crates/oxide-code/src/client/anthropic.rs +++ b/crates/oxide-code/src/client/anthropic.rs @@ -243,7 +243,7 @@ impl Client { tokio::spawn(async move { let result = stream_sse(&http, &url, &body, &tx).await; if let Err(e) = result { - let _ = tx.send(Err(e)).await; + _ = tx.send(Err(e)).await; } }); diff --git a/crates/oxide-code/src/config/oauth.rs b/crates/oxide-code/src/config/oauth.rs index 15e8fdd0..7f941f42 100644 --- a/crates/oxide-code/src/config/oauth.rs +++ b/crates/oxide-code/src/config/oauth.rs @@ -302,7 +302,7 @@ struct LockGuard { impl Drop for LockGuard { fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.path); + _ = std::fs::remove_dir_all(&self.path); } } @@ -318,7 +318,7 @@ async fn acquire_lock(path: &Path) -> Result { } Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { if is_stale_lock(path) { - let _ = std::fs::remove_dir_all(path); + _ = std::fs::remove_dir_all(path); continue; } if attempt == LOCK_MAX_RETRIES { diff --git a/crates/oxide-code/src/prompt/instructions.rs b/crates/oxide-code/src/prompt/instructions.rs index 2c4d1968..1ad26226 100644 --- a/crates/oxide-code/src/prompt/instructions.rs +++ b/crates/oxide-code/src/prompt/instructions.rs @@ -151,7 +151,7 @@ fn render(files: &[MemoryFile]) -> String { ); for file in files { - let _ = write!( + _ = write!( out, "\n\nContents of {} ({}):\n\n{}", file.path.display(), From 6e816c6310c95dfa4eeff6cf7fd301ac4d3405f4 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 23:52:27 +0800 Subject: [PATCH 27/32] style(prompt): reorder tests within sections to happy path first --- crates/oxide-code/src/prompt/environment.rs | 16 +++--- crates/oxide-code/src/prompt/instructions.rs | 60 ++++++++++---------- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/crates/oxide-code/src/prompt/environment.rs b/crates/oxide-code/src/prompt/environment.rs index 6a799e56..7fa11ec3 100644 --- a/crates/oxide-code/src/prompt/environment.rs +++ b/crates/oxide-code/src/prompt/environment.rs @@ -131,10 +131,10 @@ mod tests { // ── Environment::detect ── #[tokio::test] - async fn detect_without_cwd_uses_unknown_and_skips_git() { - let env = Environment::detect("test-model", None, None).await; - assert_eq!(env.cwd, "(unknown)"); - assert!(env.git.is_none()); + async fn detect_inside_repo_populates_git_info() { + let cwd = std::env::current_dir().expect("cwd should be available"); + let env = Environment::detect("test-model", Some(&cwd), Some(&cwd)).await; + assert!(env.git.is_some()); } #[tokio::test] @@ -149,10 +149,10 @@ mod tests { } #[tokio::test] - async fn detect_inside_repo_populates_git_info() { - let cwd = std::env::current_dir().expect("cwd should be available"); - let env = Environment::detect("test-model", Some(&cwd), Some(&cwd)).await; - assert!(env.git.is_some()); + async fn detect_without_cwd_uses_unknown_and_skips_git() { + let env = Environment::detect("test-model", None, None).await; + assert_eq!(env.cwd, "(unknown)"); + assert!(env.git.is_none()); } // ── Environment::render ── diff --git a/crates/oxide-code/src/prompt/instructions.rs b/crates/oxide-code/src/prompt/instructions.rs index 1ad26226..3ff1a806 100644 --- a/crates/oxide-code/src/prompt/instructions.rs +++ b/crates/oxide-code/src/prompt/instructions.rs @@ -279,19 +279,6 @@ mod tests { // ── load_files ── - #[tokio::test] - async fn load_files_returns_empty_when_no_files_exist() { - let slots = vec![Slot { - candidates: vec![ - PathBuf::from("/nonexistent/CLAUDE.md"), - PathBuf::from("/nonexistent/AGENTS.md"), - ], - label: "test", - }]; - let files = load_files(slots).await; - assert!(files.is_empty()); - } - #[tokio::test] async fn load_files_prefers_first_candidate() { let dir = tempfile::tempdir().expect("failed to create tempdir"); @@ -326,23 +313,6 @@ mod tests { assert_eq!(files[0].content, "agents content"); } - #[tokio::test] - async fn load_files_skips_whitespace_only_files() { - let dir = tempfile::tempdir().expect("failed to create tempdir"); - let empty_path = dir.path().join("CLAUDE.md"); - let agents_path = dir.path().join("AGENTS.md"); - fs::write(&empty_path, " \n ").await.unwrap(); - fs::write(&agents_path, "real content").await.unwrap(); - - let slots = vec![Slot { - candidates: vec![empty_path, agents_path.clone()], - label: "test", - }]; - let files = load_files(slots).await; - assert_eq!(files.len(), 1); - assert_eq!(files[0].path, agents_path); - } - #[tokio::test] async fn load_files_collects_one_file_per_slot() { let dir = tempfile::tempdir().expect("failed to create tempdir"); @@ -367,6 +337,36 @@ mod tests { assert_eq!(files[1].path, b_path); } + #[tokio::test] + async fn load_files_skips_whitespace_only_files() { + let dir = tempfile::tempdir().expect("failed to create tempdir"); + let empty_path = dir.path().join("CLAUDE.md"); + let agents_path = dir.path().join("AGENTS.md"); + fs::write(&empty_path, " \n ").await.unwrap(); + fs::write(&agents_path, "real content").await.unwrap(); + + let slots = vec![Slot { + candidates: vec![empty_path, agents_path.clone()], + label: "test", + }]; + let files = load_files(slots).await; + assert_eq!(files.len(), 1); + assert_eq!(files[0].path, agents_path); + } + + #[tokio::test] + async fn load_files_returns_empty_when_no_files_exist() { + let slots = vec![Slot { + candidates: vec![ + PathBuf::from("/nonexistent/CLAUDE.md"), + PathBuf::from("/nonexistent/AGENTS.md"), + ], + label: "test", + }]; + let files = load_files(slots).await; + assert!(files.is_empty()); + } + // ── render ── #[test] From 7a7c4caa3b85912d7a052f588f3cd01f45a1a014 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 23:59:23 +0800 Subject: [PATCH 28/32] test(prompt): add load tests for cwd fallback and .claude/ directory discovery --- crates/oxide-code/src/prompt/instructions.rs | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/oxide-code/src/prompt/instructions.rs b/crates/oxide-code/src/prompt/instructions.rs index 3ff1a806..f157b9c1 100644 --- a/crates/oxide-code/src/prompt/instructions.rs +++ b/crates/oxide-code/src/prompt/instructions.rs @@ -167,6 +167,38 @@ fn render(files: &[MemoryFile]) -> String { mod tests { use super::*; + // ── load ── + + #[tokio::test] + async fn load_uses_cwd_as_fallback_root_when_git_root_is_none() { + let dir = tempfile::tempdir().expect("failed to create tempdir"); + fs::write(dir.path().join("CLAUDE.md"), "Fallback rules.") + .await + .unwrap(); + + let result = load(Some(dir.path()), None).await; + assert!( + result.contains("Fallback rules."), + "should discover CLAUDE.md using cwd as project root" + ); + } + + #[tokio::test] + async fn load_discovers_claude_dir_instructions() { + let dir = tempfile::tempdir().expect("failed to create tempdir"); + let claude_dir = dir.path().join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + fs::write(claude_dir.join("CLAUDE.md"), "Hidden rules.") + .await + .unwrap(); + + let result = load(Some(dir.path()), Some(dir.path())).await; + assert!( + result.contains("Hidden rules."), + "should discover .claude/CLAUDE.md" + ); + } + // ── candidate_slots ── #[test] From 806a5655e95d04614f39696b6ac68ca2378a1f4b Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 23:59:27 +0800 Subject: [PATCH 29/32] docs(prompt): note now_local() falls back to UTC on multi-threaded Linux --- crates/oxide-code/src/prompt/environment.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/oxide-code/src/prompt/environment.rs b/crates/oxide-code/src/prompt/environment.rs index 7fa11ec3..0fd6f3e3 100644 --- a/crates/oxide-code/src/prompt/environment.rs +++ b/crates/oxide-code/src/prompt/environment.rs @@ -113,6 +113,8 @@ async fn detect_git_info(cwd: &Path) -> GitInfo { // ── Date Detection ── fn current_date() -> String { + // now_local() fails on multi-threaded Linux (time crate safety constraint), + // so this effectively falls back to UTC there. let date = time::OffsetDateTime::now_local() .unwrap_or_else(|_| time::OffsetDateTime::now_utc()) .date(); From f35f55964627652f113919f364aa8ea411d28abd Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 23:59:31 +0800 Subject: [PATCH 30/32] fix(prompt): strengthen identity prefix assertion and init_git_repo check --- crates/oxide-code/src/prompt.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/oxide-code/src/prompt.rs b/crates/oxide-code/src/prompt.rs index 482b89b8..5d6f2096 100644 --- a/crates/oxide-code/src/prompt.rs +++ b/crates/oxide-code/src/prompt.rs @@ -148,7 +148,11 @@ mod tests { #[tokio::test] async fn build_system_prompt_starts_with_identity_prefix() { let prompt = build_system_prompt("test-model").await; - assert!(prompt.starts_with(&format!("{IDENTITY_PREFIX}\n"))); + assert_eq!( + prompt.lines().next().unwrap(), + IDENTITY_PREFIX, + "first line must be the identity prefix" + ); } #[tokio::test] @@ -275,12 +279,13 @@ mod tests { // ── helpers ── fn init_git_repo(path: &Path) { - std::process::Command::new("git") + let status = std::process::Command::new("git") .args(["init"]) .current_dir(path) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() - .expect("git init failed"); + .expect("failed to spawn git"); + assert!(status.success(), "git init exited with non-zero status"); } } From 88a256dc086411bce15fdc01d2cac1a89bef79c0 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Mon, 6 Apr 2026 00:20:37 +0800 Subject: [PATCH 31/32] fix(client): send system prompt as array with prefix in its own block The Anthropic API requires the identity prefix to be a separate text block in the system array for OAuth validation. Concatenating it into the prompt body as a single string causes 429 for non-Haiku models. Move the prefix constant and block construction into the client, and change stream_message to accept Option<&str> for the prompt body. --- crates/oxide-code/src/client/anthropic.rs | 32 ++++++++++++++++++++--- crates/oxide-code/src/main.rs | 2 +- crates/oxide-code/src/prompt.rs | 23 +++++++--------- 3 files changed, 39 insertions(+), 18 deletions(-) diff --git a/crates/oxide-code/src/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index 8c752bd6..685664ef 100644 --- a/crates/oxide-code/src/client/anthropic.rs +++ b/crates/oxide-code/src/client/anthropic.rs @@ -17,6 +17,11 @@ const OAUTH_BETA_HEADER: &str = "oauth-2025-04-20"; /// Matches the referenced Claude Code version. const CLAUDE_CLI_VERSION: &str = "2.1.87"; +/// OAuth-required identity prefix. The Anthropic API returns 429 for non-Haiku +/// models with OAuth tokens unless the system prompt starts with this exact +/// string in its own text block. +const SYSTEM_PROMPT_PREFIX: &str = "You are Claude Code, Anthropic's official CLI for Claude."; + // ── Request types ── #[derive(Serialize)] @@ -24,7 +29,7 @@ struct CreateMessageRequest<'a> { model: &'a str, max_tokens: u32, messages: &'a [Message], - system: &'a str, + system: Vec>, stream: bool, #[serde(skip_serializing_if = "Option::is_none")] tools: Option<&'a [ToolDefinition]>, @@ -32,6 +37,16 @@ struct CreateMessageRequest<'a> { thinking: Option<&'a ThinkingConfig>, } +/// A text block in the system prompt array. The Anthropic API accepts `system` +/// as either a string or an array of these blocks. Using the array form lets +/// the identity prefix occupy its own block, which is required for OAuth +/// validation on non-Haiku models. +#[derive(Serialize)] +struct SystemBlock<'a> { + r#type: &'static str, + text: &'a str, +} + // ── SSE response types ── #[expect( @@ -222,15 +237,26 @@ impl Client { pub fn stream_message( &self, messages: &[Message], - system: &str, + system: Option<&str>, tools: &[ToolDefinition], ) -> Result>> { + let mut system_blocks = vec![SystemBlock { + r#type: "text", + text: SYSTEM_PROMPT_PREFIX, + }]; + if let Some(s) = system { + system_blocks.push(SystemBlock { + r#type: "text", + text: s, + }); + } + let url = format!("{}/v1/messages", self.config.base_url); let body = serde_json::to_value(CreateMessageRequest { model: &self.config.model, max_tokens: self.config.max_tokens, messages, - system, + system: system_blocks, stream: true, tools: (!tools.is_empty()).then_some(tools), thinking: self.config.thinking.as_ref(), diff --git a/crates/oxide-code/src/main.rs b/crates/oxide-code/src/main.rs index f18dd324..444f1ea0 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -218,7 +218,7 @@ async fn stream_response( system_prompt: &str, show_thinking: bool, ) -> Result> { - let mut rx = client.stream_message(messages, system_prompt, tools)?; + let mut rx = client.stream_message(messages, Some(system_prompt), tools)?; let mut blocks: Vec> = Vec::new(); let mut stdout = std::io::stdout(); diff --git a/crates/oxide-code/src/prompt.rs b/crates/oxide-code/src/prompt.rs index 5d6f2096..a445e4ed 100644 --- a/crates/oxide-code/src/prompt.rs +++ b/crates/oxide-code/src/prompt.rs @@ -7,10 +7,6 @@ use tokio::process::Command; use environment::Environment; -/// OAuth-required identity prefix. The Anthropic API returns 429 for non-Haiku -/// models with OAuth tokens unless the system prompt starts with this string. -const IDENTITY_PREFIX: &str = "You are Claude Code, Anthropic's official CLI for Claude."; - const IDENTITY: &str = "\ You are an interactive AI assistant that helps with software engineering tasks. \ Use the tools available to you to assist the user. @@ -89,9 +85,9 @@ pub(crate) async fn build_system_prompt(model: &str) -> String { /// Assemble the system prompt from explicit path parameters. /// -/// The prompt always begins with [`IDENTITY_PREFIX`] (required for OAuth) -/// followed by static guidance sections, a detected environment section, and -/// any discovered CLAUDE.md user instructions. +/// The identity prefix required for OAuth is handled by the API client as a +/// separate system block. This function builds the remaining prompt content: +/// identity body, static guidance sections, environment, and user instructions. async fn assemble(model: &str, cwd: Option<&Path>, git_root: Option<&Path>) -> String { let (env, claude_md) = tokio::join!( Environment::detect(model, cwd, git_root), @@ -99,7 +95,7 @@ async fn assemble(model: &str, cwd: Option<&Path>, git_root: Option<&Path>) -> S ); let mut sections = vec![ - format!("{IDENTITY_PREFIX}\n{IDENTITY}"), + IDENTITY.to_owned(), TASK_GUIDANCE.to_owned(), CAUTION.to_owned(), TOOL_GUIDANCE.to_owned(), @@ -146,12 +142,11 @@ mod tests { // ── build_system_prompt ── #[tokio::test] - async fn build_system_prompt_starts_with_identity_prefix() { + async fn build_system_prompt_starts_with_identity() { let prompt = build_system_prompt("test-model").await; - assert_eq!( - prompt.lines().next().unwrap(), - IDENTITY_PREFIX, - "first line must be the identity prefix" + assert!( + prompt.starts_with("You are an interactive AI assistant"), + "prompt should start with identity body (prefix is in the client)" ); } @@ -206,7 +201,7 @@ mod tests { let prompt = assemble("test-model", Some(tmp.path()), Some(tmp.path())).await; let expected_headers = [ - IDENTITY_PREFIX, + "You are an interactive AI assistant", "# Doing tasks", "# Executing actions with care", "# Using your tools", From 2558787a4658acd4bd059ce90828c790eeb6d78a Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Mon, 6 Apr 2026 00:20:42 +0800 Subject: [PATCH 32/32] docs(research): document system block format, attribution header, and third-party restrictions --- docs/research/anthropic-api.md | 68 ++++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/docs/research/anthropic-api.md b/docs/research/anthropic-api.md index 149c4c9d..9beed454 100644 --- a/docs/research/anthropic-api.md +++ b/docs/research/anthropic-api.md @@ -58,15 +58,46 @@ Additional useful betas: | `effort-2025-11-24` | Effort control | | `advanced-tool-use-2025-11-20` | Tool search (first-party only) | -### 2. System prompt prefix +### 2. System prompt prefix (as a separate block) -```text -You are Claude Code, Anthropic's official CLI for Claude. +The `system` parameter must be sent as an **array of text blocks**, not a plain string. The identity prefix must occupy its own block: + +```json +"system": [ + {"type": "text", "text": "You are Claude Code, Anthropic's official CLI for Claude."}, + {"type": "text", "text": "...rest of prompt..."} +] +``` + +The API validates that the **first non-attribution text block** matches one of the known prefix values: + +- `"You are Claude Code, Anthropic's official CLI for Claude."` +- `"You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK."` +- `"You are a Claude agent, built on Anthropic's Claude Agent SDK."` + +**Critical**: Concatenating the prefix into the prompt body as a single string causes the API to reject OAuth requests with 429, even though the same prefix content is present. The block-level separation is what the server checks. + +### 3. Attribution header (optional, recommended) + +Claude Code prepends an attribution header as the very first system block: + +```json +{"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.87.a3f; cc_entrypoint=cli;"} ``` -This must be the start of the system prompt. The API server uses it to identify legitimate Claude Code clients and apply correct rate limits. **Without this prefix, OAuth requests for Opus and Sonnet models return 429.** +Format: `x-anthropic-billing-header: cc_version=.; cc_entrypoint=;` + +The fingerprint is a 3-character hex value computed per request: + +1. Extract characters at indices `[4, 7, 20]` from the first user message text (use `"0"` if index is out of bounds). +2. Compute `SHA256(SALT + chars + VERSION)`, take the first 3 hex characters. +3. Salt: `59cf53e54c78` (hardcoded, must match server). + +The entrypoint is `cli` for interactive sessions. + +When `NATIVE_CLIENT_ATTESTATION` is enabled (compile-time feature flag in Bun), the header also includes a `cch=00000` placeholder that Bun's native HTTP stack (Zig) overwrites with a computed attestation token before sending. This is a tamper-proof mechanism that third-party tools cannot replicate. -### 3. Client identity headers +### 4. Client identity headers ```text User-Agent: claude-cli/ (external, cli) @@ -77,11 +108,23 @@ The `User-Agent` must start with `claude-cli/`. Claude Code constructs it as `cl ## What Happens Without These -| Missing | Haiku 4.5 | Sonnet / Opus | -| ---------------------- | --------- | ------------- | -| `claude-code-20250219` | 200 | 429 | -| `oauth-2025-04-20` | 401 | 401 | -| System prompt prefix | 200 | 429 | +| Missing | Haiku 4.5 | Sonnet / Opus | +| ------------------------ | --------- | ------------- | +| `claude-code-20250219` | 200 | 429 | +| `oauth-2025-04-20` | 401 | 401 | +| Prefix as separate block | 200 | 429 | +| Prefix in body string | 200 | 429 | + +The last two rows are the critical distinction: having the prefix present in a concatenated string is **not sufficient**. It must be a separate `{"type": "text", "text": "..."}` block in the system array. + +## Third-Party Tool Restrictions + +As of April 4, 2026, Anthropic enforces that OAuth subscription credits (Pro / Max) are only valid for official Claude Code and claude.ai clients. Third-party tools that reuse the OAuth flow are classified as "third-party harness traffic" and must use either: + +- **API key** (`ANTHROPIC_API_KEY`) with standard per-token billing. +- **Extra Usage** billing enabled on the account, which allows OAuth but bills per-token beyond the subscription. + +The native client attestation (`cch` in the attribution header) is the primary technical enforcement mechanism. Third-party tools cannot compute the attestation token since it requires Anthropic's custom Bun binary. Without valid attestation, subscription-tier rate limits are not applied. ## API Version @@ -112,11 +155,14 @@ oxide-code implements the same refresh flow: proactive refresh with the 5-minute - `claude-code/src/constants/betas.ts` — beta header constants - `claude-code/src/constants/oauth.ts` — OAuth client ID, token URL, scopes -- `claude-code/src/constants/system.ts` — system prompt prefix +- `claude-code/src/constants/system.ts` — system prompt prefix, attribution header construction +- `claude-code/src/services/api/claude.ts` — system block assembly, `buildSystemPromptBlocks` - `claude-code/src/services/api/client.ts` — SDK client construction - `claude-code/src/services/oauth/client.ts` — token refresh endpoint and request format +- `claude-code/src/utils/api.ts` — `splitSysPromptPrefix`, cache scope assignment - `claude-code/src/utils/auth.ts` — OAuth token retrieval and refresh - `claude-code/src/utils/betas.ts` — per-model beta header computation +- `claude-code/src/utils/fingerprint.ts` — 3-char SHA-256 fingerprint (salt, indices, computation) - `claude-code/src/utils/http.ts` — auth headers, User-Agent construction - `claude-code/src/utils/userAgent.ts` — `claude-cli/` User-Agent format - `claude-code/src/utils/secureStorage/index.ts` — platform-specific storage dispatch