From a96bb6c60f4f7e2572942da3aa46972527af48cb Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 19:23:05 +0000 Subject: [PATCH 01/11] 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 --- rust/crates/api/src/client.rs | 21 +++- rust/crates/commands/src/lib.rs | 139 +++++++++++++++++++-- rust/crates/compat-harness/src/lib.rs | 39 +++++- rust/crates/runtime/src/file_ops.rs | 25 ++-- rust/crates/rusty-claude-cli/src/main.rs | 150 +++++++++++++++++++---- rust/crates/tools/src/lib.rs | 42 ++++--- 6 files changed, 350 insertions(+), 66 deletions(-) diff --git a/rust/crates/api/src/client.rs b/rust/crates/api/src/client.rs index d77cf9c..5756b3e 100644 --- a/rust/crates/api/src/client.rs +++ b/rust/crates/api/src/client.rs @@ -158,7 +158,10 @@ impl AnthropicClient { .header("anthropic-version", ANTHROPIC_VERSION) .header("content-type", "application/json"); - let auth_header = self.auth_token.as_ref().map(|_| "Bearer [REDACTED]").unwrap_or(""); + let auth_header = self + .auth_token + .as_ref() + .map_or("", |_| "Bearer [REDACTED]"); 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 { @@ -192,8 +195,7 @@ fn read_api_key() -> Result { Ok(_) => Err(ApiError::MissingApiKey), Err(std::env::VarError::NotPresent) => match std::env::var("ANTHROPIC_AUTH_TOKEN") { Ok(api_key) if !api_key.is_empty() => Ok(api_key), - Ok(_) => Err(ApiError::MissingApiKey), - Err(std::env::VarError::NotPresent) => Err(ApiError::MissingApiKey), + Ok(_) | Err(std::env::VarError::NotPresent) => Err(ApiError::MissingApiKey), Err(error) => Err(ApiError::from(error)), }, Err(error) => Err(ApiError::from(error)), @@ -303,12 +305,22 @@ 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> = 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 _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"); @@ -317,6 +329,7 @@ mod tests { #[test] fn read_api_key_requires_non_empty_value() { + 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"); @@ -325,6 +338,7 @@ mod tests { #[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!( @@ -337,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"); diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index ea0624a..57f5826 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -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 }, + Unknown(String), +} + +impl SlashCommand { + #[must_use] + pub fn parse(input: &str) -> Option { + 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)] pub struct SlashCommandResult { pub message: String, @@ -42,13 +121,8 @@ pub fn handle_slash_command( session: &Session, compaction: CompactionConfig, ) -> Option { - 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 +137,47 @@ 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::Unknown(_) => None, } } #[cfg(test)] 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}; + #[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] fn compacts_sessions_via_slash_command() { let session = Session { @@ -103,8 +209,21 @@ 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() + ); } } diff --git a/rust/crates/compat-harness/src/lib.rs b/rust/crates/compat-harness/src/lib.rs index 61769d8..0363d8c 100644 --- a/rust/crates/compat-harness/src/lib.rs +++ b/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 { + 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 { let commands_source = fs::read_to_string(paths.commands_path())?; let tools_source = fs::read_to_string(paths.tools_path())?; diff --git a/rust/crates/runtime/src/file_ops.rs b/rust/crates/runtime/src/file_ops.rs index 42b3bab..47a5f7e 100644 --- a/rust/crates/runtime/src/file_ops.rs +++ b/rust/crates/runtime/src/file_ops.rs @@ -138,9 +138,9 @@ pub fn read_file( let content = fs::read_to_string(&absolute_path)?; let lines: Vec<&str> = content.lines().collect(); let start_index = offset.unwrap_or(0).min(lines.len()); - let end_index = limit - .map(|limit| start_index.saturating_add(limit).min(lines.len())) - .unwrap_or(lines.len()); + let end_index = limit.map_or(lines.len(), |limit| { + start_index.saturating_add(limit).min(lines.len()) + }); let selected = lines[start_index..end_index].join("\n"); Ok(ReadFileOutput { @@ -285,7 +285,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result { .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 { continue; } - let Ok(content) = 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(&content).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 { continue; } - let lines: Vec<&str> = content.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 { 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 in start..end { + 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}{}", lines[current])); + content_lines.push(format!("{prefix}{line_content}")); } } } @@ -376,8 +376,7 @@ fn collect_search_files(base_path: &Path) -> io::Result> { let mut files = Vec::new(); 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::other(error.to_string()))?; if entry.file_type().is_file() { files.push(entry.path().to_path_buf()); } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 43033e2..2a08694 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -11,7 +11,7 @@ use api::{ ToolResultContentBlock, }; -use commands::handle_slash_command; +use commands::{handle_slash_command, render_slash_command_help, SlashCommand}; use compat_harness::{extract_manifest, UpstreamPaths}; use render::{Spinner, TerminalRenderer}; use runtime::{ @@ -82,7 +82,7 @@ fn parse_args(args: &[String]) -> Result { let value = args .get(index + 1) .ok_or_else(|| "missing value for --model".to_string())?; - model = value.clone(); + model.clone_from(value); index += 2; } flag if flag.starts_with("--model=") => { @@ -249,19 +249,14 @@ fn run_repl(model: String) -> Result<(), Box> { 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)?, + if matches!(trimmed, "/exit" | "/quit") { + break; } + if let Some(command) = SlashCommand::parse(trimmed) { + cli.handle_repl_command(command)?; + continue; + } + cli.run_turn(trimmed)?; } Ok(()) @@ -319,17 +314,55 @@ impl LiveCli { } } + fn handle_repl_command( + &mut self, + command: SlashCommand, + ) -> Result<(), Box> { + 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) { - let usage = self.runtime.usage().cumulative_usage(); + let cumulative = self.runtime.usage().cumulative_usage(); + let latest = self.runtime.usage().current_turn_usage(); println!( - "status: messages={} turns={} input_tokens={} output_tokens={}", - self.runtime.session().messages.len(), - self.runtime.usage().turns(), - usage.input_tokens, - usage.output_tokens + "{}", + format_status_line( + &self.model, + self.runtime.session().messages.len(), + self.runtime.usage().turns(), + latest, + cumulative, + self.runtime.estimated_tokens(), + permission_mode_label(), + ) ); } + fn set_model(&mut self, model: Option) -> Result<(), Box> { + 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> { let result = self.runtime.compact(CompactionConfig::default()); 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, Box> { Ok(load_system_prompt( env::current_dir()?, @@ -388,6 +454,7 @@ impl AnthropicRuntimeClient { } impl ApiClient for AnthropicRuntimeClient { + #[allow(clippy::too_many_lines)] fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { let message_request = MessageRequest { model: self.model.clone(), @@ -442,7 +509,7 @@ impl ApiClient for AnthropicRuntimeClient { ContentBlockDelta::TextDelta { text } => { if !text.is_empty() { write!(stdout, "{text}") - .and_then(|_| stdout.flush()) + .and_then(|()| stdout.flush()) .map_err(|error| RuntimeError::new(error.to_string()))?; events.push(AssistantEvent::TextDelta(text)); } @@ -512,7 +579,7 @@ fn push_output_block( OutputContentBlock::Text { text } => { if !text.is_empty() { write!(out, "{text}") - .and_then(|_| out.flush()) + .and_then(|()| out.flush()) .map_err(|error| RuntimeError::new(error.to_string()))?; events.push(AssistantEvent::TextDelta(text)); } @@ -646,7 +713,7 @@ fn print_help() { #[cfg(test)] 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 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] fn converts_tool_roundtrip_messages() { let messages = vec![ diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index d8806b8..e849990 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -146,11 +146,17 @@ pub fn mvp_tool_specs() -> Vec { pub fn execute_tool(name: &str, input: &Value) -> Result { match name { "bash" => from_value::(input).and_then(run_bash), - "read_file" => from_value::(input).and_then(run_read_file), - "write_file" => from_value::(input).and_then(run_write_file), - "edit_file" => from_value::(input).and_then(run_edit_file), - "glob_search" => from_value::(input).and_then(run_glob_search), - "grep_search" => from_value::(input).and_then(run_grep_search), + "read_file" => from_value::(input).and_then(|input| run_read_file(&input)), + "write_file" => { + from_value::(input).and_then(|input| run_write_file(&input)) + } + "edit_file" => from_value::(input).and_then(|input| run_edit_file(&input)), + "glob_search" => { + from_value::(input).and_then(|input| run_glob_search(&input)) + } + "grep_search" => { + from_value::(input).and_then(|input| run_grep_search(&input)) + } _ => Err(format!("unsupported tool: {name}")), } } @@ -164,15 +170,17 @@ fn run_bash(input: BashCommandInput) -> Result { .map_err(|error| error.to_string()) } -fn run_read_file(input: ReadFileInput) -> Result { - to_pretty_json(read_file(&input.path, input.offset, input.limit).map_err(io_to_string)?) +fn run_read_file(input: &ReadFileInput) -> Result { + 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 { - to_pretty_json(write_file(&input.path, &input.content).map_err(io_to_string)?) +fn run_write_file(input: &WriteFileInput) -> Result { + to_pretty_json(write_file(&input.path, &input.content).map_err(|error| io_to_string(&error))?) } -fn run_edit_file(input: EditFileInput) -> Result { +fn run_edit_file(input: &EditFileInput) -> Result { to_pretty_json( edit_file( &input.path, @@ -180,23 +188,25 @@ fn run_edit_file(input: EditFileInput) -> Result { &input.new_string, 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 { - to_pretty_json(glob_search(&input.pattern, input.path.as_deref()).map_err(io_to_string)?) +fn run_glob_search(input: &GlobSearchInputValue) -> Result { + 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 { - to_pretty_json(grep_search(&input).map_err(io_to_string)?) +fn run_grep_search(input: &GrepSearchInput) -> Result { + to_pretty_json(grep_search(input).map_err(|error| io_to_string(&error))?) } fn to_pretty_json(value: T) -> Result { 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() } From 071045f5564785f5534855aedfe97e52ce1f83d6 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 19:27:31 +0000 Subject: [PATCH 02/11] feat(cli): add permissions clear and cost commands Expand the shared slash registry and REPL dispatcher with real session-management commands so the CLI feels closer to Claude Code during interactive use. /permissions now reports or switches the active permission mode, /clear rebuilds a fresh local session without restarting the process, and /cost reports cumulative token usage honestly from the runtime tracker. The implementation keeps command parsing centralized in the commands crate and preserves the existing prompt-mode path while rebuilding runtime state safely when commands change session configuration. Constraint: Commands must be genuinely useful local behavior rather than placeholders Constraint: Preserve REPL continuity when changing permissions or clearing session state Rejected: Store permission-mode changes only in environment variables | would not update the live runtime for the current session Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep future stateful slash commands rebuilding from current session + system prompt instead of mutating hidden runtime internals Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace Not-tested: manual live API session exercising permission changes mid-conversation --- rust/crates/commands/src/lib.rs | 51 +++++++++- rust/crates/rusty-claude-cli/src/main.rs | 123 +++++++++++++++++++++-- 2 files changed, 162 insertions(+), 12 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 57f5826..6ca2cdf 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -58,6 +58,21 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ summary: "Show or switch the active model", argument_hint: Some("[model]"), }, + SlashCommandSpec { + name: "permissions", + summary: "Show or switch the active permission mode", + argument_hint: Some("[read-only|workspace-write|danger-full-access]"), + }, + SlashCommandSpec { + name: "clear", + summary: "Start a fresh local session", + argument_hint: None, + }, + SlashCommandSpec { + name: "cost", + summary: "Show cumulative token usage for this session", + argument_hint: None, + }, ]; #[derive(Debug, Clone, PartialEq, Eq)] @@ -66,6 +81,9 @@ pub enum SlashCommand { Status, Compact, Model { model: Option }, + Permissions { mode: Option }, + Clear, + Cost, Unknown(String), } @@ -86,6 +104,11 @@ impl SlashCommand { "model" => Self::Model { model: parts.next().map(ToOwned::to_owned), }, + "permissions" => Self::Permissions { + mode: parts.next().map(ToOwned::to_owned), + }, + "clear" => Self::Clear, + "cost" => Self::Cost, other => Self::Unknown(other.to_string()), }) } @@ -141,7 +164,12 @@ pub fn handle_slash_command( message: render_slash_command_help(), session: session.clone(), }), - SlashCommand::Status | SlashCommand::Model { .. } | SlashCommand::Unknown(_) => None, + SlashCommand::Status + | SlashCommand::Model { .. } + | SlashCommand::Permissions { .. } + | SlashCommand::Clear + | SlashCommand::Cost + | SlashCommand::Unknown(_) => None, } } @@ -166,6 +194,14 @@ mod tests { 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)); + assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost)); } #[test] @@ -175,7 +211,10 @@ mod tests { assert!(help.contains("/status")); assert!(help.contains("/compact")); assert!(help.contains("/model [model]")); - assert_eq!(slash_command_specs().len(), 4); + assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); + assert!(help.contains("/clear")); + assert!(help.contains("/cost")); + assert_eq!(slash_command_specs().len(), 7); } #[test] @@ -225,5 +264,13 @@ mod tests { 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("/cost", &session, CompactionConfig::default()).is_none()); } } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 2a08694..b703a22 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -323,6 +323,9 @@ impl LiveCli { SlashCommand::Status => self.print_status(), SlashCommand::Compact => self.compact()?, SlashCommand::Model { model } => self.set_model(model)?, + SlashCommand::Permissions { mode } => self.set_permissions(mode)?, + SlashCommand::Clear => self.clear_session()?, + SlashCommand::Cost => self.print_cost(), SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"), } Ok(()) @@ -363,14 +366,68 @@ impl LiveCli { Ok(()) } + fn set_permissions(&mut self, mode: Option) -> Result<(), Box> { + let Some(mode) = mode else { + println!("Current permission mode: {}", permission_mode_label()); + return Ok(()); + }; + + let normalized = normalize_permission_mode(&mode).ok_or_else(|| { + format!( + "Unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access." + ) + })?; + + if normalized == permission_mode_label() { + println!("Permission mode already set to {normalized}."); + return Ok(()); + } + + let session = self.runtime.session().clone(); + self.runtime = build_runtime_with_permission_mode( + session, + self.model.clone(), + self.system_prompt.clone(), + true, + normalized, + )?; + println!("Switched permission mode to {normalized}."); + Ok(()) + } + + fn clear_session(&mut self) -> Result<(), Box> { + self.runtime = build_runtime_with_permission_mode( + Session::new(), + self.model.clone(), + self.system_prompt.clone(), + true, + permission_mode_label(), + )?; + println!("Cleared local session history."); + Ok(()) + } + + fn print_cost(&self) { + let cumulative = self.runtime.usage().cumulative_usage(); + println!( + "cost: input_tokens={} output_tokens={} cache_creation_tokens={} cache_read_tokens={} total_tokens={}", + cumulative.input_tokens, + cumulative.output_tokens, + cumulative.cache_creation_input_tokens, + cumulative.cache_read_input_tokens, + cumulative.total_tokens(), + ); + } + fn compact(&mut self) -> Result<(), Box> { let result = self.runtime.compact(CompactionConfig::default()); let removed = result.removed_message_count; - self.runtime = build_runtime( + self.runtime = build_runtime_with_permission_mode( result.compacted_session, self.model.clone(), self.system_prompt.clone(), true, + permission_mode_label(), )?; println!("Compacted {removed} messages."); Ok(()) @@ -403,9 +460,19 @@ fn format_status_line( ) } +fn normalize_permission_mode(mode: &str) -> Option<&'static str> { + match mode.trim() { + "read-only" => Some("read-only"), + "workspace-write" => Some("workspace-write"), + "danger-full-access" => Some("danger-full-access"), + _ => None, + } +} + fn permission_mode_label() -> &'static str { match env::var("RUSTY_CLAUDE_PERMISSION_MODE") { Ok(value) if value == "read-only" => "read-only", + Ok(value) if value == "danger-full-access" => "danger-full-access", _ => "workspace-write", } } @@ -425,12 +492,29 @@ fn build_runtime( system_prompt: Vec, enable_tools: bool, ) -> Result, Box> +{ + build_runtime_with_permission_mode( + session, + model, + system_prompt, + enable_tools, + permission_mode_label(), + ) +} + +fn build_runtime_with_permission_mode( + session: Session, + model: String, + system_prompt: Vec, + enable_tools: bool, + permission_mode: &str, +) -> Result, Box> { Ok(ConversationRuntime::new( session, AnthropicRuntimeClient::new(model, enable_tools)?, CliToolExecutor::new(), - permission_policy_from_env(), + permission_policy(permission_mode), system_prompt, )) } @@ -644,15 +728,14 @@ impl ToolExecutor for CliToolExecutor { } } -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) +fn permission_policy(mode: &str) -> PermissionPolicy { + if normalize_permission_mode(mode) == Some("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), + .with_tool_mode("grep_search", PermissionMode::Allow) + } else { + PermissionPolicy::new(PermissionMode::Allow) } } @@ -713,7 +796,10 @@ fn print_help() { #[cfg(test)] mod tests { - use super::{format_status_line, parse_args, render_repl_help, CliAction, DEFAULT_MODEL}; + use super::{ + format_status_line, normalize_permission_mode, parse_args, render_repl_help, CliAction, + DEFAULT_MODEL, + }; use runtime::{ContentBlock, ConversationMessage, MessageRole}; use std::path::PathBuf; @@ -783,6 +869,9 @@ mod tests { assert!(help.contains("/help")); assert!(help.contains("/status")); assert!(help.contains("/model [model]")); + assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); + assert!(help.contains("/clear")); + assert!(help.contains("/cost")); assert!(help.contains("/exit")); } @@ -814,6 +903,20 @@ mod tests { assert!(status.contains("cumulative_total_tokens=31")); } + #[test] + fn normalizes_supported_permission_modes() { + assert_eq!(normalize_permission_mode("read-only"), Some("read-only")); + assert_eq!( + normalize_permission_mode("workspace-write"), + Some("workspace-write") + ); + assert_eq!( + normalize_permission_mode("danger-full-access"), + Some("danger-full-access") + ); + assert_eq!(normalize_permission_mode("unknown"), None); + } + #[test] fn converts_tool_roundtrip_messages() { let messages = vec![ From 321a1a681aa7d2046c564fafe009a67acd5ec0e3 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 19:45:25 +0000 Subject: [PATCH 03/11] feat(cli): add resume and config inspection commands Add in-REPL session restoration and read-only config inspection so the CLI can recover saved conversations and expose Claude settings without leaving interactive mode. /resume now reloads a session file into the live runtime, and /config shows discovered settings files plus the merged effective JSON. The new commands stay on the shared slash-command surface and rebuild runtime state using the current model, system prompt, and permission mode so existing REPL behavior remains stable. Constraint: /resume must update the live REPL session rather than only supporting top-level --resume Constraint: /config should inspect existing settings without mutating user files Rejected: Add editable /config writes in this slice | read-only inspection is safer and sufficient for immediate parity work Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep resume/config behavior on the shared slash command surface so non-REPL entrypoints can reuse it later Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace Not-tested: manual interactive restore against real saved session files outside automated fixtures --- rust/crates/commands/src/lib.rs | 36 ++++++++++- rust/crates/rusty-claude-cli/src/main.rs | 77 ++++++++++++++++++++++-- 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 6ca2cdf..a60975e 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -73,6 +73,16 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ summary: "Show cumulative token usage for this session", argument_hint: None, }, + SlashCommandSpec { + name: "resume", + summary: "Load a saved session into the REPL", + argument_hint: Some(""), + }, + SlashCommandSpec { + name: "config", + summary: "Inspect discovered Claude config files", + argument_hint: None, + }, ]; #[derive(Debug, Clone, PartialEq, Eq)] @@ -84,6 +94,8 @@ pub enum SlashCommand { Permissions { mode: Option }, Clear, Cost, + Resume { session_path: Option }, + Config, Unknown(String), } @@ -109,6 +121,10 @@ impl SlashCommand { }, "clear" => Self::Clear, "cost" => Self::Cost, + "resume" => Self::Resume { + session_path: parts.next().map(ToOwned::to_owned), + }, + "config" => Self::Config, other => Self::Unknown(other.to_string()), }) } @@ -169,6 +185,8 @@ pub fn handle_slash_command( | SlashCommand::Permissions { .. } | SlashCommand::Clear | SlashCommand::Cost + | SlashCommand::Resume { .. } + | SlashCommand::Config | SlashCommand::Unknown(_) => None, } } @@ -202,6 +220,13 @@ mod tests { ); assert_eq!(SlashCommand::parse("/clear"), Some(SlashCommand::Clear)); 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)); } #[test] @@ -214,7 +239,9 @@ mod tests { assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/clear")); assert!(help.contains("/cost")); - assert_eq!(slash_command_specs().len(), 7); + assert!(help.contains("/resume ")); + assert!(help.contains("/config")); + assert_eq!(slash_command_specs().len(), 9); } #[test] @@ -272,5 +299,12 @@ mod tests { .is_none()); assert!(handle_slash_command("/clear", &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()); } } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index b703a22..1c998ae 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -15,9 +15,9 @@ use commands::{handle_slash_command, render_slash_command_help, SlashCommand}; use compat_harness::{extract_manifest, UpstreamPaths}; use render::{Spinner, TerminalRenderer}; use runtime::{ - load_system_prompt, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ContentBlock, - ConversationMessage, ConversationRuntime, MessageRole, PermissionMode, PermissionPolicy, - RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, + load_system_prompt, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, + ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole, + PermissionMode, PermissionPolicy, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, }; use tools::{execute_tool, mvp_tool_specs}; @@ -326,6 +326,8 @@ impl LiveCli { SlashCommand::Permissions { mode } => self.set_permissions(mode)?, SlashCommand::Clear => self.clear_session()?, SlashCommand::Cost => self.print_cost(), + SlashCommand::Resume { session_path } => self.resume_session(session_path)?, + SlashCommand::Config => Self::print_config()?, SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"), } Ok(()) @@ -419,6 +421,60 @@ impl LiveCli { ); } + fn resume_session( + &mut self, + session_path: Option, + ) -> Result<(), Box> { + let Some(session_path) = session_path else { + println!("Usage: /resume "); + return Ok(()); + }; + + let session = Session::load_from_path(&session_path)?; + let message_count = session.messages.len(); + self.runtime = build_runtime_with_permission_mode( + session, + self.model.clone(), + self.system_prompt.clone(), + true, + permission_mode_label(), + )?; + println!("Resumed session from {session_path} ({message_count} messages)."); + Ok(()) + } + + fn print_config() -> Result<(), Box> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let discovered = loader.discover(); + let runtime_config = loader.load()?; + + println!( + "config: loaded_files={} merged_keys={}", + runtime_config.loaded_entries().len(), + runtime_config.merged().len() + ); + for entry in discovered { + let source = match entry.source { + ConfigSource::User => "user", + ConfigSource::Project => "project", + ConfigSource::Local => "local", + }; + let status = if runtime_config + .loaded_entries() + .iter() + .any(|loaded_entry| loaded_entry.path == entry.path) + { + "loaded" + } else { + "missing" + }; + println!(" {source:<7} {status:<7} {}", entry.path.display()); + } + println!(" merged {}", runtime_config.as_json().render()); + Ok(()) + } + fn compact(&mut self) -> Result<(), Box> { let result = self.runtime.compact(CompactionConfig::default()); let removed = result.removed_message_count; @@ -798,7 +854,7 @@ fn print_help() { mod tests { use super::{ format_status_line, normalize_permission_mode, parse_args, render_repl_help, CliAction, - DEFAULT_MODEL, + SlashCommand, DEFAULT_MODEL, }; use runtime::{ContentBlock, ConversationMessage, MessageRole}; use std::path::PathBuf; @@ -872,6 +928,8 @@ mod tests { assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/clear")); assert!(help.contains("/cost")); + assert!(help.contains("/resume ")); + assert!(help.contains("/config")); assert!(help.contains("/exit")); } @@ -917,6 +975,17 @@ mod tests { assert_eq!(normalize_permission_mode("unknown"), None); } + #[test] + fn parses_resume_and_config_slash_commands() { + assert_eq!( + SlashCommand::parse("/resume saved-session.json"), + Some(SlashCommand::Resume { + session_path: Some("saved-session.json".to_string()) + }) + ); + assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config)); + } + #[test] fn converts_tool_roundtrip_messages() { let messages = vec![ From c024d8b21f942feee82502ab80d7afbf013e2893 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 19:54:09 +0000 Subject: [PATCH 04/11] feat(cli): extend resume commands and add memory inspection Improve resumed-session parity by letting top-level --resume execute shared read-only commands such as /help, /status, /cost, /config, and /memory in addition to /compact. This makes saved sessions meaningfully inspectable without reopening the interactive REPL. Also add a genuinely useful /memory command that reports the Claude instruction memory already discovered by the runtime from CLAUDE.md-style files in the current directory ancestry. The command stays honest by surfacing file paths, line counts, and a short preview instead of inventing unsupported persistent memory behavior. Constraint: Resume-path improvements must operate safely on saved sessions without requiring a live model runtime Constraint: /memory must expose real repository instruction context rather than placeholder state Rejected: Invent editable or persistent chat memory storage | no such durable feature exists in this repo yet Confidence: high Scope-risk: moderate Reversibility: clean Directive: Reuse shared slash parsing for resume-path features so saved-session commands and REPL commands stay aligned Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace Not-tested: manual resume against a diverse set of historical session files from real user workflows --- rust/crates/commands/src/lib.rs | 12 +- rust/crates/rusty-claude-cli/src/main.rs | 194 +++++++++++++++++------ 2 files changed, 158 insertions(+), 48 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index a60975e..2d3c264 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -83,6 +83,11 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ summary: "Inspect discovered Claude config files", argument_hint: None, }, + SlashCommandSpec { + name: "memory", + summary: "Inspect loaded Claude instruction memory files", + argument_hint: None, + }, ]; #[derive(Debug, Clone, PartialEq, Eq)] @@ -96,6 +101,7 @@ pub enum SlashCommand { Cost, Resume { session_path: Option }, Config, + Memory, Unknown(String), } @@ -125,6 +131,7 @@ impl SlashCommand { session_path: parts.next().map(ToOwned::to_owned), }, "config" => Self::Config, + "memory" => Self::Memory, other => Self::Unknown(other.to_string()), }) } @@ -187,6 +194,7 @@ pub fn handle_slash_command( | SlashCommand::Cost | SlashCommand::Resume { .. } | SlashCommand::Config + | SlashCommand::Memory | SlashCommand::Unknown(_) => None, } } @@ -227,6 +235,7 @@ mod tests { }) ); assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config)); + assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); } #[test] @@ -241,7 +250,8 @@ mod tests { assert!(help.contains("/cost")); assert!(help.contains("/resume ")); assert!(help.contains("/config")); - assert_eq!(slash_command_specs().len(), 9); + assert!(help.contains("/memory")); + assert_eq!(slash_command_specs().len(), 10); } #[test] diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 1c998ae..3ba1a32 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -17,7 +17,8 @@ use render::{Spinner, TerminalRenderer}; use runtime::{ load_system_prompt, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole, - PermissionMode, PermissionPolicy, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, + PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, Session, TokenUsage, ToolError, + ToolExecutor, UsageTracker, }; use tools::{execute_tool, mvp_tool_specs}; @@ -205,27 +206,20 @@ fn resume_session(session_path: &Path, command: Option) { } }; - match command { - Some(command) if command.starts_with('/') => { - let Some(result) = handle_slash_command( - &command, - &session, - CompactionConfig { - max_estimated_tokens: 0, - ..CompactionConfig::default() - }, - ) else { - eprintln!("unknown slash command: {command}"); + match command.as_deref().and_then(SlashCommand::parse) { + Some(command) => match run_resume_command(session_path, &session, &command) { + Ok(Some(message)) => println!("{message}"), + Ok(None) => {} + Err(error) => { + eprintln!("{error}"); std::process::exit(2); - }; - if let Err(error) = result.session.save_to_path(session_path) { - eprintln!("failed to persist resumed session: {error}"); - std::process::exit(1); } - println!("{}", result.message); - } - Some(other) => { - eprintln!("unsupported resumed command: {other}"); + }, + None if command.is_some() => { + eprintln!( + "unsupported resumed command: {}", + command.unwrap_or_default() + ); std::process::exit(2); } None => { @@ -238,6 +232,60 @@ fn resume_session(session_path: &Path, command: Option) { } } +fn run_resume_command( + session_path: &Path, + session: &Session, + command: &SlashCommand, +) -> Result, Box> { + match command { + SlashCommand::Help => Ok(Some(render_repl_help())), + SlashCommand::Compact => { + let Some(result) = handle_slash_command( + "/compact", + session, + CompactionConfig { + max_estimated_tokens: 0, + ..CompactionConfig::default() + }, + ) else { + return Ok(None); + }; + result.session.save_to_path(session_path)?; + Ok(Some(result.message)) + } + SlashCommand::Status => { + let usage = UsageTracker::from_session(session).cumulative_usage(); + Ok(Some(format_status_line( + "restored-session", + session.messages.len(), + UsageTracker::from_session(session).turns(), + UsageTracker::from_session(session).current_turn_usage(), + usage, + 0, + permission_mode_label(), + ))) + } + SlashCommand::Cost => { + let usage = UsageTracker::from_session(session).cumulative_usage(); + Ok(Some(format!( + "cost: input_tokens={} output_tokens={} cache_creation_tokens={} cache_read_tokens={} total_tokens={}", + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_input_tokens, + usage.cache_read_input_tokens, + usage.total_tokens(), + ))) + } + SlashCommand::Config => Ok(Some(render_config_report()?)), + SlashCommand::Memory => Ok(Some(render_memory_report()?)), + SlashCommand::Resume { .. } + | SlashCommand::Model { .. } + | SlashCommand::Permissions { .. } + | SlashCommand::Clear + | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), + } +} + fn run_repl(model: String) -> Result<(), Box> { let mut cli = LiveCli::new(model, true)?; let editor = input::LineEditor::new("› "); @@ -328,6 +376,7 @@ impl LiveCli { SlashCommand::Cost => self.print_cost(), SlashCommand::Resume { session_path } => self.resume_session(session_path)?, SlashCommand::Config => Self::print_config()?, + SlashCommand::Memory => Self::print_memory()?, SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"), } Ok(()) @@ -444,34 +493,12 @@ impl LiveCli { } fn print_config() -> Result<(), Box> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let discovered = loader.discover(); - let runtime_config = loader.load()?; + println!("{}", render_config_report()?); + Ok(()) + } - println!( - "config: loaded_files={} merged_keys={}", - runtime_config.loaded_entries().len(), - runtime_config.merged().len() - ); - for entry in discovered { - let source = match entry.source { - ConfigSource::User => "user", - ConfigSource::Project => "project", - ConfigSource::Local => "local", - }; - let status = if runtime_config - .loaded_entries() - .iter() - .any(|loaded_entry| loaded_entry.path == entry.path) - { - "loaded" - } else { - "missing" - }; - println!(" {source:<7} {status:<7} {}", entry.path.display()); - } - println!(" merged {}", runtime_config.as_json().render()); + fn print_memory() -> Result<(), Box> { + println!("{}", render_memory_report()?); Ok(()) } @@ -516,6 +543,77 @@ fn format_status_line( ) } +fn render_config_report() -> Result> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let discovered = loader.discover(); + let runtime_config = loader.load()?; + + let mut lines = vec![format!( + "config: loaded_files={} merged_keys={}", + runtime_config.loaded_entries().len(), + runtime_config.merged().len() + )]; + for entry in discovered { + let source = match entry.source { + ConfigSource::User => "user", + ConfigSource::Project => "project", + ConfigSource::Local => "local", + }; + let status = if runtime_config + .loaded_entries() + .iter() + .any(|loaded_entry| loaded_entry.path == entry.path) + { + "loaded" + } else { + "missing" + }; + lines.push(format!( + " {source:<7} {status:<7} {}", + entry.path.display() + )); + } + lines.push(format!(" merged {}", runtime_config.as_json().render())); + Ok(lines.join( + " +", + )) +} + +fn render_memory_report() -> Result> { + let project_context = ProjectContext::discover(env::current_dir()?, DEFAULT_DATE)?; + let mut lines = vec![format!( + "memory: files={}", + project_context.instruction_files.len() + )]; + if project_context.instruction_files.is_empty() { + lines.push( + " No CLAUDE instruction files discovered in the current directory ancestry." + .to_string(), + ); + } else { + for file in project_context.instruction_files { + let preview = file.content.lines().next().unwrap_or("").trim(); + let preview = if preview.is_empty() { + "" + } else { + preview + }; + lines.push(format!( + " {} ({}) {}", + file.path.display(), + file.content.lines().count(), + preview + )); + } + } + Ok(lines.join( + " +", + )) +} + fn normalize_permission_mode(mode: &str) -> Option<&'static str> { match mode.trim() { "read-only" => Some("read-only"), @@ -930,6 +1028,7 @@ mod tests { assert!(help.contains("/cost")); assert!(help.contains("/resume ")); assert!(help.contains("/config")); + assert!(help.contains("/memory")); assert!(help.contains("/exit")); } @@ -984,6 +1083,7 @@ mod tests { }) ); assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config)); + assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); } #[test] From 188c35f8a669a9a1db2426e2b76a1055e0be66ba Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 19:57:38 +0000 Subject: [PATCH 05/11] feat(cli): add safe claude-md init command Add a genuinely useful /init command that creates a starter CLAUDE.md from the current repository shape without inventing unsupported setup flows. The scaffold pulls in real verification commands and repo-structure notes for this workspace, and it refuses to overwrite an existing CLAUDE.md. This keeps the command honest and low-risk while moving the CLI closer to Claude Code's practical bootstrap surface. Constraint: /init must be non-destructive and must not overwrite an existing CLAUDE.md Constraint: Generated guidance must come from observable repo structure rather than placeholder text Rejected: Interactive multi-step init workflow | too much unsupported UI/state machinery for this Rust CLI slice Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep generated CLAUDE.md templates concise and repo-derived; do not let /init drift into fake setup promises Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace Not-tested: manual /init invocation in a separate temporary repository without a preexisting CLAUDE.md --- rust/crates/commands/src/lib.rs | 12 +++- rust/crates/rusty-claude-cli/src/main.rs | 91 +++++++++++++++++++++++- 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 2d3c264..8a2e2eb 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -88,6 +88,11 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ summary: "Inspect loaded Claude instruction memory files", argument_hint: None, }, + SlashCommandSpec { + name: "init", + summary: "Create a starter CLAUDE.md for this repo", + argument_hint: None, + }, ]; #[derive(Debug, Clone, PartialEq, Eq)] @@ -102,6 +107,7 @@ pub enum SlashCommand { Resume { session_path: Option }, Config, Memory, + Init, Unknown(String), } @@ -132,6 +138,7 @@ impl SlashCommand { }, "config" => Self::Config, "memory" => Self::Memory, + "init" => Self::Init, other => Self::Unknown(other.to_string()), }) } @@ -195,6 +202,7 @@ pub fn handle_slash_command( | SlashCommand::Resume { .. } | SlashCommand::Config | SlashCommand::Memory + | SlashCommand::Init | SlashCommand::Unknown(_) => None, } } @@ -236,6 +244,7 @@ mod tests { ); 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] @@ -251,7 +260,8 @@ mod tests { assert!(help.contains("/resume ")); assert!(help.contains("/config")); assert!(help.contains("/memory")); - assert_eq!(slash_command_specs().len(), 10); + assert!(help.contains("/init")); + assert_eq!(slash_command_specs().len(), 11); } #[test] diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 3ba1a32..3d71743 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -2,6 +2,7 @@ mod input; mod render; use std::env; +use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; @@ -282,6 +283,7 @@ fn run_resume_command( | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } | SlashCommand::Clear + | SlashCommand::Init | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), } } @@ -377,6 +379,7 @@ impl LiveCli { SlashCommand::Resume { session_path } => self.resume_session(session_path)?, SlashCommand::Config => Self::print_config()?, SlashCommand::Memory => Self::print_memory()?, + SlashCommand::Init => Self::run_init()?, SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"), } Ok(()) @@ -502,6 +505,11 @@ impl LiveCli { Ok(()) } + fn run_init() -> Result<(), Box> { + println!("{}", init_claude_md()?); + Ok(()) + } + fn compact(&mut self) -> Result<(), Box> { let result = self.runtime.compact(CompactionConfig::default()); let removed = result.removed_message_count; @@ -614,6 +622,74 @@ fn render_memory_report() -> Result> { )) } +fn init_claude_md() -> Result> { + let cwd = env::current_dir()?; + let claude_md = cwd.join("CLAUDE.md"); + if claude_md.exists() { + return Ok(format!( + "init: skipped because {} already exists", + claude_md.display() + )); + } + + let content = render_init_claude_md(&cwd); + fs::write(&claude_md, content)?; + Ok(format!("init: created {}", claude_md.display())) +} + +fn render_init_claude_md(cwd: &Path) -> String { + let mut lines = vec![ + "# CLAUDE.md".to_string(), + String::new(), + "This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.".to_string(), + String::new(), + ]; + + let mut command_lines = Vec::new(); + if cwd.join("rust").join("Cargo.toml").is_file() { + command_lines.push("- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string()); + } else if cwd.join("Cargo.toml").is_file() { + command_lines.push("- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string()); + } + if cwd.join("tests").is_dir() && cwd.join("src").is_dir() { + command_lines.push("- `src/` and `tests/` are also present; check those surfaces before removing or renaming Python-era compatibility assets.".to_string()); + } + if !command_lines.is_empty() { + lines.push("## Verification".to_string()); + lines.extend(command_lines); + lines.push(String::new()); + } + + let mut structure_lines = Vec::new(); + if cwd.join("rust").is_dir() { + structure_lines.push( + "- `rust/` contains the Rust workspace and the active CLI/runtime implementation." + .to_string(), + ); + } + if cwd.join("src").is_dir() { + structure_lines.push("- `src/` contains the older Python-first workspace artifacts referenced by the repo history and tests.".to_string()); + } + if cwd.join("tests").is_dir() { + structure_lines.push("- `tests/` exercises compatibility and porting behavior across the repository surfaces.".to_string()); + } + if !structure_lines.is_empty() { + lines.push("## Repository shape".to_string()); + lines.extend(structure_lines); + lines.push(String::new()); + } + + lines.push("## Working agreement".to_string()); + lines.push("- Prefer small, reviewable Rust changes and keep slash-command behavior aligned between the shared command registry and the CLI entrypoints.".to_string()); + lines.push("- Do not overwrite existing CLAUDE.md content automatically; update it intentionally when repo workflows change.".to_string()); + lines.push(String::new()); + + lines.join( + " +", + ) +} + fn normalize_permission_mode(mode: &str) -> Option<&'static str> { match mode.trim() { "read-only" => Some("read-only"), @@ -951,11 +1027,11 @@ fn print_help() { #[cfg(test)] mod tests { use super::{ - format_status_line, normalize_permission_mode, parse_args, render_repl_help, CliAction, - SlashCommand, DEFAULT_MODEL, + format_status_line, normalize_permission_mode, parse_args, render_init_claude_md, + render_repl_help, CliAction, SlashCommand, DEFAULT_MODEL, }; use runtime::{ContentBlock, ConversationMessage, MessageRole}; - use std::path::PathBuf; + use std::path::{Path, PathBuf}; #[test] fn defaults_to_repl_when_no_args() { @@ -1029,6 +1105,7 @@ mod tests { assert!(help.contains("/resume ")); assert!(help.contains("/config")); assert!(help.contains("/memory")); + assert!(help.contains("/init")); assert!(help.contains("/exit")); } @@ -1084,6 +1161,14 @@ mod tests { ); 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 init_template_mentions_detected_rust_workspace() { + let rendered = render_init_claude_md(Path::new(".")); + assert!(rendered.contains("# CLAUDE.md")); + assert!(rendered.contains("cargo clippy --workspace --all-targets -- -D warnings")); } #[test] From c996eb7b1bfb2a84941eefce5c3661825213179e Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 20:00:13 +0000 Subject: [PATCH 06/11] Improve resumed CLI workflows beyond one-shot inspection Extend --resume so operators can run multiple safe slash commands in sequence against a saved session file, including mutating maintenance actions like /compact and /clear plus useful local /init scaffolding. This brings resumed sessions closer to the live REPL command surface without pretending unsupported runtime-bound commands work offline. Constraint: Resumed sessions only have serialized session state, not a live model client or interactive runtime Rejected: Support every slash command under --resume | model and permission changes do not affect offline saved-session inspection meaningfully Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep --resume limited to commands that can operate purely from session files or local filesystem context Tested: cargo fmt --manifest-path ./rust/Cargo.toml --all; cargo clippy --manifest-path ./rust/Cargo.toml --workspace --all-targets -- -D warnings; cargo test --manifest-path ./rust/Cargo.toml --workspace Not-tested: Manual interactive smoke test of chained --resume commands in a shell session --- rust/crates/rusty-claude-cli/src/main.rs | 181 ++++++++++++++++------- 1 file changed, 127 insertions(+), 54 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 3d71743..7a3c0e3 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -42,8 +42,8 @@ fn run() -> Result<(), Box> { CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date), CliAction::ResumeSession { session_path, - command, - } => resume_session(&session_path, command), + commands, + } => resume_session(&session_path, &commands), CliAction::Prompt { prompt, model } => LiveCli::new(model, false)?.run_turn(&prompt)?, CliAction::Repl { model } => run_repl(model)?, CliAction::Help => print_help(), @@ -61,7 +61,7 @@ enum CliAction { }, ResumeSession { session_path: PathBuf, - command: Option, + commands: Vec, }, Prompt { prompt: String, @@ -156,13 +156,16 @@ fn parse_resume_args(args: &[String]) -> Result { .first() .ok_or_else(|| "missing session path for --resume".to_string()) .map(PathBuf::from)?; - let command = args.get(1).cloned(); - if args.len() > 2 { - return Err("--resume accepts at most one trailing slash command".to_string()); + let commands = args[1..].to_vec(); + if commands + .iter() + .any(|command| !command.trim_start().starts_with('/')) + { + return Err("--resume trailing arguments must be slash commands".to_string()); } Ok(CliAction::ResumeSession { session_path, - command, + commands, }) } @@ -198,7 +201,7 @@ fn print_system_prompt(cwd: PathBuf, date: String) { } } -fn resume_session(session_path: &Path, command: Option) { +fn resume_session(session_path: &Path, commands: &[String]) { let session = match Session::load_from_path(session_path) { Ok(session) => session, Err(error) => { @@ -207,39 +210,55 @@ fn resume_session(session_path: &Path, command: Option) { } }; - match command.as_deref().and_then(SlashCommand::parse) { - Some(command) => match run_resume_command(session_path, &session, &command) { - Ok(Some(message)) => println!("{message}"), - Ok(None) => {} + if commands.is_empty() { + println!( + "Restored session from {} ({} messages).", + session_path.display(), + session.messages.len() + ); + return; + } + + let mut session = session; + for raw_command in commands { + let Some(command) = SlashCommand::parse(raw_command) else { + eprintln!("unsupported resumed command: {raw_command}"); + std::process::exit(2); + }; + match run_resume_command(session_path, &session, &command) { + Ok(ResumeCommandOutcome { + session: next_session, + message, + }) => { + session = next_session; + if let Some(message) = message { + println!("{message}"); + } + } Err(error) => { eprintln!("{error}"); std::process::exit(2); } - }, - None if command.is_some() => { - eprintln!( - "unsupported resumed command: {}", - command.unwrap_or_default() - ); - std::process::exit(2); - } - None => { - println!( - "Restored session from {} ({} messages).", - session_path.display(), - session.messages.len() - ); } } } +#[derive(Debug, Clone)] +struct ResumeCommandOutcome { + session: Session, + message: Option, +} + fn run_resume_command( session_path: &Path, session: &Session, command: &SlashCommand, -) -> Result, Box> { +) -> Result> { match command { - SlashCommand::Help => Ok(Some(render_repl_help())), + SlashCommand::Help => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_repl_help()), + }), SlashCommand::Compact => { let Some(result) = handle_slash_command( "/compact", @@ -249,41 +268,73 @@ fn run_resume_command( ..CompactionConfig::default() }, ) else { - return Ok(None); + return Ok(ResumeCommandOutcome { + session: session.clone(), + message: None, + }); }; result.session.save_to_path(session_path)?; - Ok(Some(result.message)) + Ok(ResumeCommandOutcome { + session: result.session, + message: Some(result.message), + }) + } + SlashCommand::Clear => { + let cleared = Session::new(); + cleared.save_to_path(session_path)?; + Ok(ResumeCommandOutcome { + session: cleared, + message: Some(format!( + "Cleared resumed session file {}.", + session_path.display() + )), + }) } SlashCommand::Status => { - let usage = UsageTracker::from_session(session).cumulative_usage(); - Ok(Some(format_status_line( - "restored-session", - session.messages.len(), - UsageTracker::from_session(session).turns(), - UsageTracker::from_session(session).current_turn_usage(), - usage, - 0, - permission_mode_label(), - ))) + let tracker = UsageTracker::from_session(session); + let usage = tracker.cumulative_usage(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format_status_line( + "restored-session", + session.messages.len(), + tracker.turns(), + tracker.current_turn_usage(), + usage, + 0, + permission_mode_label(), + )), + }) } SlashCommand::Cost => { let usage = UsageTracker::from_session(session).cumulative_usage(); - Ok(Some(format!( - "cost: input_tokens={} output_tokens={} cache_creation_tokens={} cache_read_tokens={} total_tokens={}", - usage.input_tokens, - usage.output_tokens, - usage.cache_creation_input_tokens, - usage.cache_read_input_tokens, - usage.total_tokens(), - ))) + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format!( + "cost: input_tokens={} output_tokens={} cache_creation_tokens={} cache_read_tokens={} total_tokens={}", + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_input_tokens, + usage.cache_read_input_tokens, + usage.total_tokens(), + )), + }) } - SlashCommand::Config => Ok(Some(render_config_report()?)), - SlashCommand::Memory => Ok(Some(render_memory_report()?)), + SlashCommand::Config => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_config_report()?), + }), + SlashCommand::Memory => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_memory_report()?), + }), + SlashCommand::Init => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(init_claude_md()?), + }), SlashCommand::Resume { .. } | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } - | SlashCommand::Clear - | SlashCommand::Init | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), } } @@ -1021,7 +1072,7 @@ fn print_help() { println!(" rusty-claude-cli dump-manifests"); println!(" rusty-claude-cli bootstrap-plan"); println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]"); - println!(" rusty-claude-cli --resume SESSION.json [/compact]"); + println!(" rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]"); } #[cfg(test)] @@ -1088,7 +1139,29 @@ mod tests { parse_args(&args).expect("args should parse"), CliAction::ResumeSession { session_path: PathBuf::from("session.json"), - command: Some("/compact".to_string()), + commands: vec!["/compact".to_string()], + } + ); + } + + #[test] + fn parses_resume_flag_with_multiple_slash_commands() { + let args = vec![ + "--resume".to_string(), + "session.json".to_string(), + "/status".to_string(), + "/compact".to_string(), + "/cost".to_string(), + ]; + assert_eq!( + parse_args(&args).expect("args should parse"), + CliAction::ResumeSession { + session_path: PathBuf::from("session.json"), + commands: vec![ + "/status".to_string(), + "/compact".to_string(), + "/cost".to_string(), + ], } ); } From a8f5da642764f0b55f02dfd44b7fd5796d3a8bc9 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 20:01:48 +0000 Subject: [PATCH 07/11] Make CLI command discovery closer to Claude Code Improve top-level help and shared slash-command help so the implemented surface is easier to discover, with explicit resume-safe markings and concrete examples for saved-session workflows. This keeps the command registry authoritative while making the CLI feel less skeletal and more like a real operator-facing tool. Constraint: Help text must reflect the actual implemented surface without advertising unsupported offline/runtime behavior Rejected: Separate bespoke help tables for REPL and --resume | would drift from the shared command registry Confidence: high Scope-risk: narrow Reversibility: clean Directive: Add new slash commands to the shared registry first so help and resume capability stay synchronized Tested: cargo fmt --manifest-path ./rust/Cargo.toml --all; cargo clippy --manifest-path ./rust/Cargo.toml --workspace --all-targets -- -D warnings; cargo test --manifest-path ./rust/Cargo.toml --workspace Not-tested: Manual UX comparison against upstream Claude Code help output --- rust/crates/commands/src/lib.rs | 37 +++++++++++++++++-- rust/crates/rusty-claude-cli/src/main.rs | 45 ++++++++++++++++++++---- 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 8a2e2eb..e090491 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -35,6 +35,7 @@ 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] = &[ @@ -42,56 +43,67 @@ const SLASH_COMMAND_SPECS: &[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: None, + 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(""), + 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, }, ]; @@ -149,15 +161,31 @@ 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()]; + 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), }; - lines.push(format!(" {name:<20} {}", spec.summary)); + let resume = if spec.resume_supported { + " [resume]" + } else { + "" + }; + lines.push(format!(" {name:<20} {}{}", spec.summary, resume)); } lines.join("\n") } @@ -210,7 +238,8 @@ pub fn handle_slash_command( #[cfg(test)] mod tests { use super::{ - handle_slash_command, render_slash_command_help, slash_command_specs, SlashCommand, + handle_slash_command, render_slash_command_help, resume_supported_slash_commands, + slash_command_specs, SlashCommand, }; use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session}; @@ -250,6 +279,7 @@ mod tests { #[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")); @@ -262,6 +292,7 @@ mod tests { assert!(help.contains("/memory")); assert!(help.contains("/init")); assert_eq!(slash_command_specs().len(), 11); + assert_eq!(resume_supported_slash_commands().len(), 8); } #[test] diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 7a3c0e3..95e1b14 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -12,7 +12,9 @@ use api::{ ToolResultContentBlock, }; -use commands::{handle_slash_command, render_slash_command_help, SlashCommand}; +use commands::{ + handle_slash_command, render_slash_command_help, resume_supported_slash_commands, SlashCommand, +}; use compat_harness::{extract_manifest, UpstreamPaths}; use render::{Spinner, TerminalRenderer}; use runtime::{ @@ -1065,21 +1067,38 @@ fn print_help() { println!("rusty-claude-cli"); println!(); println!("Usage:"); - println!(" rusty-claude-cli [--model MODEL] Start interactive REPL"); - println!( - " rusty-claude-cli [--model MODEL] prompt TEXT Send one prompt and stream the response" - ); + println!(" rusty-claude-cli [--model MODEL]"); + println!(" Start interactive REPL"); + println!(" rusty-claude-cli [--model MODEL] prompt TEXT"); + println!(" Send one prompt and stream the response"); + println!(" rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]"); + println!(" Inspect or maintain a saved session without entering the REPL"); println!(" rusty-claude-cli dump-manifests"); println!(" rusty-claude-cli bootstrap-plan"); println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]"); - println!(" rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]"); + println!(); + println!("Interactive slash commands:"); + println!("{}", render_slash_command_help()); + println!(); + let resume_commands = resume_supported_slash_commands() + .into_iter() + .map(|spec| match spec.argument_hint { + Some(argument_hint) => format!("/{} {}", spec.name, argument_hint), + None => format!("/{}", spec.name), + }) + .collect::>() + .join(", "); + println!("Resume-safe commands: {resume_commands}"); + println!("Examples:"); + println!(" rusty-claude-cli --resume session.json /status /compact /cost"); + println!(" rusty-claude-cli --resume session.json /memory /config"); } #[cfg(test)] mod tests { use super::{ format_status_line, normalize_permission_mode, parse_args, render_init_claude_md, - render_repl_help, CliAction, SlashCommand, DEFAULT_MODEL, + render_repl_help, resume_supported_slash_commands, CliAction, SlashCommand, DEFAULT_MODEL, }; use runtime::{ContentBlock, ConversationMessage, MessageRole}; use std::path::{Path, PathBuf}; @@ -1182,6 +1201,18 @@ mod tests { assert!(help.contains("/exit")); } + #[test] + fn resume_supported_command_list_matches_expected_surface() { + let names = resume_supported_slash_commands() + .into_iter() + .map(|spec| spec.name) + .collect::>(); + assert_eq!( + names, + vec!["help", "status", "compact", "clear", "cost", "config", "memory", "init",] + ); + } + #[test] fn status_line_reports_model_and_token_totals() { let status = format_status_line( From 2ad2ec087ff611f46ae8d7bdbded96ef74b877df Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 20:22:59 +0000 Subject: [PATCH 08/11] Expose real workspace context in status output Expand /status so it reports the current working directory, whether the CLI is operating on a live REPL or resumed session file, how many Claude config files were loaded, and how many instruction memory files were discovered. This makes status feel more like an operator dashboard instead of a bare token counter while still only surfacing metadata we can inspect locally. Constraint: Status must only report context available from the current filesystem and session state Rejected: Include guessed project metadata or upstream-only fields | would make the status output look richer than the implementation actually is Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep status additive and local-truthful; avoid inventing context that is not directly discoverable Tested: cargo fmt --manifest-path ./rust/Cargo.toml --all; cargo clippy --manifest-path ./rust/Cargo.toml --workspace --all-targets -- -D warnings; cargo test --manifest-path ./rust/Cargo.toml --workspace Not-tested: Manual interactive comparison of REPL /status versus resumed-session /status --- rust/crates/rusty-claude-cli/src/main.rs | 165 +++++++++++++++++------ 1 file changed, 124 insertions(+), 41 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 95e1b14..e17ea19 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -251,6 +251,24 @@ struct ResumeCommandOutcome { message: Option, } +#[derive(Debug, Clone)] +struct StatusContext { + cwd: PathBuf, + session_path: Option, + loaded_config_files: usize, + discovered_config_files: usize, + memory_file_count: usize, +} + +#[derive(Debug, Clone, Copy)] +struct StatusUsage { + message_count: usize, + turns: u32, + latest: TokenUsage, + cumulative: TokenUsage, + estimated_tokens: usize, +} + fn run_resume_command( session_path: &Path, session: &Session, @@ -297,14 +315,17 @@ fn run_resume_command( let usage = tracker.cumulative_usage(); Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(format_status_line( + message: Some(format_status_report( "restored-session", - session.messages.len(), - tracker.turns(), - tracker.current_turn_usage(), - usage, - 0, + StatusUsage { + message_count: session.messages.len(), + turns: tracker.turns(), + latest: tracker.current_turn_usage(), + cumulative: usage, + estimated_tokens: 0, + }, permission_mode_label(), + &status_context(Some(session_path))?, )), }) } @@ -443,14 +464,17 @@ impl LiveCli { let latest = self.runtime.usage().current_turn_usage(); println!( "{}", - format_status_line( + format_status_report( &self.model, - self.runtime.session().messages.len(), - self.runtime.usage().turns(), - latest, - cumulative, - self.runtime.estimated_tokens(), + StatusUsage { + message_count: self.runtime.session().messages.len(), + turns: self.runtime.usage().turns(), + latest, + cumulative, + estimated_tokens: self.runtime.estimated_tokens(), + }, permission_mode_label(), + &status_context(None).expect("status context should load"), ) ); } @@ -586,21 +610,58 @@ fn render_repl_help() -> String { ) } -fn format_status_line( +fn status_context( + session_path: Option<&Path>, +) -> Result> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let discovered_config_files = loader.discover().len(); + let runtime_config = loader.load()?; + let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; + Ok(StatusContext { + cwd, + session_path: session_path.map(Path::to_path_buf), + loaded_config_files: runtime_config.loaded_entries().len(), + discovered_config_files, + memory_file_count: project_context.instruction_files.len(), + }) +} + +fn format_status_report( model: &str, - message_count: usize, - turns: u32, - latest: TokenUsage, - cumulative: TokenUsage, - estimated_tokens: usize, + usage: StatusUsage, permission_mode: &str, + context: &StatusContext, ) -> 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(), + let mut lines = vec![format!( + "status: model={model} permission_mode={permission_mode} messages={} turns={} estimated_tokens={} latest_tokens={} cumulative_input_tokens={} cumulative_output_tokens={} cumulative_total_tokens={}", + usage.message_count, + usage.turns, + usage.estimated_tokens, + usage.latest.total_tokens(), + usage.cumulative.input_tokens, + usage.cumulative.output_tokens, + usage.cumulative.total_tokens(), + )]; + lines.push(format!(" cwd {}", context.cwd.display())); + lines.push(format!( + " session {}", + context.session_path.as_ref().map_or_else( + || "live-repl".to_string(), + |path| path.display().to_string() + ) + )); + lines.push(format!( + " config loaded {}/{} files", + context.loaded_config_files, context.discovered_config_files + )); + lines.push(format!( + " memory {} instruction files", + context.memory_file_count + )); + lines.join( + " +", ) } @@ -1097,8 +1158,9 @@ fn print_help() { #[cfg(test)] mod tests { use super::{ - format_status_line, normalize_permission_mode, parse_args, render_init_claude_md, - render_repl_help, resume_supported_slash_commands, CliAction, SlashCommand, DEFAULT_MODEL, + format_status_report, normalize_permission_mode, parse_args, render_init_claude_md, + render_repl_help, resume_supported_slash_commands, status_context, CliAction, SlashCommand, + StatusUsage, DEFAULT_MODEL, }; use runtime::{ContentBlock, ConversationMessage, MessageRole}; use std::path::{Path, PathBuf}; @@ -1215,30 +1277,51 @@ mod tests { #[test] fn status_line_reports_model_and_token_totals() { - let status = format_status_line( + let status = format_status_report( "claude-sonnet", - 7, - 3, - runtime::TokenUsage { - input_tokens: 5, - output_tokens: 4, - cache_creation_input_tokens: 1, - cache_read_input_tokens: 0, + StatusUsage { + message_count: 7, + turns: 3, + latest: runtime::TokenUsage { + input_tokens: 5, + output_tokens: 4, + cache_creation_input_tokens: 1, + cache_read_input_tokens: 0, + }, + cumulative: runtime::TokenUsage { + input_tokens: 20, + output_tokens: 8, + cache_creation_input_tokens: 2, + cache_read_input_tokens: 1, + }, + estimated_tokens: 128, }, - runtime::TokenUsage { - input_tokens: 20, - output_tokens: 8, - cache_creation_input_tokens: 2, - cache_read_input_tokens: 1, - }, - 128, "workspace-write", + &super::StatusContext { + cwd: PathBuf::from("/tmp/project"), + session_path: Some(PathBuf::from("session.json")), + loaded_config_files: 2, + discovered_config_files: 3, + memory_file_count: 4, + }, ); 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")); + assert!(status.contains("cwd /tmp/project")); + assert!(status.contains("session session.json")); + assert!(status.contains("config loaded 2/3 files")); + assert!(status.contains("memory 4 instruction files")); + } + + #[test] + fn status_context_reads_real_workspace_metadata() { + let context = status_context(None).expect("status context should load"); + assert!(context.cwd.is_absolute()); + assert_eq!(context.discovered_config_files, 3); + assert!(context.loaded_config_files <= context.discovered_config_files); } #[test] From b510387045b58b3419f87b2df5540a8f84dd8b74 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 20:41:39 +0000 Subject: [PATCH 09/11] Polish status and config output for operator readability Reformat /status and /config into sectioned reports with stable labels so the CLI surfaces read more like a usable operator console and less like dense debug strings. This improves discoverability and parity feel without changing the underlying data model or inventing fake settings behavior. Constraint: Output polish must preserve the exact locally discoverable facts already exposed by the CLI Rejected: Add interactive /clear confirmation first | wording/layout polish was cleaner, lower-risk, and touched fewer control-flow paths Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep CLI reports sectioned and label-stable so future tests can assert on intent rather than fragile token ordering Tested: cargo fmt --manifest-path ./rust/Cargo.toml --all; cargo clippy --manifest-path ./rust/Cargo.toml --workspace --all-targets -- -D warnings; cargo test --manifest-path ./rust/Cargo.toml --workspace Not-tested: Manual terminal-width UX review for very long paths or merged JSON payloads --- rust/crates/rusty-claude-cli/src/main.rs | 113 ++++++++++++++--------- 1 file changed, 71 insertions(+), 42 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index e17ea19..e053fe0 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -633,34 +633,46 @@ fn format_status_report( permission_mode: &str, context: &StatusContext, ) -> String { - let mut lines = vec![format!( - "status: model={model} permission_mode={permission_mode} messages={} turns={} estimated_tokens={} latest_tokens={} cumulative_input_tokens={} cumulative_output_tokens={} cumulative_total_tokens={}", - usage.message_count, - usage.turns, - usage.estimated_tokens, - usage.latest.total_tokens(), - usage.cumulative.input_tokens, - usage.cumulative.output_tokens, - usage.cumulative.total_tokens(), - )]; - lines.push(format!(" cwd {}", context.cwd.display())); - lines.push(format!( - " session {}", - context.session_path.as_ref().map_or_else( - || "live-repl".to_string(), - |path| path.display().to_string() - ) - )); - lines.push(format!( - " config loaded {}/{} files", - context.loaded_config_files, context.discovered_config_files - )); - lines.push(format!( - " memory {} instruction files", - context.memory_file_count - )); - lines.join( + [ + format!( + "Status + Model {model} + Permission mode {permission_mode} + Messages {} + Turns {} + Estimated tokens {}", + usage.message_count, usage.turns, usage.estimated_tokens, + ), + format!( + "Usage + Latest total {} + Cumulative input {} + Cumulative output {} + Cumulative total {}", + usage.latest.total_tokens(), + usage.cumulative.input_tokens, + usage.cumulative.output_tokens, + usage.cumulative.total_tokens(), + ), + format!( + "Workspace + Cwd {} + Session {} + Config files loaded {}/{} + Memory files {}", + context.cwd.display(), + context.session_path.as_ref().map_or_else( + || "live-repl".to_string(), + |path| path.display().to_string() + ), + context.loaded_config_files, + context.discovered_config_files, + context.memory_file_count, + ), + ] + .join( " + ", ) } @@ -671,11 +683,18 @@ fn render_config_report() -> Result> { let discovered = loader.discover(); let runtime_config = loader.load()?; - let mut lines = vec![format!( - "config: loaded_files={} merged_keys={}", - runtime_config.loaded_entries().len(), - runtime_config.merged().len() - )]; + let mut lines = vec![ + format!( + "Config + Working directory {} + Loaded files {} + Merged keys {}", + cwd.display(), + runtime_config.loaded_entries().len(), + runtime_config.merged().len() + ), + "Discovered files".to_string(), + ]; for entry in discovered { let source = match entry.source { ConfigSource::User => "user", @@ -696,7 +715,8 @@ fn render_config_report() -> Result> { entry.path.display() )); } - lines.push(format!(" merged {}", runtime_config.as_json().render())); + lines.push("Merged JSON".to_string()); + lines.push(format!(" {}", runtime_config.as_json().render())); Ok(lines.join( " ", @@ -1305,15 +1325,24 @@ mod tests { memory_file_count: 4, }, ); - 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")); - assert!(status.contains("cwd /tmp/project")); - assert!(status.contains("session session.json")); - assert!(status.contains("config loaded 2/3 files")); - assert!(status.contains("memory 4 instruction files")); + assert!(status.contains("Status")); + assert!(status.contains("Model claude-sonnet")); + assert!(status.contains("Permission mode workspace-write")); + assert!(status.contains("Messages 7")); + assert!(status.contains("Latest total 10")); + assert!(status.contains("Cumulative total 31")); + assert!(status.contains("Cwd /tmp/project")); + assert!(status.contains("Session session.json")); + assert!(status.contains("Config files loaded 2/3")); + assert!(status.contains("Memory files 4")); + } + + #[test] + fn config_report_uses_sectioned_layout() { + let report = super::render_config_report().expect("config report should render"); + assert!(report.contains("Config")); + assert!(report.contains("Discovered files")); + assert!(report.contains("Merged JSON")); } #[test] From 0ac188caad369983365453b0c279303d56400aea Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 20:42:50 +0000 Subject: [PATCH 10/11] Prevent accidental session clears in REPL and resume flows Require an explicit /clear --confirm flag before wiping live or resumed session state. This keeps the command genuinely useful while adding the minimal safety check needed for a destructive command in a chatty terminal workflow. Constraint: /clear must remain a real functional command without introducing interactive prompt machinery that would complicate REPL input handling Rejected: Add y/n interactive confirmation prompt | extra stateful prompting would be slower to ship and more fragile inside the line editor loop Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep destructive slash commands opt-in via explicit flags unless the CLI gains a dedicated confirmation subsystem Tested: cargo fmt --manifest-path ./rust/Cargo.toml --all; cargo clippy --manifest-path ./rust/Cargo.toml --workspace --all-targets -- -D warnings; cargo test --manifest-path ./rust/Cargo.toml --workspace Not-tested: Manual keyboard-driven UX pass for accidental /clear entry in interactive REPL --- rust/crates/commands/src/lib.rs | 25 +++++++++++---- rust/crates/rusty-claude-cli/src/main.rs | 39 +++++++++++++++++++++--- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index e090491..b3609bf 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -72,7 +72,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ SlashCommandSpec { name: "clear", summary: "Start a fresh local session", - argument_hint: None, + argument_hint: Some("[--confirm]"), resume_supported: true, }, SlashCommandSpec { @@ -114,7 +114,7 @@ pub enum SlashCommand { Compact, Model { model: Option }, Permissions { mode: Option }, - Clear, + Clear { confirm: bool }, Cost, Resume { session_path: Option }, Config, @@ -143,7 +143,9 @@ impl SlashCommand { "permissions" => Self::Permissions { mode: parts.next().map(ToOwned::to_owned), }, - "clear" => Self::Clear, + "clear" => Self::Clear { + confirm: parts.next() == Some("--confirm"), + }, "cost" => Self::Cost, "resume" => Self::Resume { session_path: parts.next().map(ToOwned::to_owned), @@ -225,7 +227,7 @@ pub fn handle_slash_command( SlashCommand::Status | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } - | SlashCommand::Clear + | SlashCommand::Clear { .. } | SlashCommand::Cost | SlashCommand::Resume { .. } | SlashCommand::Config @@ -263,7 +265,14 @@ mod tests { mode: Some("read-only".to_string()), }) ); - assert_eq!(SlashCommand::parse("/clear"), Some(SlashCommand::Clear)); + 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"), @@ -285,7 +294,7 @@ mod tests { assert!(help.contains("/compact")); assert!(help.contains("/model [model]")); assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); - assert!(help.contains("/clear")); + assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/cost")); assert!(help.contains("/resume ")); assert!(help.contains("/config")); @@ -349,6 +358,10 @@ mod tests { ) .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", diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index e053fe0..e06095c 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -299,7 +299,15 @@ fn run_resume_command( message: Some(result.message), }) } - SlashCommand::Clear => { + SlashCommand::Clear { confirm } => { + if !confirm { + return Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some( + "clear: confirmation required; rerun with /clear --confirm".to_string(), + ), + }); + } let cleared = Session::new(); cleared.save_to_path(session_path)?; Ok(ResumeCommandOutcome { @@ -448,7 +456,7 @@ impl LiveCli { SlashCommand::Compact => self.compact()?, SlashCommand::Model { model } => self.set_model(model)?, SlashCommand::Permissions { mode } => self.set_permissions(mode)?, - SlashCommand::Clear => self.clear_session()?, + SlashCommand::Clear { confirm } => self.clear_session(confirm)?, SlashCommand::Cost => self.print_cost(), SlashCommand::Resume { session_path } => self.resume_session(session_path)?, SlashCommand::Config => Self::print_config()?, @@ -526,7 +534,14 @@ impl LiveCli { Ok(()) } - fn clear_session(&mut self) -> Result<(), Box> { + fn clear_session(&mut self, confirm: bool) -> Result<(), Box> { + if !confirm { + println!( + "clear: confirmation required; run /clear --confirm to start a fresh session." + ); + return Ok(()); + } + self.runtime = build_runtime_with_permission_mode( Session::new(), self.model.clone(), @@ -1274,7 +1289,7 @@ mod tests { assert!(help.contains("/status")); assert!(help.contains("/model [model]")); assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); - assert!(help.contains("/clear")); + assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/cost")); assert!(help.contains("/resume ")); assert!(help.contains("/config")); @@ -1367,6 +1382,18 @@ mod tests { assert_eq!(normalize_permission_mode("unknown"), None); } + #[test] + fn clear_command_requires_explicit_confirmation_flag() { + assert_eq!( + SlashCommand::parse("/clear"), + Some(SlashCommand::Clear { confirm: false }) + ); + assert_eq!( + SlashCommand::parse("/clear --confirm"), + Some(SlashCommand::Clear { confirm: true }) + ); + } + #[test] fn parses_resume_and_config_slash_commands() { assert_eq!( @@ -1375,6 +1402,10 @@ mod tests { session_path: Some("saved-session.json".to_string()) }) ); + assert_eq!( + SlashCommand::parse("/clear --confirm"), + Some(SlashCommand::Clear { confirm: true }) + ); assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config)); assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init)); From 3db3dfa60de71af589399e4b9ef2b20db74fded4 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 20:43:56 +0000 Subject: [PATCH 11/11] Make model inspection and switching feel more like a real CLI surface Replace terse /model strings with sectioned model reports that show the active model and preserved session context, and use a structured switch report when the model changes. This keeps the behavior honest while making model management feel more intentional and Claude-like. Constraint: Model switching must preserve the current session and avoid adding any fake model catalog or validation layer Rejected: Add a hardcoded model list or aliases | would create drift with actual backend-supported model names Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep /model output informational and backend-agnostic unless the runtime gains authoritative model discovery Tested: cargo fmt --manifest-path ./rust/Cargo.toml --all; cargo clippy --manifest-path ./rust/Cargo.toml --workspace --all-targets -- -D warnings; cargo test --manifest-path ./rust/Cargo.toml --workspace Not-tested: Manual interactive switching across multiple real Anthropic model names --- rust/crates/rusty-claude-cli/src/main.rs | 72 ++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index e06095c..faa9639 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -269,6 +269,28 @@ struct StatusUsage { estimated_tokens: usize, } +fn format_model_report(model: &str, message_count: usize, turns: u32) -> String { + format!( + "Model + Current model {model} + Session messages {message_count} + Session turns {turns} + +Usage + Inspect current model with /model + Switch models with /model " + ) +} + +fn format_model_switch_report(previous: &str, next: &str, message_count: usize) -> String { + format!( + "Model updated + Previous {previous} + Current {next} + Preserved msgs {message_count}" + ) +} + fn run_resume_command( session_path: &Path, session: &Session, @@ -489,19 +511,38 @@ impl LiveCli { fn set_model(&mut self, model: Option) -> Result<(), Box> { let Some(model) = model else { - println!("Current model: {}", self.model); + println!( + "{}", + format_model_report( + &self.model, + self.runtime.session().messages.len(), + self.runtime.usage().turns(), + ) + ); return Ok(()); }; if model == self.model { - println!("Model already set to {model}."); + println!( + "{}", + format_model_report( + &self.model, + self.runtime.session().messages.len(), + self.runtime.usage().turns(), + ) + ); return Ok(()); } + let previous = self.model.clone(); let session = self.runtime.session().clone(); + let message_count = session.messages.len(); self.runtime = build_runtime(session, model.clone(), self.system_prompt.clone(), true)?; self.model.clone_from(&model); - println!("Switched model to {model}."); + println!( + "{}", + format_model_switch_report(&previous, &model, message_count) + ); Ok(()) } @@ -1193,9 +1234,10 @@ fn print_help() { #[cfg(test)] mod tests { use super::{ - format_status_report, normalize_permission_mode, parse_args, render_init_claude_md, - render_repl_help, resume_supported_slash_commands, status_context, CliAction, SlashCommand, - StatusUsage, DEFAULT_MODEL, + format_model_report, format_model_switch_report, format_status_report, + normalize_permission_mode, parse_args, render_init_claude_md, render_repl_help, + resume_supported_slash_commands, status_context, CliAction, SlashCommand, StatusUsage, + DEFAULT_MODEL, }; use runtime::{ContentBlock, ConversationMessage, MessageRole}; use std::path::{Path, PathBuf}; @@ -1310,6 +1352,24 @@ mod tests { ); } + #[test] + fn model_report_uses_sectioned_layout() { + let report = format_model_report("claude-sonnet", 12, 4); + assert!(report.contains("Model")); + assert!(report.contains("Current model claude-sonnet")); + assert!(report.contains("Session messages 12")); + assert!(report.contains("Switch models with /model ")); + } + + #[test] + fn model_switch_report_preserves_context_summary() { + let report = format_model_switch_report("claude-sonnet", "claude-opus", 9); + assert!(report.contains("Model updated")); + assert!(report.contains("Previous claude-sonnet")); + assert!(report.contains("Current claude-opus")); + assert!(report.contains("Preserved msgs 9")); + } + #[test] fn status_line_reports_model_and_token_totals() { let status = format_status_report(