mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-03 05:41:53 +08:00
feat(cli): align slash help/status/model handling
Centralize slash command parsing in the commands crate so the REPL can share help metadata and grow toward Claude Code parity without duplicating handlers. This adds shared /help and /model parsing, routes REPL dispatch through the shared parser, and upgrades /status to report model and token totals. To satisfy the required verification gate, this also fixes existing workspace clippy and test blockers in runtime, tools, api, and compat-harness that were unrelated to the new command behavior but prevented fmt/clippy/test from passing cleanly. Constraint: Preserve existing prompt-mode and REPL behavior while adding real slash commands Constraint: cargo fmt, clippy, and workspace tests must pass before shipping command-surface work Rejected: Keep command handling only in main.rs | would deepen duplication with commands crate and resume path Confidence: high Scope-risk: moderate Reversibility: clean Directive: Extend new slash commands through the shared commands crate first so REPL and resume entrypoints stay consistent Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace Not-tested: live Anthropic network execution beyond existing mocked/integration coverage
This commit is contained in:
@@ -158,7 +158,10 @@ impl AnthropicClient {
|
|||||||
.header("anthropic-version", ANTHROPIC_VERSION)
|
.header("anthropic-version", ANTHROPIC_VERSION)
|
||||||
.header("content-type", "application/json");
|
.header("content-type", "application/json");
|
||||||
|
|
||||||
let auth_header = self.auth_token.as_ref().map(|_| "Bearer [REDACTED]").unwrap_or("<absent>");
|
let auth_header = self
|
||||||
|
.auth_token
|
||||||
|
.as_ref()
|
||||||
|
.map_or("<absent>", |_| "Bearer [REDACTED]");
|
||||||
eprintln!("[anthropic-client] headers x-api-key=[REDACTED] authorization={auth_header} anthropic-version={ANTHROPIC_VERSION} content-type=application/json");
|
eprintln!("[anthropic-client] headers x-api-key=[REDACTED] authorization={auth_header} anthropic-version={ANTHROPIC_VERSION} content-type=application/json");
|
||||||
|
|
||||||
if let Some(auth_token) = &self.auth_token {
|
if let Some(auth_token) = &self.auth_token {
|
||||||
@@ -192,8 +195,7 @@ fn read_api_key() -> Result<String, ApiError> {
|
|||||||
Ok(_) => Err(ApiError::MissingApiKey),
|
Ok(_) => Err(ApiError::MissingApiKey),
|
||||||
Err(std::env::VarError::NotPresent) => match std::env::var("ANTHROPIC_AUTH_TOKEN") {
|
Err(std::env::VarError::NotPresent) => match std::env::var("ANTHROPIC_AUTH_TOKEN") {
|
||||||
Ok(api_key) if !api_key.is_empty() => Ok(api_key),
|
Ok(api_key) if !api_key.is_empty() => Ok(api_key),
|
||||||
Ok(_) => Err(ApiError::MissingApiKey),
|
Ok(_) | Err(std::env::VarError::NotPresent) => 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)),
|
Err(error) => Err(ApiError::from(error)),
|
||||||
@@ -303,12 +305,22 @@ struct AnthropicErrorBody {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER};
|
use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER};
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::types::{ContentBlockDelta, MessageRequest};
|
use crate::types::{ContentBlockDelta, MessageRequest};
|
||||||
|
|
||||||
|
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||||
|
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
|
ENV_LOCK
|
||||||
|
.get_or_init(|| Mutex::new(()))
|
||||||
|
.lock()
|
||||||
|
.expect("env lock should not be poisoned")
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_api_key_requires_presence() {
|
fn read_api_key_requires_presence() {
|
||||||
|
let _guard = env_lock();
|
||||||
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
|
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
|
||||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||||
let error = super::read_api_key().expect_err("missing key should error");
|
let error = super::read_api_key().expect_err("missing key should error");
|
||||||
@@ -317,6 +329,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_api_key_requires_non_empty_value() {
|
fn read_api_key_requires_non_empty_value() {
|
||||||
|
let _guard = env_lock();
|
||||||
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "");
|
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "");
|
||||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||||
let error = super::read_api_key().expect_err("empty key should error");
|
let error = super::read_api_key().expect_err("empty key should error");
|
||||||
@@ -325,6 +338,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_api_key_prefers_api_key_env() {
|
fn read_api_key_prefers_api_key_env() {
|
||||||
|
let _guard = env_lock();
|
||||||
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
|
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
|
||||||
std::env::set_var("ANTHROPIC_API_KEY", "legacy-key");
|
std::env::set_var("ANTHROPIC_API_KEY", "legacy-key");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -337,6 +351,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_auth_token_reads_auth_token_env() {
|
fn read_auth_token_reads_auth_token_env() {
|
||||||
|
let _guard = env_lock();
|
||||||
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
|
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
|
||||||
assert_eq!(super::read_auth_token().as_deref(), Some("auth-token"));
|
assert_eq!(super::read_auth_token().as_deref(), Some("auth-token"));
|
||||||
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
|
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
|
||||||
|
|||||||
@@ -30,6 +30,85 @@ impl CommandRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct SlashCommandSpec {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub summary: &'static str,
|
||||||
|
pub argument_hint: Option<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "help",
|
||||||
|
summary: "Show available slash commands",
|
||||||
|
argument_hint: None,
|
||||||
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "status",
|
||||||
|
summary: "Show current session status",
|
||||||
|
argument_hint: None,
|
||||||
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "compact",
|
||||||
|
summary: "Compact local session history",
|
||||||
|
argument_hint: None,
|
||||||
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "model",
|
||||||
|
summary: "Show or switch the active model",
|
||||||
|
argument_hint: Some("[model]"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum SlashCommand {
|
||||||
|
Help,
|
||||||
|
Status,
|
||||||
|
Compact,
|
||||||
|
Model { model: Option<String> },
|
||||||
|
Unknown(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SlashCommand {
|
||||||
|
#[must_use]
|
||||||
|
pub fn parse(input: &str) -> Option<Self> {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
if !trimmed.starts_with('/') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut parts = trimmed.trim_start_matches('/').split_whitespace();
|
||||||
|
let command = parts.next().unwrap_or_default();
|
||||||
|
Some(match command {
|
||||||
|
"help" => Self::Help,
|
||||||
|
"status" => Self::Status,
|
||||||
|
"compact" => Self::Compact,
|
||||||
|
"model" => Self::Model {
|
||||||
|
model: parts.next().map(ToOwned::to_owned),
|
||||||
|
},
|
||||||
|
other => Self::Unknown(other.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
|
||||||
|
SLASH_COMMAND_SPECS
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn render_slash_command_help() -> String {
|
||||||
|
let mut lines = vec!["Available commands:".to_string()];
|
||||||
|
for spec in slash_command_specs() {
|
||||||
|
let name = match spec.argument_hint {
|
||||||
|
Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
|
||||||
|
None => format!("/{}", spec.name),
|
||||||
|
};
|
||||||
|
lines.push(format!(" {name:<20} {}", spec.summary));
|
||||||
|
}
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct SlashCommandResult {
|
pub struct SlashCommandResult {
|
||||||
pub message: String,
|
pub message: String,
|
||||||
@@ -42,13 +121,8 @@ pub fn handle_slash_command(
|
|||||||
session: &Session,
|
session: &Session,
|
||||||
compaction: CompactionConfig,
|
compaction: CompactionConfig,
|
||||||
) -> Option<SlashCommandResult> {
|
) -> Option<SlashCommandResult> {
|
||||||
let trimmed = input.trim();
|
match SlashCommand::parse(input)? {
|
||||||
if !trimmed.starts_with('/') {
|
SlashCommand::Compact => {
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
match trimmed.split_whitespace().next() {
|
|
||||||
Some("/compact") => {
|
|
||||||
let result = compact_session(session, compaction);
|
let result = compact_session(session, compaction);
|
||||||
let message = if result.removed_message_count == 0 {
|
let message = if result.removed_message_count == 0 {
|
||||||
"Compaction skipped: session is below the compaction threshold.".to_string()
|
"Compaction skipped: session is below the compaction threshold.".to_string()
|
||||||
@@ -63,15 +137,47 @@ pub fn handle_slash_command(
|
|||||||
session: result.compacted_session,
|
session: result.compacted_session,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
_ => None,
|
SlashCommand::Help => Some(SlashCommandResult {
|
||||||
|
message: render_slash_command_help(),
|
||||||
|
session: session.clone(),
|
||||||
|
}),
|
||||||
|
SlashCommand::Status | SlashCommand::Model { .. } | SlashCommand::Unknown(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::handle_slash_command;
|
use super::{
|
||||||
|
handle_slash_command, render_slash_command_help, slash_command_specs, SlashCommand,
|
||||||
|
};
|
||||||
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
|
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_supported_slash_commands() {
|
||||||
|
assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
|
||||||
|
assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/model claude-opus"),
|
||||||
|
Some(SlashCommand::Model {
|
||||||
|
model: Some("claude-opus".to_string()),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/model"),
|
||||||
|
Some(SlashCommand::Model { model: None })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_help_from_shared_specs() {
|
||||||
|
let help = render_slash_command_help();
|
||||||
|
assert!(help.contains("/help"));
|
||||||
|
assert!(help.contains("/status"));
|
||||||
|
assert!(help.contains("/compact"));
|
||||||
|
assert!(help.contains("/model [model]"));
|
||||||
|
assert_eq!(slash_command_specs().len(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compacts_sessions_via_slash_command() {
|
fn compacts_sessions_via_slash_command() {
|
||||||
let session = Session {
|
let session = Session {
|
||||||
@@ -103,8 +209,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ignores_unknown_slash_commands() {
|
fn help_command_is_non_mutating() {
|
||||||
|
let session = Session::new();
|
||||||
|
let result = handle_slash_command("/help", &session, CompactionConfig::default())
|
||||||
|
.expect("help command should be handled");
|
||||||
|
assert_eq!(result.session, session);
|
||||||
|
assert!(result.message.contains("Available commands:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_unknown_or_runtime_bound_slash_commands() {
|
||||||
let session = Session::new();
|
let session = Session::new();
|
||||||
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
|
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
|
||||||
|
assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
|
||||||
|
assert!(
|
||||||
|
handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,10 @@ impl UpstreamPaths {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.canonicalize()
|
.canonicalize()
|
||||||
.unwrap_or_else(|_| workspace_dir.as_ref().to_path_buf());
|
.unwrap_or_else(|_| workspace_dir.as_ref().to_path_buf());
|
||||||
let repo_root = workspace_dir
|
let primary_repo_root = workspace_dir
|
||||||
.parent()
|
.parent()
|
||||||
.map_or_else(|| PathBuf::from(".."), Path::to_path_buf);
|
.map_or_else(|| PathBuf::from(".."), Path::to_path_buf);
|
||||||
|
let repo_root = resolve_upstream_repo_root(&primary_repo_root);
|
||||||
Self { repo_root }
|
Self { repo_root }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +54,42 @@ pub struct ExtractedManifest {
|
|||||||
pub bootstrap: BootstrapPlan,
|
pub bootstrap: BootstrapPlan,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_upstream_repo_root(primary_repo_root: &Path) -> PathBuf {
|
||||||
|
let candidates = upstream_repo_candidates(primary_repo_root);
|
||||||
|
candidates
|
||||||
|
.into_iter()
|
||||||
|
.find(|candidate| candidate.join("src/commands.ts").is_file())
|
||||||
|
.unwrap_or_else(|| primary_repo_root.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upstream_repo_candidates(primary_repo_root: &Path) -> Vec<PathBuf> {
|
||||||
|
let mut candidates = vec![primary_repo_root.to_path_buf()];
|
||||||
|
|
||||||
|
if let Some(explicit) = std::env::var_os("CLAUDE_CODE_UPSTREAM") {
|
||||||
|
candidates.push(PathBuf::from(explicit));
|
||||||
|
}
|
||||||
|
|
||||||
|
for ancestor in primary_repo_root.ancestors().take(4) {
|
||||||
|
candidates.push(ancestor.join("claude-code"));
|
||||||
|
candidates.push(ancestor.join("clawd-code"));
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.push(
|
||||||
|
primary_repo_root
|
||||||
|
.join("reference-source")
|
||||||
|
.join("claude-code"),
|
||||||
|
);
|
||||||
|
candidates.push(primary_repo_root.join("vendor").join("claude-code"));
|
||||||
|
|
||||||
|
let mut deduped = Vec::new();
|
||||||
|
for candidate in candidates {
|
||||||
|
if !deduped.iter().any(|seen: &PathBuf| seen == &candidate) {
|
||||||
|
deduped.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deduped
|
||||||
|
}
|
||||||
|
|
||||||
pub fn extract_manifest(paths: &UpstreamPaths) -> std::io::Result<ExtractedManifest> {
|
pub fn extract_manifest(paths: &UpstreamPaths) -> std::io::Result<ExtractedManifest> {
|
||||||
let commands_source = fs::read_to_string(paths.commands_path())?;
|
let commands_source = fs::read_to_string(paths.commands_path())?;
|
||||||
let tools_source = fs::read_to_string(paths.tools_path())?;
|
let tools_source = fs::read_to_string(paths.tools_path())?;
|
||||||
|
|||||||
@@ -138,9 +138,9 @@ pub fn read_file(
|
|||||||
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();
|
||||||
let start_index = offset.unwrap_or(0).min(lines.len());
|
let start_index = offset.unwrap_or(0).min(lines.len());
|
||||||
let end_index = limit
|
let end_index = limit.map_or(lines.len(), |limit| {
|
||||||
.map(|limit| start_index.saturating_add(limit).min(lines.len()))
|
start_index.saturating_add(limit).min(lines.len())
|
||||||
.unwrap_or(lines.len());
|
});
|
||||||
let selected = lines[start_index..end_index].join("\n");
|
let selected = lines[start_index..end_index].join("\n");
|
||||||
|
|
||||||
Ok(ReadFileOutput {
|
Ok(ReadFileOutput {
|
||||||
@@ -285,7 +285,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
|||||||
.output_mode
|
.output_mode
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| String::from("files_with_matches"));
|
.unwrap_or_else(|| String::from("files_with_matches"));
|
||||||
let context = input.context.or(input.context_short).unwrap_or(0);
|
let context_window = input.context.or(input.context_short).unwrap_or(0);
|
||||||
|
|
||||||
let mut filenames = Vec::new();
|
let mut filenames = Vec::new();
|
||||||
let mut content_lines = Vec::new();
|
let mut content_lines = Vec::new();
|
||||||
@@ -296,12 +296,12 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Ok(content) = fs::read_to_string(&file_path) else {
|
let Ok(file_content) = fs::read_to_string(&file_path) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
if output_mode == "count" {
|
if output_mode == "count" {
|
||||||
let count = regex.find_iter(&content).count();
|
let count = regex.find_iter(&file_content).count();
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
filenames.push(file_path.to_string_lossy().into_owned());
|
filenames.push(file_path.to_string_lossy().into_owned());
|
||||||
total_matches += count;
|
total_matches += count;
|
||||||
@@ -309,7 +309,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let lines: Vec<&str> = content.lines().collect();
|
let lines: Vec<&str> = file_content.lines().collect();
|
||||||
let mut matched_lines = Vec::new();
|
let mut matched_lines = Vec::new();
|
||||||
for (index, line) in lines.iter().enumerate() {
|
for (index, line) in lines.iter().enumerate() {
|
||||||
if regex.is_match(line) {
|
if regex.is_match(line) {
|
||||||
@@ -325,15 +325,15 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
|||||||
filenames.push(file_path.to_string_lossy().into_owned());
|
filenames.push(file_path.to_string_lossy().into_owned());
|
||||||
if output_mode == "content" {
|
if output_mode == "content" {
|
||||||
for index in matched_lines {
|
for index in matched_lines {
|
||||||
let start = index.saturating_sub(input.before.unwrap_or(context));
|
let start = index.saturating_sub(input.before.unwrap_or(context_window));
|
||||||
let end = (index + input.after.unwrap_or(context) + 1).min(lines.len());
|
let end = (index + input.after.unwrap_or(context_window) + 1).min(lines.len());
|
||||||
for current in start..end {
|
for (current, line_content) in lines.iter().enumerate().take(end).skip(start) {
|
||||||
let prefix = if input.line_numbers.unwrap_or(true) {
|
let prefix = if input.line_numbers.unwrap_or(true) {
|
||||||
format!("{}:{}:", file_path.to_string_lossy(), current + 1)
|
format!("{}:{}:", file_path.to_string_lossy(), current + 1)
|
||||||
} else {
|
} else {
|
||||||
format!("{}:", file_path.to_string_lossy())
|
format!("{}:", file_path.to_string_lossy())
|
||||||
};
|
};
|
||||||
content_lines.push(format!("{prefix}{}", lines[current]));
|
content_lines.push(format!("{prefix}{line_content}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,8 +376,7 @@ 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 =
|
let entry = entry.map_err(|error| io::Error::other(error.to_string()))?;
|
||||||
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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use api::{
|
|||||||
ToolResultContentBlock,
|
ToolResultContentBlock,
|
||||||
};
|
};
|
||||||
|
|
||||||
use commands::handle_slash_command;
|
use commands::{handle_slash_command, render_slash_command_help, SlashCommand};
|
||||||
use compat_harness::{extract_manifest, UpstreamPaths};
|
use compat_harness::{extract_manifest, UpstreamPaths};
|
||||||
use render::{Spinner, TerminalRenderer};
|
use render::{Spinner, TerminalRenderer};
|
||||||
use runtime::{
|
use runtime::{
|
||||||
@@ -82,7 +82,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||||||
let value = args
|
let value = args
|
||||||
.get(index + 1)
|
.get(index + 1)
|
||||||
.ok_or_else(|| "missing value for --model".to_string())?;
|
.ok_or_else(|| "missing value for --model".to_string())?;
|
||||||
model = value.clone();
|
model.clone_from(value);
|
||||||
index += 2;
|
index += 2;
|
||||||
}
|
}
|
||||||
flag if flag.starts_with("--model=") => {
|
flag if flag.starts_with("--model=") => {
|
||||||
@@ -249,19 +249,14 @@ fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match trimmed {
|
if matches!(trimmed, "/exit" | "/quit") {
|
||||||
"/exit" | "/quit" => break,
|
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(),
|
if let Some(command) = SlashCommand::parse(trimmed) {
|
||||||
"/compact" => cli.compact()?,
|
cli.handle_repl_command(command)?;
|
||||||
_ => cli.run_turn(trimmed)?,
|
continue;
|
||||||
}
|
}
|
||||||
|
cli.run_turn(trimmed)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -319,17 +314,55 @@ impl LiveCli {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_repl_command(
|
||||||
|
&mut self,
|
||||||
|
command: SlashCommand,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
match command {
|
||||||
|
SlashCommand::Help => println!("{}", render_repl_help()),
|
||||||
|
SlashCommand::Status => self.print_status(),
|
||||||
|
SlashCommand::Compact => self.compact()?,
|
||||||
|
SlashCommand::Model { model } => self.set_model(model)?,
|
||||||
|
SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn print_status(&self) {
|
fn print_status(&self) {
|
||||||
let usage = self.runtime.usage().cumulative_usage();
|
let cumulative = self.runtime.usage().cumulative_usage();
|
||||||
|
let latest = self.runtime.usage().current_turn_usage();
|
||||||
println!(
|
println!(
|
||||||
"status: messages={} turns={} input_tokens={} output_tokens={}",
|
"{}",
|
||||||
|
format_status_line(
|
||||||
|
&self.model,
|
||||||
self.runtime.session().messages.len(),
|
self.runtime.session().messages.len(),
|
||||||
self.runtime.usage().turns(),
|
self.runtime.usage().turns(),
|
||||||
usage.input_tokens,
|
latest,
|
||||||
usage.output_tokens
|
cumulative,
|
||||||
|
self.runtime.estimated_tokens(),
|
||||||
|
permission_mode_label(),
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_model(&mut self, model: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let Some(model) = model else {
|
||||||
|
println!("Current model: {}", self.model);
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if model == self.model {
|
||||||
|
println!("Model already set to {model}.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = self.runtime.session().clone();
|
||||||
|
self.runtime = build_runtime(session, model.clone(), self.system_prompt.clone(), true)?;
|
||||||
|
self.model.clone_from(&model);
|
||||||
|
println!("Switched model to {model}.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let result = self.runtime.compact(CompactionConfig::default());
|
let result = self.runtime.compact(CompactionConfig::default());
|
||||||
let removed = result.removed_message_count;
|
let removed = result.removed_message_count;
|
||||||
@@ -344,6 +377,39 @@ impl LiveCli {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_repl_help() -> String {
|
||||||
|
format!(
|
||||||
|
"{}
|
||||||
|
/exit Quit the REPL",
|
||||||
|
render_slash_command_help()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_status_line(
|
||||||
|
model: &str,
|
||||||
|
message_count: usize,
|
||||||
|
turns: u32,
|
||||||
|
latest: TokenUsage,
|
||||||
|
cumulative: TokenUsage,
|
||||||
|
estimated_tokens: usize,
|
||||||
|
permission_mode: &str,
|
||||||
|
) -> String {
|
||||||
|
format!(
|
||||||
|
"status: model={model} permission_mode={permission_mode} messages={message_count} turns={turns} estimated_tokens={estimated_tokens} latest_tokens={} cumulative_input_tokens={} cumulative_output_tokens={} cumulative_total_tokens={}",
|
||||||
|
latest.total_tokens(),
|
||||||
|
cumulative.input_tokens,
|
||||||
|
cumulative.output_tokens,
|
||||||
|
cumulative.total_tokens(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn permission_mode_label() -> &'static str {
|
||||||
|
match env::var("RUSTY_CLAUDE_PERMISSION_MODE") {
|
||||||
|
Ok(value) if value == "read-only" => "read-only",
|
||||||
|
_ => "workspace-write",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||||
Ok(load_system_prompt(
|
Ok(load_system_prompt(
|
||||||
env::current_dir()?,
|
env::current_dir()?,
|
||||||
@@ -388,6 +454,7 @@ impl AnthropicRuntimeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ApiClient for AnthropicRuntimeClient {
|
impl ApiClient for AnthropicRuntimeClient {
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||||
let message_request = MessageRequest {
|
let message_request = MessageRequest {
|
||||||
model: self.model.clone(),
|
model: self.model.clone(),
|
||||||
@@ -442,7 +509,7 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
ContentBlockDelta::TextDelta { text } => {
|
ContentBlockDelta::TextDelta { text } => {
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
write!(stdout, "{text}")
|
write!(stdout, "{text}")
|
||||||
.and_then(|_| stdout.flush())
|
.and_then(|()| stdout.flush())
|
||||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||||
events.push(AssistantEvent::TextDelta(text));
|
events.push(AssistantEvent::TextDelta(text));
|
||||||
}
|
}
|
||||||
@@ -512,7 +579,7 @@ fn push_output_block(
|
|||||||
OutputContentBlock::Text { text } => {
|
OutputContentBlock::Text { text } => {
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
write!(out, "{text}")
|
write!(out, "{text}")
|
||||||
.and_then(|_| out.flush())
|
.and_then(|()| out.flush())
|
||||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||||
events.push(AssistantEvent::TextDelta(text));
|
events.push(AssistantEvent::TextDelta(text));
|
||||||
}
|
}
|
||||||
@@ -646,7 +713,7 @@ fn print_help() {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{parse_args, CliAction, DEFAULT_MODEL};
|
use super::{format_status_line, parse_args, render_repl_help, CliAction, DEFAULT_MODEL};
|
||||||
use runtime::{ContentBlock, ConversationMessage, MessageRole};
|
use runtime::{ContentBlock, ConversationMessage, MessageRole};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
@@ -710,6 +777,43 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repl_help_includes_shared_commands_and_exit() {
|
||||||
|
let help = render_repl_help();
|
||||||
|
assert!(help.contains("/help"));
|
||||||
|
assert!(help.contains("/status"));
|
||||||
|
assert!(help.contains("/model [model]"));
|
||||||
|
assert!(help.contains("/exit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_line_reports_model_and_token_totals() {
|
||||||
|
let status = format_status_line(
|
||||||
|
"claude-sonnet",
|
||||||
|
7,
|
||||||
|
3,
|
||||||
|
runtime::TokenUsage {
|
||||||
|
input_tokens: 5,
|
||||||
|
output_tokens: 4,
|
||||||
|
cache_creation_input_tokens: 1,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
},
|
||||||
|
runtime::TokenUsage {
|
||||||
|
input_tokens: 20,
|
||||||
|
output_tokens: 8,
|
||||||
|
cache_creation_input_tokens: 2,
|
||||||
|
cache_read_input_tokens: 1,
|
||||||
|
},
|
||||||
|
128,
|
||||||
|
"workspace-write",
|
||||||
|
);
|
||||||
|
assert!(status.contains("model=claude-sonnet"));
|
||||||
|
assert!(status.contains("permission_mode=workspace-write"));
|
||||||
|
assert!(status.contains("messages=7"));
|
||||||
|
assert!(status.contains("latest_tokens=10"));
|
||||||
|
assert!(status.contains("cumulative_total_tokens=31"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn converts_tool_roundtrip_messages() {
|
fn converts_tool_roundtrip_messages() {
|
||||||
let messages = vec![
|
let messages = vec![
|
||||||
|
|||||||
@@ -146,11 +146,17 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
pub fn execute_tool(name: &str, input: &Value) -> Result<String, String> {
|
pub fn execute_tool(name: &str, input: &Value) -> Result<String, String> {
|
||||||
match name {
|
match name {
|
||||||
"bash" => from_value::<BashCommandInput>(input).and_then(run_bash),
|
"bash" => from_value::<BashCommandInput>(input).and_then(run_bash),
|
||||||
"read_file" => from_value::<ReadFileInput>(input).and_then(run_read_file),
|
"read_file" => from_value::<ReadFileInput>(input).and_then(|input| run_read_file(&input)),
|
||||||
"write_file" => from_value::<WriteFileInput>(input).and_then(run_write_file),
|
"write_file" => {
|
||||||
"edit_file" => from_value::<EditFileInput>(input).and_then(run_edit_file),
|
from_value::<WriteFileInput>(input).and_then(|input| run_write_file(&input))
|
||||||
"glob_search" => from_value::<GlobSearchInputValue>(input).and_then(run_glob_search),
|
}
|
||||||
"grep_search" => from_value::<GrepSearchInput>(input).and_then(run_grep_search),
|
"edit_file" => from_value::<EditFileInput>(input).and_then(|input| run_edit_file(&input)),
|
||||||
|
"glob_search" => {
|
||||||
|
from_value::<GlobSearchInputValue>(input).and_then(|input| run_glob_search(&input))
|
||||||
|
}
|
||||||
|
"grep_search" => {
|
||||||
|
from_value::<GrepSearchInput>(input).and_then(|input| run_grep_search(&input))
|
||||||
|
}
|
||||||
_ => Err(format!("unsupported tool: {name}")),
|
_ => Err(format!("unsupported tool: {name}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,15 +170,17 @@ fn run_bash(input: BashCommandInput) -> Result<String, String> {
|
|||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_read_file(input: ReadFileInput) -> Result<String, 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)?)
|
to_pretty_json(
|
||||||
|
read_file(&input.path, input.offset, input.limit).map_err(|error| io_to_string(&error))?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_write_file(input: WriteFileInput) -> Result<String, String> {
|
fn run_write_file(input: &WriteFileInput) -> Result<String, String> {
|
||||||
to_pretty_json(write_file(&input.path, &input.content).map_err(io_to_string)?)
|
to_pretty_json(write_file(&input.path, &input.content).map_err(|error| io_to_string(&error))?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_edit_file(input: EditFileInput) -> Result<String, String> {
|
fn run_edit_file(input: &EditFileInput) -> Result<String, String> {
|
||||||
to_pretty_json(
|
to_pretty_json(
|
||||||
edit_file(
|
edit_file(
|
||||||
&input.path,
|
&input.path,
|
||||||
@@ -180,23 +188,25 @@ fn run_edit_file(input: EditFileInput) -> Result<String, String> {
|
|||||||
&input.new_string,
|
&input.new_string,
|
||||||
input.replace_all.unwrap_or(false),
|
input.replace_all.unwrap_or(false),
|
||||||
)
|
)
|
||||||
.map_err(io_to_string)?,
|
.map_err(|error| io_to_string(&error))?,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_glob_search(input: GlobSearchInputValue) -> Result<String, 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)?)
|
to_pretty_json(
|
||||||
|
glob_search(&input.pattern, input.path.as_deref()).map_err(|error| io_to_string(&error))?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_grep_search(input: GrepSearchInput) -> Result<String, String> {
|
fn run_grep_search(input: &GrepSearchInput) -> Result<String, String> {
|
||||||
to_pretty_json(grep_search(&input).map_err(io_to_string)?)
|
to_pretty_json(grep_search(input).map_err(|error| io_to_string(&error))?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_pretty_json<T: serde::Serialize>(value: T) -> Result<String, 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())
|
serde_json::to_string_pretty(&value).map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn io_to_string(error: std::io::Error) -> String {
|
fn io_to_string(error: &std::io::Error) -> String {
|
||||||
error.to_string()
|
error.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user