diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 4f8362a..c0edd35 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -425,19 +425,65 @@ fn parse_resume_args(args: &[String]) -> Result { .first() .ok_or_else(|| "missing session path for --resume".to_string()) .map(PathBuf::from)?; - 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()); + let mut commands = Vec::new(); + let mut current_command = String::new(); + + for token in &args[1..] { + if token.trim_start().starts_with('/') { + if resume_command_can_absorb_token(¤t_command, token) { + current_command.push(' '); + current_command.push_str(token); + continue; + } + if !current_command.is_empty() { + commands.push(current_command); + } + current_command = token.clone(); + continue; + } + + if current_command.is_empty() { + return Err("--resume trailing arguments must be slash commands".to_string()); + } + + current_command.push(' '); + current_command.push_str(token); } + + if !current_command.is_empty() { + commands.push(current_command); + } + Ok(CliAction::ResumeSession { session_path, commands, }) } +fn resume_command_can_absorb_token(current_command: &str, token: &str) -> bool { + matches!( + SlashCommand::parse(current_command), + Some(SlashCommand::Export { path: None }) + ) && !looks_like_slash_command_token(token) +} + +fn looks_like_slash_command_token(token: &str) -> bool { + let trimmed = token.trim_start(); + let Some(name) = trimmed.strip_prefix('/').and_then(|value| { + value + .split_whitespace() + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) + }) else { + return false; + }; + + slash_command_specs() + .iter() + .any(|spec| spec.name == name || spec.aliases.contains(&name)) +} + fn dump_manifests() { let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); let paths = UpstreamPaths::from_workspace_dir(&workspace_dir); @@ -3286,7 +3332,6 @@ impl AnthropicRuntimeClient { progress_reporter, }) } - } fn resolve_cli_auth_source() -> Result> { @@ -4573,6 +4618,46 @@ mod tests { ); } + #[test] + fn parses_resume_flag_with_slash_command_arguments() { + let args = vec![ + "--resume".to_string(), + "session.jsonl".to_string(), + "/export".to_string(), + "notes.txt".to_string(), + "/clear".to_string(), + "--confirm".to_string(), + ]; + assert_eq!( + parse_args(&args).expect("args should parse"), + CliAction::ResumeSession { + session_path: PathBuf::from("session.jsonl"), + commands: vec![ + "/export notes.txt".to_string(), + "/clear --confirm".to_string() + ], + } + ); + } + + #[test] + fn parses_resume_flag_with_absolute_export_path() { + let args = vec![ + "--resume".to_string(), + "session.jsonl".to_string(), + "/export".to_string(), + "/tmp/notes.txt".to_string(), + "/status".to_string(), + ]; + assert_eq!( + parse_args(&args).expect("args should parse"), + CliAction::ResumeSession { + session_path: PathBuf::from("session.jsonl"), + commands: vec!["/export /tmp/notes.txt".to_string(), "/status".to_string()], + } + ); + } + #[test] fn filtered_tool_specs_respect_allowlist() { let allowed = ["read_file", "grep_search"] diff --git a/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs b/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs new file mode 100644 index 0000000..6b77de3 --- /dev/null +++ b/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs @@ -0,0 +1,71 @@ +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use runtime::Session; + +static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[test] +fn resumed_binary_accepts_slash_commands_with_arguments() { + let temp_dir = unique_temp_dir("resume-slash-commands"); + fs::create_dir_all(&temp_dir).expect("temp dir should exist"); + + let session_path = temp_dir.join("session.jsonl"); + let export_path = temp_dir.join("notes.txt"); + + let mut session = Session::new(); + session + .push_user_text("ship the slash command harness") + .expect("session write should succeed"); + session + .save_to_path(&session_path) + .expect("session should persist"); + + let output = Command::new(env!("CARGO_BIN_EXE_claw")) + .current_dir(&temp_dir) + .args([ + "--resume", + session_path.to_str().expect("utf8 path"), + "/export", + export_path.to_str().expect("utf8 path"), + "/clear", + "--confirm", + ]) + .output() + .expect("claw should launch"); + + assert!( + output.status.success(), + "stdout:\n{}\n\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8"); + assert!(stdout.contains("Export")); + assert!(stdout.contains("wrote transcript")); + assert!(stdout.contains(export_path.to_str().expect("utf8 path"))); + assert!(stdout.contains("Cleared resumed session file")); + + let export = fs::read_to_string(&export_path).expect("export file should exist"); + assert!(export.contains("# Conversation Export")); + assert!(export.contains("ship the slash command harness")); + + let restored = Session::load_from_path(&session_path).expect("cleared session should load"); + assert!(restored.messages.is_empty()); +} + +fn unique_temp_dir(label: &str) -> PathBuf { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_millis(); + let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "claw-{label}-{}-{millis}-{counter}", + std::process::id() + )) +}