Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5acf1e6
feat(prompt): add system prompt builder with CLAUDE.md injection
hakula139 Apr 5, 2026
ecb648a
docs(roadmap): move system prompt to working, advance current focus
hakula139 Apr 5, 2026
8ea7dc6
fix(prompt): address review findings
hakula139 Apr 5, 2026
8f0bbf5
docs(research): add system prompt architecture notes
hakula139 Apr 5, 2026
2fd7e25
feat(prompt): add AGENTS.md as instruction file fallback
hakula139 Apr 5, 2026
876bd09
refactor(prompt): rename claude_md to instructions, fix research doc
hakula139 Apr 5, 2026
3ffb654
fix(prompt): include AGENTS.md fallback in global slot
hakula139 Apr 5, 2026
a503d06
docs(roadmap): note configurable instruction directories under config
hakula139 Apr 5, 2026
7d194a4
feat(prompt): walk project root to CWD for instruction files
hakula139 Apr 5, 2026
7572ec0
refactor(prompt): simplify environment detection
hakula139 Apr 5, 2026
be0726b
fix(prompt): rebuild system prompt per user message
hakula139 Apr 5, 2026
9c56644
refactor(prompt): introduce Slot type for instruction discovery
hakula139 Apr 5, 2026
fa5d460
test(prompt): add coverage for find_git_root and build_system_prompt
hakula139 Apr 5, 2026
c587068
docs(research): update system prompt research with walk behavior and …
hakula139 Apr 5, 2026
7e5c1c7
feat(prompt): strengthen system prompt with caution and security guid…
hakula139 Apr 5, 2026
4971f36
docs(research): use h3 headings for sources subsections
hakula139 Apr 5, 2026
bcc09fc
style(prompt): add blank line before caution bullet list
hakula139 Apr 5, 2026
1ea04c0
docs: add user-facing guide with quickstart, configuration, and instr…
hakula139 Apr 5, 2026
8aa7a50
docs: add quickstart next steps
hakula139 Apr 5, 2026
29e2236
style(prompt): reorder test sections to match production function order
hakula139 Apr 5, 2026
05320b3
docs(roadmap): mention root-to-CWD walk and AGENTS.md in system promp…
hakula139 Apr 5, 2026
8bf04a2
refactor(prompt): extract assemble() for testable prompt construction
hakula139 Apr 5, 2026
a893a4b
test(prompt): add integration tests for assemble with controlled git …
hakula139 Apr 5, 2026
eae7194
fix(prompt): check git command exit status before reading stdout
hakula139 Apr 5, 2026
3aa6309
docs(guide): fix instruction file discovery timing from startup to pe…
hakula139 Apr 5, 2026
4d47293
style(oxide-code): replace let _ = with _ = for consistency
hakula139 Apr 5, 2026
6e816c6
style(prompt): reorder tests within sections to happy path first
hakula139 Apr 5, 2026
7a7c4ca
test(prompt): add load tests for cwd fallback and .claude/ directory …
hakula139 Apr 5, 2026
806a565
docs(prompt): note now_local() falls back to UTC on multi-threaded Linux
hakula139 Apr 5, 2026
f35f559
fix(prompt): strengthen identity prefix assertion and init_git_repo c…
hakula139 Apr 5, 2026
88a256d
fix(client): send system prompt as array with prefix in its own block
hakula139 Apr 5, 2026
2558787
docs(research): document system block format, attribution header, and…
hakula139 Apr 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cspell/words.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
anthropic
anyhow
claudemd
clippy
codex
creds
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 7 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions crates/oxide-code/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 26 additions & 10 deletions crates/oxide-code/src/client/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──
Expand All @@ -29,14 +29,24 @@ struct CreateMessageRequest<'a> {
model: &'a str,
max_tokens: u32,
messages: &'a [Message],
system: &'a str,
system: Vec<SystemBlock<'a>>,
stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<&'a [ToolDefinition]>,
#[serde(skip_serializing_if = "Option::is_none")]
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(
Expand Down Expand Up @@ -230,17 +240,23 @@ impl Client {
system: Option<&str>,
tools: &[ToolDefinition],
) -> Result<mpsc::Receiver<Result<StreamEvent>>> {
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(),
Expand All @@ -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;
}
});

Expand Down
4 changes: 2 additions & 2 deletions crates/oxide-code/src/config/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand All @@ -318,7 +318,7 @@ async fn acquire_lock(path: &Path) -> Result<LockGuard> {
}
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 {
Expand Down
21 changes: 16 additions & 5 deletions crates/oxide-code/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mod client;
mod config;
mod message;
mod prompt;
mod tool;

use std::io::Write;
Expand Down Expand Up @@ -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),
Expand All @@ -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<Message> = Vec::new();
Expand All @@ -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(())
Expand All @@ -76,13 +84,15 @@ async fn agent_turn(
client: &Client,
tools: &ToolRegistry,
messages: &mut Vec<Message>,
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()
Expand Down Expand Up @@ -205,9 +215,10 @@ async fn stream_response(
client: &Client,
messages: &[Message],
tools: &[ToolDefinition],
system_prompt: &str,
show_thinking: bool,
) -> Result<Vec<ContentBlock>> {
let mut rx = client.stream_message(messages, None, tools)?;
let mut rx = client.stream_message(messages, Some(system_prompt), tools)?;

let mut blocks: Vec<Option<BlockAccumulator>> = Vec::new();
let mut stdout = std::io::stdout();
Expand Down
Loading