diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 3cd9d6d..b396bb0 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -105,6 +105,30 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ argument_hint: None, resume_supported: true, }, + SlashCommandSpec { + name: "diff", + summary: "Show git diff for current workspace changes", + argument_hint: None, + resume_supported: true, + }, + SlashCommandSpec { + name: "version", + summary: "Show CLI version and build information", + argument_hint: None, + resume_supported: true, + }, + SlashCommandSpec { + name: "export", + summary: "Export the current conversation to a file", + argument_hint: Some("[file]"), + resume_supported: true, + }, + SlashCommandSpec { + name: "session", + summary: "List or switch managed local sessions", + argument_hint: Some("[list|switch ]"), + resume_supported: false, + }, ]; #[derive(Debug, Clone, PartialEq, Eq)] @@ -112,14 +136,33 @@ pub enum SlashCommand { Help, Status, Compact, - Model { model: Option }, - Permissions { mode: Option }, - Clear { confirm: bool }, + Model { + model: Option, + }, + Permissions { + mode: Option, + }, + Clear { + confirm: bool, + }, Cost, - Resume { session_path: Option }, - Config { section: Option }, + Resume { + session_path: Option, + }, + Config { + section: Option, + }, Memory, Init, + Diff, + Version, + Export { + path: Option, + }, + Session { + action: Option, + target: Option, + }, Unknown(String), } @@ -155,6 +198,15 @@ impl SlashCommand { }, "memory" => Self::Memory, "init" => Self::Init, + "diff" => Self::Diff, + "version" => Self::Version, + "export" => Self::Export { + path: parts.next().map(ToOwned::to_owned), + }, + "session" => Self::Session { + action: parts.next().map(ToOwned::to_owned), + target: parts.next().map(ToOwned::to_owned), + }, other => Self::Unknown(other.to_string()), }) } @@ -235,6 +287,10 @@ pub fn handle_slash_command( | SlashCommand::Config { .. } | SlashCommand::Memory | SlashCommand::Init + | SlashCommand::Diff + | SlashCommand::Version + | SlashCommand::Export { .. } + | SlashCommand::Session { .. } | SlashCommand::Unknown(_) => None, } } @@ -294,6 +350,21 @@ mod tests { ); assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init)); + assert_eq!(SlashCommand::parse("/diff"), Some(SlashCommand::Diff)); + assert_eq!(SlashCommand::parse("/version"), Some(SlashCommand::Version)); + assert_eq!( + SlashCommand::parse("/export notes.txt"), + Some(SlashCommand::Export { + path: Some("notes.txt".to_string()) + }) + ); + assert_eq!( + SlashCommand::parse("/session switch abc123"), + Some(SlashCommand::Session { + action: Some("switch".to_string()), + target: Some("abc123".to_string()) + }) + ); } #[test] @@ -311,8 +382,12 @@ mod tests { assert!(help.contains("/config [env|hooks|model]")); assert!(help.contains("/memory")); assert!(help.contains("/init")); - assert_eq!(slash_command_specs().len(), 11); - assert_eq!(resume_supported_slash_commands().len(), 8); + assert!(help.contains("/diff")); + assert!(help.contains("/version")); + assert!(help.contains("/export [file]")); + assert!(help.contains("/session [list|switch ]")); + assert_eq!(slash_command_specs().len(), 15); + assert_eq!(resume_supported_slash_commands().len(), 11); } #[test] @@ -384,5 +459,14 @@ mod tests { assert!( handle_slash_command("/config env", &session, CompactionConfig::default()).is_none() ); + assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none()); + assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none()); + assert!( + handle_slash_command("/export note.txt", &session, CompactionConfig::default()) + .is_none() + ); + assert!( + handle_slash_command("/session list", &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 350291a..afbd550 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -5,6 +5,7 @@ use std::env; use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; use api::{ AnthropicClient, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, @@ -21,16 +22,23 @@ use runtime::{ PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, }; +use serde_json::json; use tools::{execute_tool, mvp_tool_specs}; const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514"; const DEFAULT_MAX_TOKENS: u32 = 32; const DEFAULT_DATE: &str = "2026-03-31"; const VERSION: &str = env!("CARGO_PKG_VERSION"); +const BUILD_TARGET: Option<&str> = option_env!("TARGET"); +const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); fn main() { if let Err(error) = run() { - eprintln!("{error}"); + eprintln!( + "error: {error} + +Run `rusty-claude-cli --help` for usage." + ); std::process::exit(1); } } @@ -45,10 +53,13 @@ fn run() -> Result<(), Box> { session_path, commands, } => resume_session(&session_path, &commands), - CliAction::Prompt { prompt, model } => LiveCli::new(model, false)?.run_turn(&prompt)?, + CliAction::Prompt { + prompt, + model, + output_format, + } => LiveCli::new(model, false)?.run_turn_with_output(&prompt, output_format)?, CliAction::Repl { model } => run_repl(model)?, CliAction::Help => print_help(), - CliAction::Version => print_version(), } Ok(()) } @@ -68,16 +79,36 @@ enum CliAction { Prompt { prompt: String, model: String, + output_format: CliOutputFormat, }, Repl { model: String, }, + // prompt-mode formatting is only supported for non-interactive runs Help, - Version, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CliOutputFormat { + Text, + Json, +} + +impl CliOutputFormat { + fn parse(value: &str) -> Result { + match value { + "text" => Ok(Self::Text), + "json" => Ok(Self::Json), + other => Err(format!( + "unsupported value for --output-format: {other} (expected text or json)" + )), + } + } } fn parse_args(args: &[String]) -> Result { let mut model = DEFAULT_MODEL.to_string(); + let mut output_format = CliOutputFormat::Text; let mut rest = Vec::new(); let mut index = 0; @@ -94,6 +125,17 @@ fn parse_args(args: &[String]) -> Result { model = flag[8..].to_string(); index += 1; } + "--output-format" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --output-format".to_string())?; + output_format = CliOutputFormat::parse(value)?; + index += 2; + } + flag if flag.starts_with("--output-format=") => { + output_format = CliOutputFormat::parse(&flag[16..])?; + index += 1; + } other => { rest.push(other.to_string()); index += 1; @@ -107,9 +149,6 @@ fn parse_args(args: &[String]) -> Result { if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) { return Ok(CliAction::Help); } - if matches!(rest.first().map(String::as_str), Some("--version" | "-V")) { - return Ok(CliAction::Version); - } if rest.first().map(String::as_str) == Some("--resume") { return parse_resume_args(&rest[1..]); } @@ -123,8 +162,17 @@ fn parse_args(args: &[String]) -> Result { if prompt.trim().is_empty() { return Err("prompt subcommand requires a prompt string".to_string()); } - Ok(CliAction::Prompt { prompt, model }) + Ok(CliAction::Prompt { + prompt, + model, + output_format, + }) } + other if !other.starts_with('/') => Ok(CliAction::Prompt { + prompt: rest.join(" "), + model, + output_format, + }), other => Err(format!("unknown subcommand: {other}")), } } @@ -447,6 +495,7 @@ fn find_git_root() -> Result> { Ok(PathBuf::from(path)) } +#[allow(clippy::too_many_lines)] fn run_resume_command( session_path: &Path, session: &Session, @@ -531,9 +580,30 @@ fn run_resume_command( session: session.clone(), message: Some(init_claude_md()?), }), + SlashCommand::Diff => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_diff_report()?), + }), + SlashCommand::Version => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_version_report()), + }), + SlashCommand::Export { path } => { + let export_path = resolve_export_path(path.as_deref(), session)?; + fs::write(&export_path, render_export_text(session))?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format!( + "Export\n Result wrote transcript\n File {}\n Messages {}", + export_path.display(), + session.messages.len(), + )), + }) + } SlashCommand::Resume { .. } | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } + | SlashCommand::Session { .. } | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), } } @@ -541,8 +611,7 @@ fn run_resume_command( fn run_repl(model: String) -> Result<(), Box> { let mut cli = LiveCli::new(model, true)?; let editor = input::LineEditor::new("› "); - println!("Rusty Claude CLI interactive mode"); - println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline."); + println!("{}", cli.startup_banner()); while let Some(input) = editor.read_line()? { let trimmed = input.trim(); @@ -562,26 +631,57 @@ fn run_repl(model: String) -> Result<(), Box> { Ok(()) } +#[derive(Debug, Clone)] +struct SessionHandle { + id: String, + path: PathBuf, +} + +#[derive(Debug, Clone)] +struct ManagedSessionSummary { + id: String, + path: PathBuf, + modified_epoch_secs: u64, + message_count: usize, +} + struct LiveCli { model: String, system_prompt: Vec, runtime: ConversationRuntime, + session: SessionHandle, } impl LiveCli { fn new(model: String, enable_tools: bool) -> Result> { let system_prompt = build_system_prompt()?; + let session = create_managed_session_handle()?; let runtime = build_runtime( Session::new(), model.clone(), system_prompt.clone(), enable_tools, )?; - Ok(Self { + let cli = Self { model, system_prompt, runtime, - }) + session, + }; + cli.persist_session()?; + Ok(cli) + } + + fn startup_banner(&self) -> String { + format!( + "Rusty Claude CLI\n Model {}\n Working directory {}\n Session {}\n\nType /help for commands. Shift+Enter or Ctrl+J inserts a newline.", + self.model, + env::current_dir().map_or_else( + |_| "".to_string(), + |path| path.display().to_string(), + ), + self.session.id, + ) } fn run_turn(&mut self, input: &str) -> Result<(), Box> { @@ -601,6 +701,7 @@ impl LiveCli { &mut stdout, )?; println!(); + self.persist_session()?; Ok(()) } Err(error) => { @@ -614,6 +715,60 @@ impl LiveCli { } } + fn run_turn_with_output( + &mut self, + input: &str, + output_format: CliOutputFormat, + ) -> Result<(), Box> { + match output_format { + CliOutputFormat::Text => self.run_turn(input), + CliOutputFormat::Json => self.run_prompt_json(input), + } + } + + fn run_prompt_json(&mut self, input: &str) -> Result<(), Box> { + let client = AnthropicClient::from_env()?; + let request = MessageRequest { + model: self.model.clone(), + max_tokens: DEFAULT_MAX_TOKENS, + messages: vec![InputMessage { + role: "user".to_string(), + content: vec![InputContentBlock::Text { + text: input.to_string(), + }], + }], + system: (!self.system_prompt.is_empty()).then(|| self.system_prompt.join("\n\n")), + tools: None, + tool_choice: None, + stream: false, + }; + let runtime = tokio::runtime::Runtime::new()?; + let response = runtime.block_on(client.send_message(&request))?; + let text = response + .content + .iter() + .filter_map(|block| match block { + OutputContentBlock::Text { text } => Some(text.as_str()), + OutputContentBlock::ToolUse { .. } => None, + }) + .collect::>() + .join(""); + println!( + "{}", + json!({ + "message": text, + "model": self.model, + "usage": { + "input_tokens": response.usage.input_tokens, + "output_tokens": response.usage.output_tokens, + "cache_creation_input_tokens": response.usage.cache_creation_input_tokens, + "cache_read_input_tokens": response.usage.cache_read_input_tokens, + } + }) + ); + Ok(()) + } + fn handle_repl_command( &mut self, command: SlashCommand, @@ -630,11 +785,22 @@ impl LiveCli { SlashCommand::Config { section } => Self::print_config(section.as_deref())?, SlashCommand::Memory => Self::print_memory()?, SlashCommand::Init => Self::run_init()?, + SlashCommand::Diff => Self::print_diff()?, + SlashCommand::Version => Self::print_version(), + SlashCommand::Export { path } => self.export_session(path.as_deref())?, + SlashCommand::Session { action, target } => { + self.handle_session_command(action.as_deref(), target.as_deref())?; + } SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"), } Ok(()) } + fn persist_session(&self) -> Result<(), Box> { + self.runtime.session().save_to_path(&self.session.path)?; + Ok(()) + } + fn print_status(&self) { let cumulative = self.runtime.usage().cumulative_usage(); let latest = self.runtime.usage().current_turn_usage(); @@ -650,7 +816,7 @@ impl LiveCli { estimated_tokens: self.runtime.estimated_tokens(), }, permission_mode_label(), - &status_context(None).expect("status context should load"), + &status_context(Some(&self.session.path)).expect("status context should load"), ) ); } @@ -685,6 +851,7 @@ impl LiveCli { let message_count = session.messages.len(); self.runtime = build_runtime(session, model.clone(), self.system_prompt.clone(), true)?; self.model.clone_from(&model); + self.persist_session()?; println!( "{}", format_model_switch_report(&previous, &model, message_count) @@ -700,7 +867,7 @@ impl LiveCli { let normalized = normalize_permission_mode(&mode).ok_or_else(|| { format!( - "Unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access." + "unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access." ) })?; @@ -718,6 +885,7 @@ impl LiveCli { true, normalized, )?; + self.persist_session()?; println!( "{}", format_permissions_switch_report(&previous, normalized) @@ -733,6 +901,7 @@ impl LiveCli { return Ok(()); } + self.session = create_managed_session_handle()?; self.runtime = build_runtime_with_permission_mode( Session::new(), self.model.clone(), @@ -740,13 +909,12 @@ impl LiveCli { true, permission_mode_label(), )?; + self.persist_session()?; println!( - "Session cleared - Mode fresh session - Preserved model {} - Permission mode {}", + "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}", self.model, - permission_mode_label() + permission_mode_label(), + self.session.id, ); Ok(()) } @@ -760,12 +928,13 @@ impl LiveCli { &mut self, session_path: Option, ) -> Result<(), Box> { - let Some(session_path) = session_path else { + let Some(session_ref) = session_path else { println!("Usage: /resume "); return Ok(()); }; - let session = Session::load_from_path(&session_path)?; + let handle = resolve_session_reference(&session_ref)?; + let session = Session::load_from_path(&handle.path)?; let message_count = session.messages.len(); self.runtime = build_runtime_with_permission_mode( session, @@ -774,9 +943,15 @@ impl LiveCli { true, permission_mode_label(), )?; + self.session = handle; + self.persist_session()?; println!( "{}", - format_resume_report(&session_path, message_count, self.runtime.usage().turns()) + format_resume_report( + &self.session.path.display().to_string(), + message_count, + self.runtime.usage().turns(), + ) ); Ok(()) } @@ -796,6 +971,71 @@ impl LiveCli { Ok(()) } + fn print_diff() -> Result<(), Box> { + println!("{}", render_diff_report()?); + Ok(()) + } + + fn print_version() { + println!("{}", render_version_report()); + } + + fn export_session( + &self, + requested_path: Option<&str>, + ) -> Result<(), Box> { + let export_path = resolve_export_path(requested_path, self.runtime.session())?; + fs::write(&export_path, render_export_text(self.runtime.session()))?; + println!( + "Export\n Result wrote transcript\n File {}\n Messages {}", + export_path.display(), + self.runtime.session().messages.len(), + ); + Ok(()) + } + + fn handle_session_command( + &mut self, + action: Option<&str>, + target: Option<&str>, + ) -> Result<(), Box> { + match action { + None | Some("list") => { + println!("{}", render_session_list(&self.session.id)?); + Ok(()) + } + Some("switch") => { + let Some(target) = target else { + println!("Usage: /session switch "); + return Ok(()); + }; + let handle = resolve_session_reference(target)?; + let session = Session::load_from_path(&handle.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(), + )?; + self.session = handle; + self.persist_session()?; + println!( + "Session switched\n Active session {}\n File {}\n Messages {}", + self.session.id, + self.session.path.display(), + message_count, + ); + Ok(()) + } + Some(other) => { + println!("Unknown /session action '{other}'. Use /session list or /session switch ."); + Ok(()) + } + } + } + fn compact(&mut self) -> Result<(), Box> { let result = self.runtime.compact(CompactionConfig::default()); let removed = result.removed_message_count; @@ -808,11 +1048,112 @@ impl LiveCli { true, permission_mode_label(), )?; + self.persist_session()?; println!("{}", format_compact_report(removed, kept, skipped)); Ok(()) } } +fn sessions_dir() -> Result> { + let cwd = env::current_dir()?; + let path = cwd.join(".claude").join("sessions"); + fs::create_dir_all(&path)?; + Ok(path) +} + +fn create_managed_session_handle() -> Result> { + let id = generate_session_id(); + let path = sessions_dir()?.join(format!("{id}.json")); + Ok(SessionHandle { id, path }) +} + +fn generate_session_id() -> String { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default(); + format!("session-{millis}") +} + +fn resolve_session_reference(reference: &str) -> Result> { + let direct = PathBuf::from(reference); + let path = if direct.exists() { + direct + } else { + sessions_dir()?.join(format!("{reference}.json")) + }; + if !path.exists() { + return Err(format!("session not found: {reference}").into()); + } + let id = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or(reference) + .to_string(); + Ok(SessionHandle { id, path }) +} + +fn list_managed_sessions() -> Result, Box> { + let mut sessions = Vec::new(); + for entry in fs::read_dir(sessions_dir()?)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + let metadata = entry.metadata()?; + let modified_epoch_secs = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()) + .unwrap_or_default(); + let message_count = Session::load_from_path(&path) + .map(|session| session.messages.len()) + .unwrap_or_default(); + let id = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("unknown") + .to_string(); + sessions.push(ManagedSessionSummary { + id, + path, + modified_epoch_secs, + message_count, + }); + } + sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs)); + Ok(sessions) +} + +fn render_session_list(active_session_id: &str) -> Result> { + let sessions = list_managed_sessions()?; + let mut lines = vec![ + "Sessions".to_string(), + format!(" Directory {}", sessions_dir()?.display()), + ]; + if sessions.is_empty() { + lines.push(" No managed sessions saved yet.".to_string()); + return Ok(lines.join("\n")); + } + for session in sessions { + let marker = if session.id == active_session_id { + "● current" + } else { + "○ saved" + }; + lines.push(format!( + " {id:<20} {marker:<10} msgs={msgs:<4} modified={modified} path={path}", + id = session.id, + msgs = session.message_count, + modified = session.modified_epoch_secs, + path = session.path.display(), + )); + } + Ok(lines.join("\n")) +} + fn render_repl_help() -> String { [ "REPL".to_string(), @@ -1102,6 +1443,120 @@ fn permission_mode_label() -> &'static str { } } +fn render_diff_report() -> Result> { + let output = std::process::Command::new("git") + .args(["diff", "--", ":(exclude).omx"]) + .current_dir(env::current_dir()?) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git diff failed: {stderr}").into()); + } + let diff = String::from_utf8(output.stdout)?; + if diff.trim().is_empty() { + return Ok( + "Diff\n Result clean working tree\n Detail no current changes" + .to_string(), + ); + } + Ok(format!("Diff\n\n{}", diff.trim_end())) +} + +fn render_version_report() -> String { + let git_sha = GIT_SHA.unwrap_or("unknown"); + let target = BUILD_TARGET.unwrap_or("unknown"); + format!( + "Version\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}" + ) +} + +fn render_export_text(session: &Session) -> String { + let mut lines = vec!["# Conversation Export".to_string(), String::new()]; + for (index, message) in session.messages.iter().enumerate() { + let role = match message.role { + MessageRole::System => "system", + MessageRole::User => "user", + MessageRole::Assistant => "assistant", + MessageRole::Tool => "tool", + }; + lines.push(format!("## {}. {role}", index + 1)); + for block in &message.blocks { + match block { + ContentBlock::Text { text } => lines.push(text.clone()), + ContentBlock::ToolUse { id, name, input } => { + lines.push(format!("[tool_use id={id} name={name}] {input}")); + } + ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } => { + lines.push(format!( + "[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}" + )); + } + } + } + lines.push(String::new()); + } + lines.join("\n") +} + +fn default_export_filename(session: &Session) -> String { + let stem = session + .messages + .iter() + .find_map(|message| match message.role { + MessageRole::User => message.blocks.iter().find_map(|block| match block { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }), + _ => None, + }) + .map_or("conversation", |text| { + text.lines().next().unwrap_or("conversation") + }) + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::() + .split('-') + .filter(|part| !part.is_empty()) + .take(8) + .collect::>() + .join("-"); + let fallback = if stem.is_empty() { + "conversation" + } else { + &stem + }; + format!("{fallback}.txt") +} + +fn resolve_export_path( + requested_path: Option<&str>, + session: &Session, +) -> Result> { + let cwd = env::current_dir()?; + let file_name = + requested_path.map_or_else(|| default_export_filename(session), ToOwned::to_owned); + let final_name = if Path::new(&file_name) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("txt")) + { + file_name + } else { + format!("{file_name}.txt") + }; + Ok(cwd.join(final_name)) +} + fn build_system_prompt() -> Result, Box> { Ok(load_system_prompt( env::current_dir()?, @@ -1406,91 +1861,28 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec { } fn print_help() { - let mut stdout = io::stdout(); - let _ = print_help_to(&mut stdout); -} - -fn print_help_to(out: &mut impl Write) -> io::Result<()> { - writeln!(out, "rusty-claude-cli")?; - writeln!(out, "Version: {VERSION}")?; - writeln!(out)?; - writeln!( - out, - "Rust-first Claude Code-style CLI for prompt, REPL, and saved-session workflows." - )?; - writeln!(out)?; - writeln!(out, "Usage:")?; - writeln!(out, " rusty-claude-cli [--model MODEL]")?; - writeln!(out, " Start interactive REPL")?; - writeln!(out, " rusty-claude-cli [--model MODEL] prompt TEXT")?; - writeln!(out, " Send one prompt and stream the response")?; - writeln!( - out, - " rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]" - )?; - writeln!( - out, - " Inspect or maintain a saved session without entering the REPL" - )?; - writeln!(out, " rusty-claude-cli dump-manifests")?; - writeln!( - out, - " Inspect extracted upstream command/tool metadata" - )?; - writeln!(out, " rusty-claude-cli bootstrap-plan")?; - writeln!(out, " Print the current bootstrap phase skeleton")?; - writeln!( - out, - " rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]" - )?; - writeln!( - out, - " Render the synthesized system prompt for debugging" - )?; - writeln!(out, " rusty-claude-cli --help")?; - writeln!(out, " rusty-claude-cli --version")?; - writeln!(out)?; - writeln!(out, "Options:")?; - writeln!( - out, - " --model MODEL Override the active Anthropic model" - )?; - writeln!( - out, - " --resume PATH Restore a saved session file and optionally run slash commands" - )?; - writeln!(out, " -h, --help Show this help page")?; - writeln!(out, " -V, --version Print the CLI version")?; - writeln!(out)?; - writeln!(out, "Environment:")?; - writeln!( - out, - " ANTHROPIC_AUTH_TOKEN Preferred bearer token for Anthropic API requests" - )?; - writeln!( - out, - " ANTHROPIC_API_KEY Legacy API key fallback if auth token is unset" - )?; - writeln!( - out, - " ANTHROPIC_BASE_URL Override the Anthropic API base URL" - )?; - writeln!( - out, - " ANTHROPIC_MODEL Default model for selected integration tests" - )?; - writeln!( - out, - " RUSTY_CLAUDE_PERMISSION_MODE Default permission mode for REPL sessions" - )?; - writeln!( - out, - " CLAUDE_CONFIG_HOME Override Claude config discovery root" - )?; - writeln!(out)?; - writeln!(out, "Interactive slash commands:")?; - writeln!(out, "{}", render_slash_command_help())?; - writeln!(out)?; + println!("rusty-claude-cli v{VERSION}"); + println!(); + println!("Usage:"); + println!(" rusty-claude-cli [--model MODEL]"); + println!(" Start the interactive REPL"); + println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] prompt TEXT"); + println!(" Send one prompt and exit"); + println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] TEXT"); + println!(" Shorthand non-interactive prompt mode"); + 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!(); + println!("Flags:"); + println!(" --model MODEL Override the active model"); + println!(" --output-format FORMAT Non-interactive output format: text or json"); + 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 { @@ -1499,26 +1891,11 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { }) .collect::>() .join(", "); - writeln!(out, "Resume-safe commands: {resume_commands}")?; - writeln!(out, "Examples:")?; - writeln!( - out, - " rusty-claude-cli prompt \"Summarize the repo architecture\"" - )?; - writeln!(out, " rusty-claude-cli --model claude-sonnet-4-20250514")?; - writeln!( - out, - " rusty-claude-cli --resume session.json /status /compact /cost" - )?; - writeln!( - out, - " rusty-claude-cli --resume session.json /memory /config" - )?; - Ok(()) -} - -fn print_version() { - println!("rusty-claude-cli {VERSION}"); + println!("Resume-safe commands: {resume_commands}"); + println!("Examples:"); + println!(" rusty-claude-cli --model claude-opus \"summarize this repo\""); + println!(" rusty-claude-cli --output-format json prompt \"explain src/main.rs\""); + println!(" rusty-claude-cli --resume session.json /status /diff /export notes.txt"); } #[cfg(test)] @@ -1529,7 +1906,7 @@ mod tests { format_resume_report, format_status_report, normalize_permission_mode, parse_args, parse_git_status_metadata, render_config_report, render_init_claude_md, render_memory_report, render_repl_help, resume_supported_slash_commands, status_context, - CliAction, SlashCommand, StatusUsage, DEFAULT_MODEL, + CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, }; use runtime::{ContentBlock, ConversationMessage, MessageRole}; use std::path::{Path, PathBuf}; @@ -1556,6 +1933,26 @@ mod tests { CliAction::Prompt { prompt: "hello world".to_string(), model: DEFAULT_MODEL.to_string(), + output_format: CliOutputFormat::Text, + } + ); + } + + #[test] + fn parses_bare_prompt_and_json_output_flag() { + let args = vec![ + "--output-format=json".to_string(), + "--model".to_string(), + "claude-opus".to_string(), + "explain".to_string(), + "this".to_string(), + ]; + assert_eq!( + parse_args(&args).expect("args should parse"), + CliAction::Prompt { + prompt: "explain this".to_string(), + model: "claude-opus".to_string(), + output_format: CliOutputFormat::Json, } ); } @@ -1616,18 +2013,6 @@ mod tests { ); } - #[test] - fn parses_version_flags() { - assert_eq!( - parse_args(&["--version".to_string()]).expect("args should parse"), - CliAction::Version - ); - assert_eq!( - parse_args(&["-V".to_string()]).expect("args should parse"), - CliAction::Version - ); - } - #[test] fn shared_help_uses_resume_annotation_copy() { let help = commands::render_slash_command_help(); @@ -1635,16 +2020,6 @@ mod tests { assert!(help.contains("works with --resume SESSION.json")); } - #[test] - fn cli_help_mentions_version_and_environment() { - let mut output = Vec::new(); - super::print_help_to(&mut output).expect("help should render"); - let help = String::from_utf8(output).expect("help should be utf8"); - assert!(help.contains("--version")); - assert!(help.contains("ANTHROPIC_AUTH_TOKEN")); - assert!(help.contains("RUSTY_CLAUDE_PERMISSION_MODE")); - } - #[test] fn repl_help_includes_shared_commands_and_exit() { let help = render_repl_help(); @@ -1659,8 +2034,11 @@ mod tests { assert!(help.contains("/config [env|hooks|model]")); assert!(help.contains("/memory")); assert!(help.contains("/init")); + assert!(help.contains("/diff")); + assert!(help.contains("/version")); + assert!(help.contains("/export [file]")); + assert!(help.contains("/session [list|switch ]")); assert!(help.contains("/exit")); - assert!(help.contains("slash commands")); } #[test] @@ -1671,7 +2049,10 @@ mod tests { .collect::>(); assert_eq!( names, - vec!["help", "status", "compact", "clear", "cost", "config", "memory", "init",] + vec![ + "help", "status", "compact", "clear", "cost", "config", "memory", "init", "diff", + "version", "export", + ] ); }