Browse Source

Merge remote-tracking branch 'origin/rcc/cli' into dev/rust

Yeachan-Heo 2 months ago
parent
commit
681a0b58c3

+ 13 - 20
rust/crates/api/src/client.rs

@@ -305,48 +305,40 @@ struct AnthropicErrorBody {
 #[cfg(test)]
 mod tests {
     use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER};
+    use std::sync::{Mutex, OnceLock};
     use std::time::Duration;
 
     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]
     fn read_api_key_requires_presence() {
-        let previous_auth = std::env::var("ANTHROPIC_AUTH_TOKEN").ok();
-        let previous_key = std::env::var("ANTHROPIC_API_KEY").ok();
+        let _guard = env_lock();
         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));
-        match previous_auth {
-            Some(value) => std::env::set_var("ANTHROPIC_AUTH_TOKEN", value),
-            None => std::env::remove_var("ANTHROPIC_AUTH_TOKEN"),
-        }
-        match previous_key {
-            Some(value) => std::env::set_var("ANTHROPIC_API_KEY", value),
-            None => std::env::remove_var("ANTHROPIC_API_KEY"),
-        }
     }
 
     #[test]
     fn read_api_key_requires_non_empty_value() {
-        let previous_auth = std::env::var("ANTHROPIC_AUTH_TOKEN").ok();
-        let previous_key = std::env::var("ANTHROPIC_API_KEY").ok();
+        let _guard = env_lock();
         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));
-        match previous_auth {
-            Some(value) => std::env::set_var("ANTHROPIC_AUTH_TOKEN", value),
-            None => std::env::remove_var("ANTHROPIC_AUTH_TOKEN"),
-        }
-        match previous_key {
-            Some(value) => std::env::set_var("ANTHROPIC_API_KEY", value),
-            None => std::env::remove_var("ANTHROPIC_API_KEY"),
-        }
     }
 
     #[test]
     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_API_KEY", "legacy-key");
         assert_eq!(
@@ -359,6 +351,7 @@ mod tests {
 
     #[test]
     fn read_auth_token_reads_auth_token_env() {
+        let _guard = env_lock();
         std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
         assert_eq!(super::read_auth_token().as_deref(), Some("auth-token"));
         std::env::remove_var("ANTHROPIC_AUTH_TOKEN");

+ 274 - 10
rust/crates/commands/src/lib.rs

@@ -30,6 +30,168 @@ 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>,
+    pub resume_supported: bool,
+}
+
+const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
+    SlashCommandSpec {
+        name: "help",
+        summary: "Show available slash commands",
+        argument_hint: None,
+        resume_supported: true,
+    },
+    SlashCommandSpec {
+        name: "status",
+        summary: "Show current session status",
+        argument_hint: None,
+        resume_supported: true,
+    },
+    SlashCommandSpec {
+        name: "compact",
+        summary: "Compact local session history",
+        argument_hint: None,
+        resume_supported: true,
+    },
+    SlashCommandSpec {
+        name: "model",
+        summary: "Show or switch the active model",
+        argument_hint: Some("[model]"),
+        resume_supported: false,
+    },
+    SlashCommandSpec {
+        name: "permissions",
+        summary: "Show or switch the active permission mode",
+        argument_hint: Some("[read-only|workspace-write|danger-full-access]"),
+        resume_supported: false,
+    },
+    SlashCommandSpec {
+        name: "clear",
+        summary: "Start a fresh local session",
+        argument_hint: Some("[--confirm]"),
+        resume_supported: true,
+    },
+    SlashCommandSpec {
+        name: "cost",
+        summary: "Show cumulative token usage for this session",
+        argument_hint: None,
+        resume_supported: true,
+    },
+    SlashCommandSpec {
+        name: "resume",
+        summary: "Load a saved session into the REPL",
+        argument_hint: Some("<session-path>"),
+        resume_supported: false,
+    },
+    SlashCommandSpec {
+        name: "config",
+        summary: "Inspect discovered Claude config files",
+        argument_hint: None,
+        resume_supported: true,
+    },
+    SlashCommandSpec {
+        name: "memory",
+        summary: "Inspect loaded Claude instruction memory files",
+        argument_hint: None,
+        resume_supported: true,
+    },
+    SlashCommandSpec {
+        name: "init",
+        summary: "Create a starter CLAUDE.md for this repo",
+        argument_hint: None,
+        resume_supported: true,
+    },
+];
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum SlashCommand {
+    Help,
+    Status,
+    Compact,
+    Model { model: Option<String> },
+    Permissions { mode: Option<String> },
+    Clear { confirm: bool },
+    Cost,
+    Resume { session_path: Option<String> },
+    Config,
+    Memory,
+    Init,
+    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),
+            },
+            "permissions" => Self::Permissions {
+                mode: parts.next().map(ToOwned::to_owned),
+            },
+            "clear" => Self::Clear {
+                confirm: parts.next() == Some("--confirm"),
+            },
+            "cost" => Self::Cost,
+            "resume" => Self::Resume {
+                session_path: parts.next().map(ToOwned::to_owned),
+            },
+            "config" => Self::Config,
+            "memory" => Self::Memory,
+            "init" => Self::Init,
+            other => Self::Unknown(other.to_string()),
+        })
+    }
+}
+
+#[must_use]
+pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
+    SLASH_COMMAND_SPECS
+}
+
+#[must_use]
+pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
+    slash_command_specs()
+        .iter()
+        .filter(|spec| spec.resume_supported)
+        .collect()
+}
+
+#[must_use]
+pub fn render_slash_command_help() -> String {
+    let mut lines = vec![
+        "Available commands:".to_string(),
+        "  (resume-safe commands are marked with [resume])".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),
+        };
+        let resume = if spec.resume_supported {
+            " [resume]"
+        } else {
+            ""
+        };
+        lines.push(format!("  {name:<20} {}{}", spec.summary, resume));
+    }
+    lines.join("\n")
+}
+
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct SlashCommandResult {
     pub message: String,
@@ -42,13 +204,8 @@ pub fn handle_slash_command(
     session: &Session,
     compaction: CompactionConfig,
 ) -> Option<SlashCommandResult> {
-    let trimmed = input.trim();
-    if !trimmed.starts_with('/') {
-        return None;
-    }
-
-    match trimmed.split_whitespace().next() {
-        Some("/compact") => {
+    match SlashCommand::parse(input)? {
+        SlashCommand::Compact => {
             let result = compact_session(session, compaction);
             let message = if result.removed_message_count == 0 {
                 "Compaction skipped: session is below the compaction threshold.".to_string()
@@ -63,15 +220,90 @@ pub fn handle_slash_command(
                 session: result.compacted_session,
             })
         }
-        _ => None,
+        SlashCommand::Help => Some(SlashCommandResult {
+            message: render_slash_command_help(),
+            session: session.clone(),
+        }),
+        SlashCommand::Status
+        | SlashCommand::Model { .. }
+        | SlashCommand::Permissions { .. }
+        | SlashCommand::Clear { .. }
+        | SlashCommand::Cost
+        | SlashCommand::Resume { .. }
+        | SlashCommand::Config
+        | SlashCommand::Memory
+        | SlashCommand::Init
+        | SlashCommand::Unknown(_) => None,
     }
 }
 
 #[cfg(test)]
 mod tests {
-    use super::handle_slash_command;
+    use super::{
+        handle_slash_command, render_slash_command_help, resume_supported_slash_commands,
+        slash_command_specs, SlashCommand,
+    };
     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 })
+        );
+        assert_eq!(
+            SlashCommand::parse("/permissions read-only"),
+            Some(SlashCommand::Permissions {
+                mode: Some("read-only".to_string()),
+            })
+        );
+        assert_eq!(
+            SlashCommand::parse("/clear"),
+            Some(SlashCommand::Clear { confirm: false })
+        );
+        assert_eq!(
+            SlashCommand::parse("/clear --confirm"),
+            Some(SlashCommand::Clear { confirm: true })
+        );
+        assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost));
+        assert_eq!(
+            SlashCommand::parse("/resume session.json"),
+            Some(SlashCommand::Resume {
+                session_path: Some("session.json".to_string()),
+            })
+        );
+        assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config));
+        assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
+        assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
+    }
+
+    #[test]
+    fn renders_help_from_shared_specs() {
+        let help = render_slash_command_help();
+        assert!(help.contains("resume-safe commands"));
+        assert!(help.contains("/help"));
+        assert!(help.contains("/status"));
+        assert!(help.contains("/compact"));
+        assert!(help.contains("/model [model]"));
+        assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
+        assert!(help.contains("/clear [--confirm]"));
+        assert!(help.contains("/cost"));
+        assert!(help.contains("/resume <session-path>"));
+        assert!(help.contains("/config"));
+        assert!(help.contains("/memory"));
+        assert!(help.contains("/init"));
+        assert_eq!(slash_command_specs().len(), 11);
+        assert_eq!(resume_supported_slash_commands().len(), 8);
+    }
+
     #[test]
     fn compacts_sessions_via_slash_command() {
         let session = Session {
@@ -103,8 +335,40 @@ mod tests {
     }
 
     #[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();
         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()
+        );
+        assert!(handle_slash_command(
+            "/permissions read-only",
+            &session,
+            CompactionConfig::default()
+        )
+        .is_none());
+        assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none());
+        assert!(
+            handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
+                .is_none()
+        );
+        assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
+        assert!(handle_slash_command(
+            "/resume session.json",
+            &session,
+            CompactionConfig::default()
+        )
+        .is_none());
+        assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none());
     }
 }

+ 38 - 1
rust/crates/compat-harness/src/lib.rs

@@ -24,9 +24,10 @@ impl UpstreamPaths {
             .as_ref()
             .canonicalize()
             .unwrap_or_else(|_| workspace_dir.as_ref().to_path_buf());
-        let repo_root = workspace_dir
+        let primary_repo_root = workspace_dir
             .parent()
             .map_or_else(|| PathBuf::from(".."), Path::to_path_buf);
+        let repo_root = resolve_upstream_repo_root(&primary_repo_root);
         Self { repo_root }
     }
 
@@ -53,6 +54,42 @@ pub struct ExtractedManifest {
     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> {
     let commands_source = fs::read_to_string(paths.commands_path())?;
     let tools_source = fs::read_to_string(paths.tools_path())?;

+ 10 - 10
rust/crates/runtime/src/file_ops.rs

@@ -285,7 +285,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
         .output_mode
         .clone()
         .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 content_lines = Vec::new();
@@ -296,12 +296,12 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
             continue;
         }
 
-        let Ok(file_text) = fs::read_to_string(&file_path) else {
+        let Ok(file_content) = fs::read_to_string(&file_path) else {
             continue;
         };
 
         if output_mode == "count" {
-            let count = regex.find_iter(&file_text).count();
+            let count = regex.find_iter(&file_content).count();
             if count > 0 {
                 filenames.push(file_path.to_string_lossy().into_owned());
                 total_matches += count;
@@ -309,7 +309,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
             continue;
         }
 
-        let lines: Vec<&str> = file_text.lines().collect();
+        let lines: Vec<&str> = file_content.lines().collect();
         let mut matched_lines = Vec::new();
         for (index, line) in lines.iter().enumerate() {
             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());
         if output_mode == "content" {
             for index in matched_lines {
-                let start = index.saturating_sub(input.before.unwrap_or(context));
-                let end = (index + input.after.unwrap_or(context) + 1).min(lines.len());
-                for (current, line_text) in lines.iter().enumerate().take(end).skip(start) {
+                let start = index.saturating_sub(input.before.unwrap_or(context_window));
+                let end = (index + input.after.unwrap_or(context_window) + 1).min(lines.len());
+                for (current, line_content) in lines.iter().enumerate().take(end).skip(start) {
                     let prefix = if input.line_numbers.unwrap_or(true) {
                         format!("{}:{}:", file_path.to_string_lossy(), current + 1)
                     } else {
                         format!("{}:", file_path.to_string_lossy())
                     };
-                    content_lines.push(format!("{prefix}{line_text}"));
+                    content_lines.push(format!("{prefix}{line_content}"));
                 }
             }
         }
@@ -341,7 +341,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
 
     let (filenames, applied_limit, applied_offset) =
         apply_limit(filenames, input.head_limit, input.offset);
-    let content_output = if output_mode == "content" {
+    let content = if output_mode == "content" {
         let (lines, limit, offset) = apply_limit(content_lines, input.head_limit, input.offset);
         return Ok(GrepSearchOutput {
             mode: Some(output_mode),
@@ -361,7 +361,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
         mode: Some(output_mode.clone()),
         num_files: filenames.len(),
         filenames,
-        content: content_output,
+        content,
         num_lines: None,
         num_matches: (output_mode == "count").then_some(total_matches),
         applied_limit,

File diff suppressed because it is too large
+ 564 - 404
rust/crates/rusty-claude-cli/src/main.rs


+ 9 - 5
rust/crates/tools/src/lib.rs

@@ -172,12 +172,12 @@ fn run_bash(input: BashCommandInput) -> 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(|error| error.to_string())?,
+        read_file(&input.path, input.offset, input.limit).map_err(|error| io_to_string(&error))?,
     )
 }
 
 fn run_write_file(input: &WriteFileInput) -> Result<String, String> {
-    to_pretty_json(write_file(&input.path, &input.content).map_err(|error| error.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> {
@@ -188,24 +188,28 @@ fn run_edit_file(input: &EditFileInput) -> Result<String, String> {
             &input.new_string,
             input.replace_all.unwrap_or(false),
         )
-        .map_err(|error| error.to_string())?,
+        .map_err(|error| io_to_string(&error))?,
     )
 }
 
 fn run_glob_search(input: &GlobSearchInputValue) -> Result<String, String> {
     to_pretty_json(
-        glob_search(&input.pattern, input.path.as_deref()).map_err(|error| error.to_string())?,
+        glob_search(&input.pattern, input.path.as_deref()).map_err(|error| io_to_string(&error))?,
     )
 }
 
 fn run_grep_search(input: &GrepSearchInput) -> Result<String, String> {
-    to_pretty_json(grep_search(input).map_err(|error| error.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> {
     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,

Some files were not shown because too many files changed in this diff