feat: make rusty-claude-cli usable end-to-end

Wire the CLI to the Anthropic client, runtime conversation loop, and MVP in-tree tool executor so prompt mode and the default REPL both execute real turns instead of scaffold-only commands.

Constraint: Proxy auth uses ANTHROPIC_AUTH_TOKEN as the primary x-api-key source and may stream extra usage fields
Constraint: Must preserve existing scaffold commands while enabling real prompt and REPL flows
Rejected: Keep prompt mode on the old scaffold path | does not satisfy end-to-end CLI requirement
Rejected: Depend solely on raw SSE message_stop from proxy | proxy/event differences required tolerant parsing plus fallback handling
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep prompt mode tool-free unless the one-shot path is explicitly expanded and reverified against the proxy
Tested: cargo test -p api; cargo test -p tools; cargo test -p runtime; cargo test -p rusty-claude-cli; cargo build; cargo run -p rusty-claude-cli -- prompt "say hello"; printf '/quit\n' | cargo run -p rusty-claude-cli --
Not-tested: Full interactive tool_use roundtrip against the proxy in REPL mode
This commit is contained in:
Yeachan-Heo
2026-03-31 18:39:39 +00:00
parent 450556559a
commit 3faf8dd365
13 changed files with 2801 additions and 78 deletions

View File

@@ -41,14 +41,12 @@ impl AnthropicClient {
}
pub fn from_env() -> Result<Self, ApiError> {
Ok(Self::new(read_api_key(|key| std::env::var(key))?)
.with_auth_token(std::env::var("ANTHROPIC_AUTH_TOKEN").ok())
.with_base_url(
std::env::var("ANTHROPIC_BASE_URL")
.ok()
.or_else(|| std::env::var("CLAUDE_CODE_API_BASE_URL").ok())
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
))
Ok(Self::new(read_api_key()?).with_base_url(
std::env::var("ANTHROPIC_BASE_URL")
.ok()
.or_else(|| std::env::var("CLAUDE_CODE_API_BASE_URL").ok())
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
))
}
#[must_use]
@@ -187,13 +185,16 @@ impl AnthropicClient {
}
}
fn read_api_key(
getter: impl FnOnce(&str) -> Result<String, std::env::VarError>,
) -> Result<String, ApiError> {
match getter("ANTHROPIC_API_KEY") {
Ok(api_key) if api_key.is_empty() => Err(ApiError::MissingApiKey),
Ok(api_key) => Ok(api_key),
Err(std::env::VarError::NotPresent) => Err(ApiError::MissingApiKey),
fn read_api_key() -> Result<String, ApiError> {
match std::env::var("ANTHROPIC_AUTH_TOKEN") {
Ok(api_key) if !api_key.is_empty() => Ok(api_key),
Ok(_) => Err(ApiError::MissingApiKey),
Err(std::env::VarError::NotPresent) => match std::env::var("ANTHROPIC_API_KEY") {
Ok(api_key) if !api_key.is_empty() => Ok(api_key),
Ok(_) => Err(ApiError::MissingApiKey),
Err(std::env::VarError::NotPresent) => Err(ApiError::MissingApiKey),
Err(error) => Err(ApiError::from(error)),
},
Err(error) => Err(ApiError::from(error)),
}
}
@@ -289,8 +290,6 @@ struct AnthropicErrorBody {
#[cfg(test)]
mod tests {
use std::env::VarError;
use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER};
use std::time::Duration;
@@ -298,21 +297,30 @@ mod tests {
#[test]
fn read_api_key_requires_presence() {
let error = super::read_api_key(|_| Err(VarError::NotPresent))
.expect_err("missing key should error");
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY");
let error = super::read_api_key().expect_err("missing key should error");
assert!(matches!(error, crate::error::ApiError::MissingApiKey));
}
#[test]
fn read_api_key_requires_non_empty_value() {
let error = super::read_api_key(|_| Ok(String::new())).expect_err("empty key should error");
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "");
std::env::remove_var("ANTHROPIC_API_KEY");
let error = super::read_api_key().expect_err("empty key should error");
assert!(matches!(error, crate::error::ApiError::MissingApiKey));
}
#[test]
fn with_auth_token_drops_empty_values() {
let client = super::AnthropicClient::new("test-key").with_auth_token(Some(String::new()));
assert!(client.auth_token.is_none());
fn read_api_key_prefers_auth_token() {
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
std::env::set_var("ANTHROPIC_API_KEY", "legacy-key");
assert_eq!(
super::read_api_key().expect("token should load"),
"auth-token"
);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY");
}
#[test]

View File

@@ -50,11 +50,14 @@ impl Display for ApiError {
Self::MissingApiKey => {
write!(
f,
"ANTHROPIC_API_KEY is not set; export it before calling the Anthropic API"
"ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY is not set; export one before calling the Anthropic API"
)
}
Self::InvalidApiKeyEnv(error) => {
write!(f, "failed to read ANTHROPIC_API_KEY: {error}")
write!(
f,
"failed to read ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY: {error}"
)
}
Self::Http(error) => write!(f, "http error: {error}"),
Self::Io(error) => write!(f, "io error: {error}"),

View File

@@ -178,6 +178,8 @@ mod tests {
},
usage: Usage {
input_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
output_tokens: 2,
},
}),

View File

@@ -64,6 +64,11 @@ pub enum InputContentBlock {
Text {
text: String,
},
ToolUse {
id: String,
name: String,
input: Value,
},
ToolResult {
tool_use_id: String,
content: Vec<ToolResultContentBlock>,
@@ -135,6 +140,10 @@ pub enum OutputContentBlock {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Usage {
pub input_tokens: u32,
#[serde(default)]
pub cache_creation_input_tokens: u32,
#[serde(default)]
pub cache_read_input_tokens: u32,
pub output_tokens: u32,
}