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:
1875
rust/Cargo.lock
generated
1875
rust/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -41,14 +41,12 @@ impl AnthropicClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_env() -> Result<Self, ApiError> {
|
pub fn from_env() -> Result<Self, ApiError> {
|
||||||
Ok(Self::new(read_api_key(|key| std::env::var(key))?)
|
Ok(Self::new(read_api_key()?).with_base_url(
|
||||||
.with_auth_token(std::env::var("ANTHROPIC_AUTH_TOKEN").ok())
|
std::env::var("ANTHROPIC_BASE_URL")
|
||||||
.with_base_url(
|
.ok()
|
||||||
std::env::var("ANTHROPIC_BASE_URL")
|
.or_else(|| std::env::var("CLAUDE_CODE_API_BASE_URL").ok())
|
||||||
.ok()
|
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
|
||||||
.or_else(|| std::env::var("CLAUDE_CODE_API_BASE_URL").ok())
|
))
|
||||||
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -187,13 +185,16 @@ impl AnthropicClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_api_key(
|
fn read_api_key() -> Result<String, ApiError> {
|
||||||
getter: impl FnOnce(&str) -> Result<String, std::env::VarError>,
|
match std::env::var("ANTHROPIC_AUTH_TOKEN") {
|
||||||
) -> Result<String, ApiError> {
|
Ok(api_key) if !api_key.is_empty() => Ok(api_key),
|
||||||
match getter("ANTHROPIC_API_KEY") {
|
Ok(_) => Err(ApiError::MissingApiKey),
|
||||||
Ok(api_key) if api_key.is_empty() => Err(ApiError::MissingApiKey),
|
Err(std::env::VarError::NotPresent) => match std::env::var("ANTHROPIC_API_KEY") {
|
||||||
Ok(api_key) => Ok(api_key),
|
Ok(api_key) if !api_key.is_empty() => Ok(api_key),
|
||||||
Err(std::env::VarError::NotPresent) => Err(ApiError::MissingApiKey),
|
Ok(_) => Err(ApiError::MissingApiKey),
|
||||||
|
Err(std::env::VarError::NotPresent) => Err(ApiError::MissingApiKey),
|
||||||
|
Err(error) => Err(ApiError::from(error)),
|
||||||
|
},
|
||||||
Err(error) => Err(ApiError::from(error)),
|
Err(error) => Err(ApiError::from(error)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,8 +290,6 @@ struct AnthropicErrorBody {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::env::VarError;
|
|
||||||
|
|
||||||
use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER};
|
use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -298,21 +297,30 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_api_key_requires_presence() {
|
fn read_api_key_requires_presence() {
|
||||||
let error = super::read_api_key(|_| Err(VarError::NotPresent))
|
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
|
||||||
.expect_err("missing key should error");
|
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));
|
assert!(matches!(error, crate::error::ApiError::MissingApiKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_api_key_requires_non_empty_value() {
|
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));
|
assert!(matches!(error, crate::error::ApiError::MissingApiKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn with_auth_token_drops_empty_values() {
|
fn read_api_key_prefers_auth_token() {
|
||||||
let client = super::AnthropicClient::new("test-key").with_auth_token(Some(String::new()));
|
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
|
||||||
assert!(client.auth_token.is_none());
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -50,11 +50,14 @@ impl Display for ApiError {
|
|||||||
Self::MissingApiKey => {
|
Self::MissingApiKey => {
|
||||||
write!(
|
write!(
|
||||||
f,
|
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) => {
|
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::Http(error) => write!(f, "http error: {error}"),
|
||||||
Self::Io(error) => write!(f, "io error: {error}"),
|
Self::Io(error) => write!(f, "io error: {error}"),
|
||||||
|
|||||||
@@ -178,6 +178,8 @@ mod tests {
|
|||||||
},
|
},
|
||||||
usage: Usage {
|
usage: Usage {
|
||||||
input_tokens: 1,
|
input_tokens: 1,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
output_tokens: 2,
|
output_tokens: 2,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ pub enum InputContentBlock {
|
|||||||
Text {
|
Text {
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
|
ToolUse {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
input: Value,
|
||||||
|
},
|
||||||
ToolResult {
|
ToolResult {
|
||||||
tool_use_id: String,
|
tool_use_id: String,
|
||||||
content: Vec<ToolResultContentBlock>,
|
content: Vec<ToolResultContentBlock>,
|
||||||
@@ -135,6 +140,10 @@ pub enum OutputContentBlock {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Usage {
|
pub struct Usage {
|
||||||
pub input_tokens: u32,
|
pub input_tokens: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_creation_input_tokens: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_read_input_tokens: u32,
|
||||||
pub output_tokens: u32,
|
pub output_tokens: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,5 +5,13 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
glob = "0.3"
|
||||||
|
regex = "1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["macros", "process", "rt", "rt-multi-thread", "time"] }
|
||||||
|
walkdir = "2"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -129,7 +129,11 @@ pub struct GrepSearchOutput {
|
|||||||
pub applied_offset: Option<usize>,
|
pub applied_offset: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_file(path: &str, offset: Option<usize>, limit: Option<usize>) -> io::Result<ReadFileOutput> {
|
pub fn read_file(
|
||||||
|
path: &str,
|
||||||
|
offset: Option<usize>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> io::Result<ReadFileOutput> {
|
||||||
let absolute_path = normalize_path(path)?;
|
let absolute_path = normalize_path(path)?;
|
||||||
let content = fs::read_to_string(&absolute_path)?;
|
let content = fs::read_to_string(&absolute_path)?;
|
||||||
let lines: Vec<&str> = content.lines().collect();
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
@@ -173,14 +177,25 @@ pub fn write_file(path: &str, content: &str) -> io::Result<WriteFileOutput> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn edit_file(path: &str, old_string: &str, new_string: &str, replace_all: bool) -> io::Result<EditFileOutput> {
|
pub fn edit_file(
|
||||||
|
path: &str,
|
||||||
|
old_string: &str,
|
||||||
|
new_string: &str,
|
||||||
|
replace_all: bool,
|
||||||
|
) -> io::Result<EditFileOutput> {
|
||||||
let absolute_path = normalize_path(path)?;
|
let absolute_path = normalize_path(path)?;
|
||||||
let original_file = fs::read_to_string(&absolute_path)?;
|
let original_file = fs::read_to_string(&absolute_path)?;
|
||||||
if old_string == new_string {
|
if old_string == new_string {
|
||||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "old_string and new_string must differ"));
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"old_string and new_string must differ",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if !original_file.contains(old_string) {
|
if !original_file.contains(old_string) {
|
||||||
return Err(io::Error::new(io::ErrorKind::NotFound, "old_string not found in file"));
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::NotFound,
|
||||||
|
"old_string not found in file",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let updated = if replace_all {
|
let updated = if replace_all {
|
||||||
@@ -204,7 +219,10 @@ pub fn edit_file(path: &str, old_string: &str, new_string: &str, replace_all: bo
|
|||||||
|
|
||||||
pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOutput> {
|
pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOutput> {
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let base_dir = path.map(normalize_path).transpose()?.unwrap_or(std::env::current_dir()?);
|
let base_dir = path
|
||||||
|
.map(normalize_path)
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or(std::env::current_dir()?);
|
||||||
let search_pattern = if Path::new(pattern).is_absolute() {
|
let search_pattern = if Path::new(pattern).is_absolute() {
|
||||||
pattern.to_owned()
|
pattern.to_owned()
|
||||||
} else {
|
} else {
|
||||||
@@ -212,7 +230,8 @@ pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOu
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut matches = Vec::new();
|
let mut matches = Vec::new();
|
||||||
let entries = glob::glob(&search_pattern).map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
|
let entries = glob::glob(&search_pattern)
|
||||||
|
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
if entry.is_file() {
|
if entry.is_file() {
|
||||||
matches.push(entry);
|
matches.push(entry);
|
||||||
@@ -255,9 +274,17 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
|||||||
.build()
|
.build()
|
||||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
|
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
|
||||||
|
|
||||||
let glob_filter = input.glob.as_deref().map(Pattern::new).transpose().map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
|
let glob_filter = input
|
||||||
|
.glob
|
||||||
|
.as_deref()
|
||||||
|
.map(Pattern::new)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
|
||||||
let file_type = input.file_type.as_deref();
|
let file_type = input.file_type.as_deref();
|
||||||
let output_mode = input.output_mode.clone().unwrap_or_else(|| String::from("files_with_matches"));
|
let output_mode = input
|
||||||
|
.output_mode
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| String::from("files_with_matches"));
|
||||||
let context = input.context.or(input.context_short).unwrap_or(0);
|
let context = input.context.or(input.context_short).unwrap_or(0);
|
||||||
|
|
||||||
let mut filenames = Vec::new();
|
let mut filenames = Vec::new();
|
||||||
@@ -312,7 +339,8 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let (filenames, applied_limit, applied_offset) = apply_limit(filenames, input.head_limit, input.offset);
|
let (filenames, applied_limit, applied_offset) =
|
||||||
|
apply_limit(filenames, input.head_limit, input.offset);
|
||||||
let content = if output_mode == "content" {
|
let content = if output_mode == "content" {
|
||||||
let (lines, limit, offset) = apply_limit(content_lines, input.head_limit, input.offset);
|
let (lines, limit, offset) = apply_limit(content_lines, input.head_limit, input.offset);
|
||||||
return Ok(GrepSearchOutput {
|
return Ok(GrepSearchOutput {
|
||||||
@@ -348,7 +376,8 @@ fn collect_search_files(base_path: &Path) -> io::Result<Vec<PathBuf>> {
|
|||||||
|
|
||||||
let mut files = Vec::new();
|
let mut files = Vec::new();
|
||||||
for entry in WalkDir::new(base_path) {
|
for entry in WalkDir::new(base_path) {
|
||||||
let entry = entry.map_err(|error| io::Error::new(io::ErrorKind::Other, error.to_string()))?;
|
let entry =
|
||||||
|
entry.map_err(|error| io::Error::new(io::ErrorKind::Other, error.to_string()))?;
|
||||||
if entry.file_type().is_file() {
|
if entry.file_type().is_file() {
|
||||||
files.push(entry.path().to_path_buf());
|
files.push(entry.path().to_path_buf());
|
||||||
}
|
}
|
||||||
@@ -356,7 +385,11 @@ fn collect_search_files(base_path: &Path) -> io::Result<Vec<PathBuf>> {
|
|||||||
Ok(files)
|
Ok(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn matches_optional_filters(path: &Path, glob_filter: Option<&Pattern>, file_type: Option<&str>) -> bool {
|
fn matches_optional_filters(
|
||||||
|
path: &Path,
|
||||||
|
glob_filter: Option<&Pattern>,
|
||||||
|
file_type: Option<&str>,
|
||||||
|
) -> bool {
|
||||||
if let Some(glob_filter) = glob_filter {
|
if let Some(glob_filter) = glob_filter {
|
||||||
let path_string = path.to_string_lossy();
|
let path_string = path.to_string_lossy();
|
||||||
if !glob_filter.matches(&path_string) && !glob_filter.matches_path(path) {
|
if !glob_filter.matches(&path_string) && !glob_filter.matches_path(path) {
|
||||||
@@ -374,7 +407,11 @@ fn matches_optional_filters(path: &Path, glob_filter: Option<&Pattern>, file_typ
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_limit<T>(items: Vec<T>, limit: Option<usize>, offset: Option<usize>) -> (Vec<T>, Option<usize>, Option<usize>) {
|
fn apply_limit<T>(
|
||||||
|
items: Vec<T>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
offset: Option<usize>,
|
||||||
|
) -> (Vec<T>, Option<usize>, Option<usize>) {
|
||||||
let offset_value = offset.unwrap_or(0);
|
let offset_value = offset.unwrap_or(0);
|
||||||
let mut items = items.into_iter().skip(offset_value).collect::<Vec<_>>();
|
let mut items = items.into_iter().skip(offset_value).collect::<Vec<_>>();
|
||||||
let explicit_limit = limit.unwrap_or(250);
|
let explicit_limit = limit.unwrap_or(250);
|
||||||
@@ -430,7 +467,9 @@ fn normalize_path_allow_missing(path: &str) -> io::Result<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(parent) = candidate.parent() {
|
if let Some(parent) = candidate.parent() {
|
||||||
let canonical_parent = parent.canonicalize().unwrap_or_else(|_| parent.to_path_buf());
|
let canonical_parent = parent
|
||||||
|
.canonicalize()
|
||||||
|
.unwrap_or_else(|_| parent.to_path_buf());
|
||||||
if let Some(name) = candidate.file_name() {
|
if let Some(name) = candidate.file_name() {
|
||||||
return Ok(canonical_parent.join(name));
|
return Ok(canonical_parent.join(name));
|
||||||
}
|
}
|
||||||
@@ -456,18 +495,22 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn reads_and_writes_files() {
|
fn reads_and_writes_files() {
|
||||||
let path = temp_path("read-write.txt");
|
let path = temp_path("read-write.txt");
|
||||||
let write_output = write_file(path.to_string_lossy().as_ref(), "one\ntwo\nthree").expect("write should succeed");
|
let write_output = write_file(path.to_string_lossy().as_ref(), "one\ntwo\nthree")
|
||||||
|
.expect("write should succeed");
|
||||||
assert_eq!(write_output.kind, "create");
|
assert_eq!(write_output.kind, "create");
|
||||||
|
|
||||||
let read_output = read_file(path.to_string_lossy().as_ref(), Some(1), Some(1)).expect("read should succeed");
|
let read_output = read_file(path.to_string_lossy().as_ref(), Some(1), Some(1))
|
||||||
|
.expect("read should succeed");
|
||||||
assert_eq!(read_output.file.content, "two");
|
assert_eq!(read_output.file.content, "two");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn edits_file_contents() {
|
fn edits_file_contents() {
|
||||||
let path = temp_path("edit.txt");
|
let path = temp_path("edit.txt");
|
||||||
write_file(path.to_string_lossy().as_ref(), "alpha beta alpha").expect("initial write should succeed");
|
write_file(path.to_string_lossy().as_ref(), "alpha beta alpha")
|
||||||
let output = edit_file(path.to_string_lossy().as_ref(), "alpha", "omega", true).expect("edit should succeed");
|
.expect("initial write should succeed");
|
||||||
|
let output = edit_file(path.to_string_lossy().as_ref(), "alpha", "omega", true)
|
||||||
|
.expect("edit should succeed");
|
||||||
assert!(output.replace_all);
|
assert!(output.replace_all);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,9 +519,14 @@ mod tests {
|
|||||||
let dir = temp_path("search-dir");
|
let dir = temp_path("search-dir");
|
||||||
std::fs::create_dir_all(&dir).expect("directory should be created");
|
std::fs::create_dir_all(&dir).expect("directory should be created");
|
||||||
let file = dir.join("demo.rs");
|
let file = dir.join("demo.rs");
|
||||||
write_file(file.to_string_lossy().as_ref(), "fn main() {\n println!(\"hello\");\n}\n").expect("file write should succeed");
|
write_file(
|
||||||
|
file.to_string_lossy().as_ref(),
|
||||||
|
"fn main() {\n println!(\"hello\");\n}\n",
|
||||||
|
)
|
||||||
|
.expect("file write should succeed");
|
||||||
|
|
||||||
let globbed = glob_search("**/*.rs", Some(dir.to_string_lossy().as_ref())).expect("glob should succeed");
|
let globbed = glob_search("**/*.rs", Some(dir.to_string_lossy().as_ref()))
|
||||||
|
.expect("glob should succeed");
|
||||||
assert_eq!(globbed.num_files, 1);
|
assert_eq!(globbed.num_files, 1);
|
||||||
|
|
||||||
let grep_output = grep_search(&GrepSearchInput {
|
let grep_output = grep_search(&GrepSearchInput {
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
|
mod bash;
|
||||||
mod bootstrap;
|
mod bootstrap;
|
||||||
mod compact;
|
mod compact;
|
||||||
mod config;
|
mod config;
|
||||||
mod conversation;
|
mod conversation;
|
||||||
|
mod file_ops;
|
||||||
mod json;
|
mod json;
|
||||||
mod permissions;
|
mod permissions;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod session;
|
mod session;
|
||||||
mod usage;
|
mod usage;
|
||||||
|
|
||||||
|
pub use bash::{execute_bash, BashCommandInput, BashCommandOutput};
|
||||||
pub use bootstrap::{BootstrapPhase, BootstrapPlan};
|
pub use bootstrap::{BootstrapPhase, BootstrapPlan};
|
||||||
pub use compact::{
|
pub use compact::{
|
||||||
compact_session, estimate_session_tokens, format_compact_summary,
|
compact_session, estimate_session_tokens, format_compact_summary,
|
||||||
@@ -21,6 +24,11 @@ pub use conversation::{
|
|||||||
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
|
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
|
||||||
ToolError, ToolExecutor, TurnSummary,
|
ToolError, ToolExecutor, TurnSummary,
|
||||||
};
|
};
|
||||||
|
pub use file_ops::{
|
||||||
|
edit_file, glob_search, grep_search, read_file, write_file, EditFileOutput, GlobSearchOutput,
|
||||||
|
GrepSearchInput, GrepSearchOutput, ReadFileOutput, StructuredPatchHunk, TextFilePayload,
|
||||||
|
WriteFileOutput,
|
||||||
|
};
|
||||||
pub use permissions::{
|
pub use permissions::{
|
||||||
PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPromptDecision,
|
PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPromptDecision,
|
||||||
PermissionPrompter, PermissionRequest,
|
PermissionPrompter, PermissionRequest,
|
||||||
|
|||||||
@@ -6,9 +6,16 @@ license.workspace = true
|
|||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
api = { path = "../api" }
|
||||||
commands = { path = "../commands" }
|
commands = { path = "../commands" }
|
||||||
compat-harness = { path = "../compat-harness" }
|
compat-harness = { path = "../compat-harness" }
|
||||||
|
crossterm = "0.28"
|
||||||
|
pulldown-cmark = "0.13"
|
||||||
runtime = { path = "../runtime" }
|
runtime = { path = "../runtime" }
|
||||||
|
serde_json = "1"
|
||||||
|
syntect = "5"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "time"] }
|
||||||
|
tools = { path = "../tools" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::io::{self, Write};
|
use std::io::{self, IsTerminal, Write};
|
||||||
|
|
||||||
use crossterm::cursor::MoveToColumn;
|
use crossterm::cursor::MoveToColumn;
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
|
||||||
@@ -100,6 +100,10 @@ impl LineEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_line(&self) -> io::Result<Option<String>> {
|
pub fn read_line(&self) -> io::Result<Option<String>> {
|
||||||
|
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
|
||||||
|
return self.read_line_fallback();
|
||||||
|
}
|
||||||
|
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
let mut input = InputBuffer::new();
|
let mut input = InputBuffer::new();
|
||||||
@@ -125,6 +129,23 @@ impl LineEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_line_fallback(&self) -> io::Result<Option<String>> {
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
write!(stdout, "{}", self.prompt)?;
|
||||||
|
stdout.flush()?;
|
||||||
|
|
||||||
|
let mut buffer = String::new();
|
||||||
|
let bytes_read = io::stdin().read_line(&mut buffer)?;
|
||||||
|
if bytes_read == 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
while matches!(buffer.chars().last(), Some('\n' | '\r')) {
|
||||||
|
buffer.pop();
|
||||||
|
}
|
||||||
|
Ok(Some(buffer))
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_key(key: KeyEvent, input: &mut InputBuffer) -> EditorAction {
|
fn handle_key(key: KeyEvent, input: &mut InputBuffer) -> EditorAction {
|
||||||
match key {
|
match key {
|
||||||
KeyEvent {
|
KeyEvent {
|
||||||
|
|||||||
@@ -1,28 +1,52 @@
|
|||||||
|
mod input;
|
||||||
|
mod render;
|
||||||
|
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::io::{self, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use api::{
|
||||||
|
AnthropicClient, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
|
||||||
|
MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
|
||||||
|
ToolResultContentBlock,
|
||||||
|
};
|
||||||
|
|
||||||
use commands::handle_slash_command;
|
use commands::handle_slash_command;
|
||||||
use compat_harness::{extract_manifest, UpstreamPaths};
|
use compat_harness::{extract_manifest, UpstreamPaths};
|
||||||
use runtime::{load_system_prompt, BootstrapPlan, CompactionConfig, Session};
|
use render::{Spinner, TerminalRenderer};
|
||||||
|
use runtime::{
|
||||||
|
load_system_prompt, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ContentBlock,
|
||||||
|
ConversationMessage, ConversationRuntime, MessageRole, PermissionMode, PermissionPolicy,
|
||||||
|
RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
|
||||||
|
};
|
||||||
|
use tools::{execute_tool, mvp_tool_specs};
|
||||||
|
|
||||||
|
const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
|
||||||
|
const DEFAULT_MAX_TOKENS: u32 = 32;
|
||||||
|
const DEFAULT_DATE: &str = "2026-03-31";
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let args: Vec<String> = env::args().skip(1).collect();
|
if let Err(error) = run() {
|
||||||
|
eprintln!("{error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match parse_args(&args) {
|
fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
Ok(CliAction::DumpManifests) => dump_manifests(),
|
let args: Vec<String> = env::args().skip(1).collect();
|
||||||
Ok(CliAction::BootstrapPlan) => print_bootstrap_plan(),
|
match parse_args(&args)? {
|
||||||
Ok(CliAction::PrintSystemPrompt { cwd, date }) => print_system_prompt(cwd, date),
|
CliAction::DumpManifests => dump_manifests(),
|
||||||
Ok(CliAction::ResumeSession {
|
CliAction::BootstrapPlan => print_bootstrap_plan(),
|
||||||
|
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
|
||||||
|
CliAction::ResumeSession {
|
||||||
session_path,
|
session_path,
|
||||||
command,
|
command,
|
||||||
}) => resume_session(&session_path, command),
|
} => resume_session(&session_path, command),
|
||||||
Ok(CliAction::Help) => print_help(),
|
CliAction::Prompt { prompt, model } => LiveCli::new(model, false)?.run_turn(&prompt)?,
|
||||||
Err(error) => {
|
CliAction::Repl { model } => run_repl(model)?,
|
||||||
eprintln!("{error}");
|
CliAction::Help => print_help(),
|
||||||
print_help();
|
|
||||||
std::process::exit(2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -37,33 +61,69 @@ enum CliAction {
|
|||||||
session_path: PathBuf,
|
session_path: PathBuf,
|
||||||
command: Option<String>,
|
command: Option<String>,
|
||||||
},
|
},
|
||||||
|
Prompt {
|
||||||
|
prompt: String,
|
||||||
|
model: String,
|
||||||
|
},
|
||||||
|
Repl {
|
||||||
|
model: String,
|
||||||
|
},
|
||||||
Help,
|
Help,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
if args.is_empty() {
|
let mut model = DEFAULT_MODEL.to_string();
|
||||||
|
let mut rest = Vec::new();
|
||||||
|
let mut index = 0;
|
||||||
|
|
||||||
|
while index < args.len() {
|
||||||
|
match args[index].as_str() {
|
||||||
|
"--model" => {
|
||||||
|
let value = args
|
||||||
|
.get(index + 1)
|
||||||
|
.ok_or_else(|| "missing value for --model".to_string())?;
|
||||||
|
model = value.clone();
|
||||||
|
index += 2;
|
||||||
|
}
|
||||||
|
flag if flag.starts_with("--model=") => {
|
||||||
|
model = flag[8..].to_string();
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
rest.push(other.to_string());
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rest.is_empty() {
|
||||||
|
return Ok(CliAction::Repl { model });
|
||||||
|
}
|
||||||
|
if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
|
||||||
return Ok(CliAction::Help);
|
return Ok(CliAction::Help);
|
||||||
}
|
}
|
||||||
|
if rest.first().map(String::as_str) == Some("--resume") {
|
||||||
if matches!(args.first().map(String::as_str), Some("--help" | "-h")) {
|
return parse_resume_args(&rest[1..]);
|
||||||
return Ok(CliAction::Help);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if args.first().map(String::as_str) == Some("--resume") {
|
match rest[0].as_str() {
|
||||||
return parse_resume_args(&args[1..]);
|
|
||||||
}
|
|
||||||
|
|
||||||
match args[0].as_str() {
|
|
||||||
"dump-manifests" => Ok(CliAction::DumpManifests),
|
"dump-manifests" => Ok(CliAction::DumpManifests),
|
||||||
"bootstrap-plan" => Ok(CliAction::BootstrapPlan),
|
"bootstrap-plan" => Ok(CliAction::BootstrapPlan),
|
||||||
"system-prompt" => parse_system_prompt_args(&args[1..]),
|
"system-prompt" => parse_system_prompt_args(&rest[1..]),
|
||||||
|
"prompt" => {
|
||||||
|
let prompt = rest[1..].join(" ");
|
||||||
|
if prompt.trim().is_empty() {
|
||||||
|
return Err("prompt subcommand requires a prompt string".to_string());
|
||||||
|
}
|
||||||
|
Ok(CliAction::Prompt { prompt, model })
|
||||||
|
}
|
||||||
other => Err(format!("unknown subcommand: {other}")),
|
other => Err(format!("unknown subcommand: {other}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
|
fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
|
let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
|
||||||
let mut date = "2026-03-31".to_string();
|
let mut date = DEFAULT_DATE.to_string();
|
||||||
let mut index = 0;
|
let mut index = 0;
|
||||||
|
|
||||||
while index < args.len() {
|
while index < args.len() {
|
||||||
@@ -121,7 +181,7 @@ fn dump_manifests() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn print_bootstrap_plan() {
|
fn print_bootstrap_plan() {
|
||||||
for phase in BootstrapPlan::claude_code_default().phases() {
|
for phase in runtime::BootstrapPlan::claude_code_default().phases() {
|
||||||
println!("- {phase:?}");
|
println!("- {phase:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,24 +238,444 @@ fn resume_session(session_path: &Path, command: Option<String>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut cli = LiveCli::new(model, true)?;
|
||||||
|
let editor = input::LineEditor::new("› ");
|
||||||
|
println!("Rusty Claude CLI interactive mode");
|
||||||
|
println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
|
||||||
|
|
||||||
|
while let Some(input) = editor.read_line()? {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match trimmed {
|
||||||
|
"/exit" | "/quit" => break,
|
||||||
|
"/help" => {
|
||||||
|
println!("Available commands:");
|
||||||
|
println!(" /help Show help");
|
||||||
|
println!(" /status Show session status");
|
||||||
|
println!(" /compact Compact session history");
|
||||||
|
println!(" /exit Quit the REPL");
|
||||||
|
}
|
||||||
|
"/status" => cli.print_status(),
|
||||||
|
"/compact" => cli.compact()?,
|
||||||
|
_ => cli.run_turn(trimmed)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LiveCli {
|
||||||
|
model: String,
|
||||||
|
system_prompt: Vec<String>,
|
||||||
|
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LiveCli {
|
||||||
|
fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
let system_prompt = build_system_prompt()?;
|
||||||
|
let runtime = build_runtime(
|
||||||
|
Session::new(),
|
||||||
|
model.clone(),
|
||||||
|
system_prompt.clone(),
|
||||||
|
enable_tools,
|
||||||
|
)?;
|
||||||
|
Ok(Self {
|
||||||
|
model,
|
||||||
|
system_prompt,
|
||||||
|
runtime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut spinner = Spinner::new();
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
spinner.tick(
|
||||||
|
"Waiting for Claude",
|
||||||
|
TerminalRenderer::new().color_theme(),
|
||||||
|
&mut stdout,
|
||||||
|
)?;
|
||||||
|
let result = self.runtime.run_turn(input, None);
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
spinner.finish(
|
||||||
|
"Claude response complete",
|
||||||
|
TerminalRenderer::new().color_theme(),
|
||||||
|
&mut stdout,
|
||||||
|
)?;
|
||||||
|
println!();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
spinner.fail(
|
||||||
|
"Claude request failed",
|
||||||
|
TerminalRenderer::new().color_theme(),
|
||||||
|
&mut stdout,
|
||||||
|
)?;
|
||||||
|
Err(Box::new(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_status(&self) {
|
||||||
|
let usage = self.runtime.usage().cumulative_usage();
|
||||||
|
println!(
|
||||||
|
"status: messages={} turns={} input_tokens={} output_tokens={}",
|
||||||
|
self.runtime.session().messages.len(),
|
||||||
|
self.runtime.usage().turns(),
|
||||||
|
usage.input_tokens,
|
||||||
|
usage.output_tokens
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let result = self.runtime.compact(CompactionConfig::default());
|
||||||
|
let removed = result.removed_message_count;
|
||||||
|
self.runtime = build_runtime(
|
||||||
|
result.compacted_session,
|
||||||
|
self.model.clone(),
|
||||||
|
self.system_prompt.clone(),
|
||||||
|
true,
|
||||||
|
)?;
|
||||||
|
println!("Compacted {removed} messages.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||||
|
Ok(load_system_prompt(
|
||||||
|
env::current_dir()?,
|
||||||
|
DEFAULT_DATE,
|
||||||
|
env::consts::OS,
|
||||||
|
"unknown",
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_runtime(
|
||||||
|
session: Session,
|
||||||
|
model: String,
|
||||||
|
system_prompt: Vec<String>,
|
||||||
|
enable_tools: bool,
|
||||||
|
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
|
||||||
|
{
|
||||||
|
Ok(ConversationRuntime::new(
|
||||||
|
session,
|
||||||
|
AnthropicRuntimeClient::new(model, enable_tools)?,
|
||||||
|
CliToolExecutor::new(),
|
||||||
|
permission_policy_from_env(),
|
||||||
|
system_prompt,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AnthropicRuntimeClient {
|
||||||
|
runtime: tokio::runtime::Runtime,
|
||||||
|
client: AnthropicClient,
|
||||||
|
model: String,
|
||||||
|
enable_tools: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnthropicRuntimeClient {
|
||||||
|
fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
Ok(Self {
|
||||||
|
runtime: tokio::runtime::Runtime::new()?,
|
||||||
|
client: AnthropicClient::from_env()?,
|
||||||
|
model,
|
||||||
|
enable_tools,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiClient for AnthropicRuntimeClient {
|
||||||
|
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||||
|
let message_request = MessageRequest {
|
||||||
|
model: self.model.clone(),
|
||||||
|
max_tokens: DEFAULT_MAX_TOKENS,
|
||||||
|
messages: convert_messages(&request.messages),
|
||||||
|
system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
|
||||||
|
tools: self.enable_tools.then(|| {
|
||||||
|
mvp_tool_specs()
|
||||||
|
.into_iter()
|
||||||
|
.map(|spec| ToolDefinition {
|
||||||
|
name: spec.name.to_string(),
|
||||||
|
description: Some(spec.description.to_string()),
|
||||||
|
input_schema: spec.input_schema,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}),
|
||||||
|
tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
|
||||||
|
stream: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.runtime.block_on(async {
|
||||||
|
let mut stream = self
|
||||||
|
.client
|
||||||
|
.stream_message(&message_request)
|
||||||
|
.await
|
||||||
|
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
let mut events = Vec::new();
|
||||||
|
let mut pending_tool: Option<(String, String, String)> = None;
|
||||||
|
let mut saw_stop = false;
|
||||||
|
|
||||||
|
while let Some(event) = stream
|
||||||
|
.next_event()
|
||||||
|
.await
|
||||||
|
.map_err(|error| RuntimeError::new(error.to_string()))?
|
||||||
|
{
|
||||||
|
match event {
|
||||||
|
ApiStreamEvent::MessageStart(start) => {
|
||||||
|
for block in start.message.content {
|
||||||
|
push_output_block(block, &mut stdout, &mut events, &mut pending_tool)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ApiStreamEvent::ContentBlockStart(start) => {
|
||||||
|
push_output_block(
|
||||||
|
start.content_block,
|
||||||
|
&mut stdout,
|
||||||
|
&mut events,
|
||||||
|
&mut pending_tool,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
|
||||||
|
ContentBlockDelta::TextDelta { text } => {
|
||||||
|
if !text.is_empty() {
|
||||||
|
write!(stdout, "{text}")
|
||||||
|
.and_then(|_| stdout.flush())
|
||||||
|
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||||
|
events.push(AssistantEvent::TextDelta(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ContentBlockDelta::InputJsonDelta { partial_json } => {
|
||||||
|
if let Some((_, _, input)) = &mut pending_tool {
|
||||||
|
input.push_str(&partial_json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ApiStreamEvent::ContentBlockStop(_) => {
|
||||||
|
if let Some((id, name, input)) = pending_tool.take() {
|
||||||
|
events.push(AssistantEvent::ToolUse { id, name, input });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ApiStreamEvent::MessageDelta(delta) => {
|
||||||
|
events.push(AssistantEvent::Usage(TokenUsage {
|
||||||
|
input_tokens: delta.usage.input_tokens,
|
||||||
|
output_tokens: delta.usage.output_tokens,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
ApiStreamEvent::MessageStop(_) => {
|
||||||
|
saw_stop = true;
|
||||||
|
events.push(AssistantEvent::MessageStop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !saw_stop
|
||||||
|
&& events.iter().any(|event| {
|
||||||
|
matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
|
||||||
|
|| matches!(event, AssistantEvent::ToolUse { .. })
|
||||||
|
})
|
||||||
|
{
|
||||||
|
events.push(AssistantEvent::MessageStop);
|
||||||
|
}
|
||||||
|
|
||||||
|
if events
|
||||||
|
.iter()
|
||||||
|
.any(|event| matches!(event, AssistantEvent::MessageStop))
|
||||||
|
{
|
||||||
|
return Ok(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.send_message(&MessageRequest {
|
||||||
|
stream: false,
|
||||||
|
..message_request.clone()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||||
|
response_to_events(response, &mut stdout)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_output_block(
|
||||||
|
block: OutputContentBlock,
|
||||||
|
out: &mut impl Write,
|
||||||
|
events: &mut Vec<AssistantEvent>,
|
||||||
|
pending_tool: &mut Option<(String, String, String)>,
|
||||||
|
) -> Result<(), RuntimeError> {
|
||||||
|
match block {
|
||||||
|
OutputContentBlock::Text { text } => {
|
||||||
|
if !text.is_empty() {
|
||||||
|
write!(out, "{text}")
|
||||||
|
.and_then(|_| out.flush())
|
||||||
|
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||||
|
events.push(AssistantEvent::TextDelta(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutputContentBlock::ToolUse { id, name, input } => {
|
||||||
|
*pending_tool = Some((id, name, input.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response_to_events(
|
||||||
|
response: MessageResponse,
|
||||||
|
out: &mut impl Write,
|
||||||
|
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
let mut pending_tool = None;
|
||||||
|
|
||||||
|
for block in response.content {
|
||||||
|
push_output_block(block, out, &mut events, &mut pending_tool)?;
|
||||||
|
if let Some((id, name, input)) = pending_tool.take() {
|
||||||
|
events.push(AssistantEvent::ToolUse { id, name, input });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events.push(AssistantEvent::Usage(TokenUsage {
|
||||||
|
input_tokens: response.usage.input_tokens,
|
||||||
|
output_tokens: response.usage.output_tokens,
|
||||||
|
cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
|
||||||
|
cache_read_input_tokens: response.usage.cache_read_input_tokens,
|
||||||
|
}));
|
||||||
|
events.push(AssistantEvent::MessageStop);
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CliToolExecutor {
|
||||||
|
renderer: TerminalRenderer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CliToolExecutor {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
renderer: TerminalRenderer::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolExecutor for CliToolExecutor {
|
||||||
|
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
|
||||||
|
let value = serde_json::from_str(input)
|
||||||
|
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
|
||||||
|
match execute_tool(tool_name, &value) {
|
||||||
|
Ok(output) => {
|
||||||
|
let markdown = format!("### Tool `{tool_name}`\n\n```json\n{output}\n```\n");
|
||||||
|
self.renderer
|
||||||
|
.stream_markdown(&markdown, &mut io::stdout())
|
||||||
|
.map_err(|error| ToolError::new(error.to_string()))?;
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
Err(error) => Err(ToolError::new(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn permission_policy_from_env() -> PermissionPolicy {
|
||||||
|
let mode =
|
||||||
|
env::var("RUSTY_CLAUDE_PERMISSION_MODE").unwrap_or_else(|_| "workspace-write".to_string());
|
||||||
|
match mode.as_str() {
|
||||||
|
"read-only" => PermissionPolicy::new(PermissionMode::Deny)
|
||||||
|
.with_tool_mode("read_file", PermissionMode::Allow)
|
||||||
|
.with_tool_mode("glob_search", PermissionMode::Allow)
|
||||||
|
.with_tool_mode("grep_search", PermissionMode::Allow),
|
||||||
|
_ => PermissionPolicy::new(PermissionMode::Allow),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
||||||
|
messages
|
||||||
|
.iter()
|
||||||
|
.filter_map(|message| {
|
||||||
|
let role = match message.role {
|
||||||
|
MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
|
||||||
|
MessageRole::Assistant => "assistant",
|
||||||
|
};
|
||||||
|
let content = message
|
||||||
|
.blocks
|
||||||
|
.iter()
|
||||||
|
.map(|block| match block {
|
||||||
|
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
|
||||||
|
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
|
||||||
|
id: id.clone(),
|
||||||
|
name: name.clone(),
|
||||||
|
input: serde_json::from_str(input)
|
||||||
|
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
|
||||||
|
},
|
||||||
|
ContentBlock::ToolResult {
|
||||||
|
tool_use_id,
|
||||||
|
output,
|
||||||
|
is_error,
|
||||||
|
..
|
||||||
|
} => InputContentBlock::ToolResult {
|
||||||
|
tool_use_id: tool_use_id.clone(),
|
||||||
|
content: vec![ToolResultContentBlock::Text {
|
||||||
|
text: output.clone(),
|
||||||
|
}],
|
||||||
|
is_error: *is_error,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
(!content.is_empty()).then(|| InputMessage {
|
||||||
|
role: role.to_string(),
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!("rusty-claude-cli");
|
println!("rusty-claude-cli");
|
||||||
println!();
|
println!();
|
||||||
println!("Current scaffold commands:");
|
println!("Usage:");
|
||||||
|
println!(" rusty-claude-cli [--model MODEL] Start interactive REPL");
|
||||||
println!(
|
println!(
|
||||||
" dump-manifests Read upstream TS sources and print extracted counts"
|
" rusty-claude-cli [--model MODEL] prompt TEXT Send one prompt and stream the response"
|
||||||
);
|
);
|
||||||
println!(" bootstrap-plan Print the current bootstrap phase skeleton");
|
println!(" rusty-claude-cli dump-manifests");
|
||||||
println!(" system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
|
println!(" rusty-claude-cli bootstrap-plan");
|
||||||
println!(" Build a Claude-style system prompt from CLAUDE.md and config files");
|
println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
|
||||||
println!(" --resume SESSION.json [/compact] Restore a saved session and optionally run a slash command");
|
println!(" rusty-claude-cli --resume SESSION.json [/compact]");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{parse_args, CliAction};
|
use super::{parse_args, CliAction, DEFAULT_MODEL};
|
||||||
|
use runtime::{ContentBlock, ConversationMessage, MessageRole};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_to_repl_when_no_args() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(&[]).expect("args should parse"),
|
||||||
|
CliAction::Repl {
|
||||||
|
model: DEFAULT_MODEL.to_string(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_prompt_subcommand() {
|
||||||
|
let args = vec![
|
||||||
|
"prompt".to_string(),
|
||||||
|
"hello".to_string(),
|
||||||
|
"world".to_string(),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(&args).expect("args should parse"),
|
||||||
|
CliAction::Prompt {
|
||||||
|
prompt: "hello world".to_string(),
|
||||||
|
model: DEFAULT_MODEL.to_string(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_system_prompt_options() {
|
fn parses_system_prompt_options() {
|
||||||
let args = vec![
|
let args = vec![
|
||||||
@@ -229,4 +709,31 @@ mod tests {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_tool_roundtrip_messages() {
|
||||||
|
let messages = vec![
|
||||||
|
ConversationMessage::user_text("hello"),
|
||||||
|
ConversationMessage::assistant(vec![ContentBlock::ToolUse {
|
||||||
|
id: "tool-1".to_string(),
|
||||||
|
name: "bash".to_string(),
|
||||||
|
input: "{\"command\":\"pwd\"}".to_string(),
|
||||||
|
}]),
|
||||||
|
ConversationMessage {
|
||||||
|
role: MessageRole::Tool,
|
||||||
|
blocks: vec![ContentBlock::ToolResult {
|
||||||
|
tool_use_id: "tool-1".to_string(),
|
||||||
|
tool_name: "bash".to_string(),
|
||||||
|
output: "ok".to_string(),
|
||||||
|
is_error: false,
|
||||||
|
}],
|
||||||
|
usage: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let converted = super::convert_messages(&messages);
|
||||||
|
assert_eq!(converted.len(), 3);
|
||||||
|
assert_eq!(converted[1].role, "assistant");
|
||||||
|
assert_eq!(converted[2].role, "user");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,10 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
runtime = { path = "../runtime" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
use runtime::{
|
||||||
|
edit_file, execute_bash, glob_search, grep_search, read_file, write_file, BashCommandInput,
|
||||||
|
GrepSearchInput,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ToolManifestEntry {
|
pub struct ToolManifestEntry {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -26,3 +33,218 @@ impl ToolRegistry {
|
|||||||
&self.entries
|
&self.entries
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ToolSpec {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub description: &'static str,
|
||||||
|
pub input_schema: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
||||||
|
vec![
|
||||||
|
ToolSpec {
|
||||||
|
name: "bash",
|
||||||
|
description: "Execute a shell command in the current workspace.",
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": { "type": "string" },
|
||||||
|
"timeout": { "type": "integer", "minimum": 1 },
|
||||||
|
"description": { "type": "string" },
|
||||||
|
"run_in_background": { "type": "boolean" },
|
||||||
|
"dangerouslyDisableSandbox": { "type": "boolean" }
|
||||||
|
},
|
||||||
|
"required": ["command"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ToolSpec {
|
||||||
|
name: "read_file",
|
||||||
|
description: "Read a text file from the workspace.",
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": { "type": "string" },
|
||||||
|
"offset": { "type": "integer", "minimum": 0 },
|
||||||
|
"limit": { "type": "integer", "minimum": 1 }
|
||||||
|
},
|
||||||
|
"required": ["path"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ToolSpec {
|
||||||
|
name: "write_file",
|
||||||
|
description: "Write a text file in the workspace.",
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": { "type": "string" },
|
||||||
|
"content": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["path", "content"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ToolSpec {
|
||||||
|
name: "edit_file",
|
||||||
|
description: "Replace text in a workspace file.",
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": { "type": "string" },
|
||||||
|
"old_string": { "type": "string" },
|
||||||
|
"new_string": { "type": "string" },
|
||||||
|
"replace_all": { "type": "boolean" }
|
||||||
|
},
|
||||||
|
"required": ["path", "old_string", "new_string"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ToolSpec {
|
||||||
|
name: "glob_search",
|
||||||
|
description: "Find files by glob pattern.",
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": { "type": "string" },
|
||||||
|
"path": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["pattern"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ToolSpec {
|
||||||
|
name: "grep_search",
|
||||||
|
description: "Search file contents with a regex pattern.",
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": { "type": "string" },
|
||||||
|
"path": { "type": "string" },
|
||||||
|
"glob": { "type": "string" },
|
||||||
|
"output_mode": { "type": "string" },
|
||||||
|
"-B": { "type": "integer", "minimum": 0 },
|
||||||
|
"-A": { "type": "integer", "minimum": 0 },
|
||||||
|
"-C": { "type": "integer", "minimum": 0 },
|
||||||
|
"context": { "type": "integer", "minimum": 0 },
|
||||||
|
"-n": { "type": "boolean" },
|
||||||
|
"-i": { "type": "boolean" },
|
||||||
|
"type": { "type": "string" },
|
||||||
|
"head_limit": { "type": "integer", "minimum": 1 },
|
||||||
|
"offset": { "type": "integer", "minimum": 0 },
|
||||||
|
"multiline": { "type": "boolean" }
|
||||||
|
},
|
||||||
|
"required": ["pattern"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute_tool(name: &str, input: &Value) -> Result<String, String> {
|
||||||
|
match name {
|
||||||
|
"bash" => from_value::<BashCommandInput>(input).and_then(run_bash),
|
||||||
|
"read_file" => from_value::<ReadFileInput>(input).and_then(run_read_file),
|
||||||
|
"write_file" => from_value::<WriteFileInput>(input).and_then(run_write_file),
|
||||||
|
"edit_file" => from_value::<EditFileInput>(input).and_then(run_edit_file),
|
||||||
|
"glob_search" => from_value::<GlobSearchInputValue>(input).and_then(run_glob_search),
|
||||||
|
"grep_search" => from_value::<GrepSearchInput>(input).and_then(run_grep_search),
|
||||||
|
_ => Err(format!("unsupported tool: {name}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_value<T: for<'de> Deserialize<'de>>(input: &Value) -> Result<T, String> {
|
||||||
|
serde_json::from_value(input.clone()).map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_bash(input: BashCommandInput) -> Result<String, String> {
|
||||||
|
serde_json::to_string_pretty(&execute_bash(input).map_err(|error| error.to_string())?)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_read_file(input: ReadFileInput) -> Result<String, String> {
|
||||||
|
to_pretty_json(read_file(&input.path, input.offset, input.limit).map_err(io_to_string)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_write_file(input: WriteFileInput) -> Result<String, String> {
|
||||||
|
to_pretty_json(write_file(&input.path, &input.content).map_err(io_to_string)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_edit_file(input: EditFileInput) -> Result<String, String> {
|
||||||
|
to_pretty_json(
|
||||||
|
edit_file(
|
||||||
|
&input.path,
|
||||||
|
&input.old_string,
|
||||||
|
&input.new_string,
|
||||||
|
input.replace_all.unwrap_or(false),
|
||||||
|
)
|
||||||
|
.map_err(io_to_string)?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_glob_search(input: GlobSearchInputValue) -> Result<String, String> {
|
||||||
|
to_pretty_json(glob_search(&input.pattern, input.path.as_deref()).map_err(io_to_string)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_grep_search(input: GrepSearchInput) -> Result<String, String> {
|
||||||
|
to_pretty_json(grep_search(&input).map_err(io_to_string)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_pretty_json<T: serde::Serialize>(value: T) -> Result<String, String> {
|
||||||
|
serde_json::to_string_pretty(&value).map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn io_to_string(error: std::io::Error) -> String {
|
||||||
|
error.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ReadFileInput {
|
||||||
|
path: String,
|
||||||
|
offset: Option<usize>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct WriteFileInput {
|
||||||
|
path: String,
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct EditFileInput {
|
||||||
|
path: String,
|
||||||
|
old_string: String,
|
||||||
|
new_string: String,
|
||||||
|
replace_all: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GlobSearchInputValue {
|
||||||
|
pattern: String,
|
||||||
|
path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{execute_tool, mvp_tool_specs};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exposes_mvp_tools() {
|
||||||
|
let names = mvp_tool_specs()
|
||||||
|
.into_iter()
|
||||||
|
.map(|spec| spec.name)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(names.contains(&"bash"));
|
||||||
|
assert!(names.contains(&"read_file"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unknown_tool_names() {
|
||||||
|
let error = execute_tool("nope", &json!({})).expect_err("tool should be rejected");
|
||||||
|
assert!(error.contains("unsupported tool"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user