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/CLAUDE.md b/CLAUDE.md index f0962673..a5c6d1ba 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/ +│ ├── 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/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/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/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/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index 99c1a458..685664ef 100644 --- a/crates/oxide-code/src/client/anthropic.rs +++ b/crates/oxide-code/src/client/anthropic.rs @@ -17,9 +17,9 @@ 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. +/// 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 ── @@ -29,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]>, @@ -37,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( @@ -230,17 +240,23 @@ impl Client { system: Option<&str>, tools: &[ToolDefinition], ) -> Result>> { - let system_prompt = match system { - Some(s) => format!("{SYSTEM_PROMPT_PREFIX}\n{s}"), - None => SYSTEM_PROMPT_PREFIX.to_owned(), - }; + 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_prompt, + system: system_blocks, stream: true, tools: (!tools.is_empty()).then_some(tools), thinking: self.config.thinking.as_ref(), @@ -253,7 +269,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/main.rs b/crates/oxide-code/src/main.rs index 346dc3ae..444f1ea0 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 model = config.model.clone(); 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, &model, show_thinking).await } -async fn repl(client: &Client, tools: &ToolRegistry, show_thinking: bool) -> Result<()> { +async fn repl( + client: &Client, + tools: &ToolRegistry, + model: &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,8 @@ 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?; + let system_prompt = prompt::build_system_prompt(model).await; + agent_turn(client, tools, &mut messages, &system_prompt, show_thinking).await?; } Ok(()) @@ -76,13 +84,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 +215,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, 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 new file mode 100644 index 00000000..a445e4ed --- /dev/null +++ b/crates/oxide-code/src/prompt.rs @@ -0,0 +1,286 @@ +mod environment; +mod instructions; + +use std::path::{Path, PathBuf}; + +use tokio::process::Command; + +use environment::Environment; + +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 + +- 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 — 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 + +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. +- 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. +/// +/// 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 { + Some(cwd) => find_git_root(cwd).await, + None => None, + }; + + assemble(model, cwd.as_deref(), git_root.as_deref()).await +} + +/// Assemble the system prompt from explicit path parameters. +/// +/// 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), + instructions::load(cwd, git_root), + ); + + let mut sections = vec![ + IDENTITY.to_owned(), + TASK_GUIDANCE.to_owned(), + CAUTION.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() { + let prompt = build_system_prompt("test-model").await; + assert!( + prompt.starts_with("You are an interactive AI assistant"), + "prompt should start with identity body (prefix is in the client)" + ); + } + + #[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("# Executing actions with care")); + 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")); + } + + /// 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" + ); + } + + // ── 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 = [ + "You are an interactive AI assistant", + "# 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] + 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()); + } + + // ── helpers ── + + fn init_git_repo(path: &Path) { + let status = std::process::Command::new("git") + .args(["init"]) + .current_dir(path) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .expect("failed to spawn git"); + assert!(status.success(), "git init exited with non-zero status"); + } +} diff --git a/crates/oxide-code/src/prompt/environment.rs b/crates/oxide-code/src/prompt/environment.rs new file mode 100644 index 00000000..0fd6f3e3 --- /dev/null +++ b/crates/oxide-code/src/prompt/environment.rs @@ -0,0 +1,256 @@ +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() => 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(); + + 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) -> GitInfo { + 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(), + ); + + // 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() + .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 } +} + +// ── 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(); + format!( + "{}-{:02}-{:02}", + date.year(), + date.month() as u8, + date.day() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Environment::detect ── + + #[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()); + } + + #[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_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 ── + + #[test] + fn render_with_git_shows_all_fields_in_order() { + 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(); + 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] + 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(); + 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] + 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 ── + + #[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}"); + } +} diff --git a/crates/oxide-code/src/prompt/instructions.rs b/crates/oxide-code/src/prompt/instructions.rs new file mode 100644 index 00000000..f157b9c1 --- /dev/null +++ b/crates/oxide-code/src/prompt/instructions.rs @@ -0,0 +1,447 @@ +use std::path::{Path, PathBuf}; + +use tokio::fs; + +/// 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 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, + content: String, + label: &'static str, +} + +/// Discover and load instruction files, returning the formatted section for the +/// system prompt. +/// +/// At each directory level, filenames are checked in +/// [`INSTRUCTION_FILENAMES`] order — the first file found wins. Discovery +/// 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. 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 +/// 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 slots = candidate_slots(cwd, project_root); + let files = load_files(slots).await; + + if files.is_empty() { + return String::new(); + } + + render(&files) +} + +/// 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 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 { + let mut slots = Vec::new(); + + if let Some(home) = dirs::home_dir() { + slots.push(Slot { + candidates: INSTRUCTION_FILENAMES + .iter() + .map(|f| home.join(".claude").join(f)) + .collect(), + label: "user's global instructions", + }); + } + + if let Some(root) = project_root { + for dir in walk_root_to_cwd(root, cwd) { + 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(), + label: "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()]; + }; + + // 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() { + 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 { + let mut files = Vec::new(); + + 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(); + if !content.is_empty() { + files.push(MemoryFile { + path, + content, + label, + }); + break; + } + } + } + } + + 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 { + _ = write!( + out, + "\n\nContents of {} ({}):\n\n{}", + file.path.display(), + file.label, + file.content, + ); + } + + out +} + +#[cfg(test)] +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] + fn candidate_slots_cwd_equals_root() { + let root = PathBuf::from("/home/user/project"); + let slots = candidate_slots(Some(&root), Some(&root)); + + let project: Vec<_> = slots + .iter() + .filter(|s| s.label == "project instructions") + .collect(); + assert_eq!(project.len(), 1); + assert_eq!( + 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] + 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() + .filter(|s| s.label == "project instructions") + .collect(); + // 3 levels: /repo, /repo/crates, /repo/crates/core + assert_eq!(project.len(), 3); + 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] + fn candidate_slots_without_project_root_still_includes_global() { + let slots = candidate_slots(None, None); + + if let Some(home) = dirs::home_dir() { + assert_eq!(slots.len(), 1); + assert_eq!(slots[0].label, "user's global instructions"); + assert_eq!( + slots[0].candidates, + vec![ + home.join(".claude").join("CLAUDE.md"), + home.join(".claude").join("AGENTS.md"), + ] + ); + } else { + assert!(slots.is_empty()); + } + } + + // ── 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")]); + } + + // ── load_files ── + + #[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_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); + } + + #[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] + 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" + ); + } + + #[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.")); + } +} diff --git a/docs/README.md b/docs/README.md index 65814edf..839ab5c7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,8 +1,16 @@ # Documentation Index -Internal docs for oxide-code development: research findings, architecture notes, and project status. +## User Guide -| 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 | +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 | +| -------------------------------------------------------------- | ---------------------------------------------------------------------- | +| [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/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..bd22941c --- /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 each turn 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..b95172dd --- /dev/null +++ b/docs/guide/quickstart.md @@ -0,0 +1,67 @@ +# 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. + +## 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. 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 diff --git a/docs/research/system-prompt.md b/docs/research/system-prompt.md new file mode 100644 index 00000000..722b2e66 --- /dev/null +++ b/docs/research/system-prompt.md @@ -0,0 +1,95 @@ +# System Prompt Architecture + +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 + +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 | + +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. +- **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 (TypeScript / Bun) uses a similar hierarchical approach: + +- **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` +- `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/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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 8efa4268..659ea6c0 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 / 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. -## Next Phase +## Current Focus ### Terminal UI @@ -52,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