diff --git a/rust/crates/api/src/client.rs b/rust/crates/api/src/client.rs index f99bb8d..b224fec 100644 --- a/rust/crates/api/src/client.rs +++ b/rust/crates/api/src/client.rs @@ -311,18 +311,38 @@ mod tests { #[test] fn read_api_key_requires_presence() { + let previous_auth = std::env::var("ANTHROPIC_AUTH_TOKEN").ok(); + let previous_key = std::env::var("ANTHROPIC_API_KEY").ok(); std::env::remove_var("ANTHROPIC_AUTH_TOKEN"); std::env::remove_var("ANTHROPIC_API_KEY"); let error = super::read_api_key().expect_err("missing key should error"); assert!(matches!(error, crate::error::ApiError::MissingApiKey)); + match previous_auth { + Some(value) => std::env::set_var("ANTHROPIC_AUTH_TOKEN", value), + None => std::env::remove_var("ANTHROPIC_AUTH_TOKEN"), + } + match previous_key { + Some(value) => std::env::set_var("ANTHROPIC_API_KEY", value), + None => std::env::remove_var("ANTHROPIC_API_KEY"), + } } #[test] fn read_api_key_requires_non_empty_value() { + let previous_auth = std::env::var("ANTHROPIC_AUTH_TOKEN").ok(); + let previous_key = std::env::var("ANTHROPIC_API_KEY").ok(); std::env::set_var("ANTHROPIC_AUTH_TOKEN", ""); std::env::remove_var("ANTHROPIC_API_KEY"); let error = super::read_api_key().expect_err("empty key should error"); assert!(matches!(error, crate::error::ApiError::MissingApiKey)); + match previous_auth { + Some(value) => std::env::set_var("ANTHROPIC_AUTH_TOKEN", value), + None => std::env::remove_var("ANTHROPIC_AUTH_TOKEN"), + } + match previous_key { + Some(value) => std::env::set_var("ANTHROPIC_API_KEY", value), + None => std::env::remove_var("ANTHROPIC_API_KEY"), + } } #[test] diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index a9009d9..bf6a0b3 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -2,8 +2,10 @@ mod input; mod render; 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, @@ -24,6 +26,7 @@ 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 DEFAULT_SESSION_LIMIT: usize = 20; fn main() { if let Err(error) = run() { @@ -42,6 +45,8 @@ fn run() -> Result<(), Box> { session_path, command, } => resume_session(&session_path, command), + CliAction::ResumeNamed { target, command } => resume_named_session(&target, command), + CliAction::ListSessions { query, limit } => list_sessions(query.as_deref(), limit), CliAction::Prompt { prompt, model } => LiveCli::new(model, false)?.run_turn(&prompt)?, CliAction::Repl { model } => run_repl(model)?, CliAction::Help => print_help(), @@ -61,6 +66,14 @@ enum CliAction { session_path: PathBuf, command: Option, }, + ResumeNamed { + target: String, + command: Option, + }, + ListSessions { + query: Option, + limit: usize, + }, Prompt { prompt: String, model: String, @@ -109,6 +122,8 @@ fn parse_args(args: &[String]) -> Result { match rest[0].as_str() { "dump-manifests" => Ok(CliAction::DumpManifests), "bootstrap-plan" => Ok(CliAction::BootstrapPlan), + "resume" => parse_named_resume_args(&rest[1..]), + "sessions" => parse_sessions_args(&rest[1..]), "system-prompt" => parse_system_prompt_args(&rest[1..]), "prompt" => { let prompt = rest[1..].join(" "); @@ -149,6 +164,48 @@ fn parse_system_prompt_args(args: &[String]) -> Result { Ok(CliAction::PrintSystemPrompt { cwd, date }) } +fn parse_named_resume_args(args: &[String]) -> Result { + let target = args + .first() + .ok_or_else(|| "missing session id, path, or 'latest' for resume".to_string())? + .clone(); + let command = args.get(1).cloned(); + if args.len() > 2 { + return Err("resume accepts at most one trailing slash command".to_string()); + } + Ok(CliAction::ResumeNamed { target, command }) +} + +fn parse_sessions_args(args: &[String]) -> Result { + let mut query = None; + let mut limit = DEFAULT_SESSION_LIMIT; + let mut index = 0; + + while index < args.len() { + match args[index].as_str() { + "--query" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --query".to_string())?; + query = Some(value.clone()); + index += 2; + } + "--limit" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --limit".to_string())?; + limit = value + .parse::() + .map_err(|error| format!("invalid --limit value: {error}"))?; + index += 2; + } + other => return Err(format!("unknown sessions option: {other}")), + } + } + + Ok(CliAction::ListSessions { query, limit }) +} + fn parse_resume_args(args: &[String]) -> Result { let session_path = args .first() @@ -238,6 +295,43 @@ fn resume_session(session_path: &Path, command: Option) { } } +fn resume_named_session(target: &str, command: Option) { + let session_path = match resolve_session_target(target) { + Ok(path) => path, + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + }; + resume_session(&session_path, command); +} + +fn list_sessions(query: Option<&str>, limit: usize) { + match load_session_entries(query, limit) { + Ok(entries) => { + if entries.is_empty() { + println!("No saved sessions found."); + return; + } + println!("Saved sessions:"); + for entry in entries { + println!( + "- {} | updated={} | messages={} | tokens={} | {}", + entry.id, + entry.updated_unix, + entry.message_count, + entry.total_tokens, + entry.preview + ); + } + } + Err(error) => { + eprintln!("failed to list sessions: {error}"); + std::process::exit(1); + } + } +} + fn run_repl(model: String) -> Result<(), Box> { let mut cli = LiveCli::new(model, true)?; let editor = input::LineEditor::new("› "); @@ -271,11 +365,13 @@ struct LiveCli { model: String, system_prompt: Vec, runtime: ConversationRuntime, + session_path: PathBuf, } impl LiveCli { fn new(model: String, enable_tools: bool) -> Result> { let system_prompt = build_system_prompt()?; + let session_path = new_session_path()?; let runtime = build_runtime( Session::new(), model.clone(), @@ -286,6 +382,7 @@ impl LiveCli { model, system_prompt, runtime, + session_path, }) } @@ -306,6 +403,7 @@ impl LiveCli { &mut stdout, )?; println!(); + self.persist_session()?; self.print_turn_usage(turn.usage); Ok(()) } @@ -370,8 +468,150 @@ impl LiveCli { let estimated_saved = estimated_before.saturating_sub(estimated_after); println!("Estimated tokens saved: {estimated_saved}"); } + self.persist_session()?; Ok(()) } + + fn persist_session(&self) -> Result<(), Box> { + self.runtime.session().save_to_path(&self.session_path)?; + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SessionListEntry { + id: String, + path: PathBuf, + updated_unix: u64, + message_count: usize, + total_tokens: u32, + preview: String, +} + +fn new_session_path() -> io::Result { + let session_dir = default_session_dir()?; + fs::create_dir_all(&session_dir)?; + let timestamp = current_unix_timestamp(); + let process_id = std::process::id(); + Ok(session_dir.join(format!("session-{timestamp}-{process_id}.json"))) +} + +fn default_session_dir() -> io::Result { + Ok(env::current_dir()?.join(".rusty-claude").join("sessions")) +} + +fn current_unix_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()) +} + +fn resolve_session_target(target: &str) -> io::Result { + let direct_path = PathBuf::from(target); + if direct_path.is_file() { + return Ok(direct_path); + } + + let entries = load_session_entries(None, usize::MAX)?; + if target == "latest" { + return entries + .into_iter() + .next() + .map(|entry| entry.path) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no saved sessions found")); + } + + let mut matches = entries + .into_iter() + .filter(|entry| entry.id.contains(target) || entry.preview.contains(target)) + .collect::>(); + if matches.is_empty() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("no saved session matched '{target}'"), + )); + } + matches.sort_by(|left, right| right.updated_unix.cmp(&left.updated_unix)); + Ok(matches.remove(0).path) +} + +fn load_session_entries(query: Option<&str>, limit: usize) -> io::Result> { + let session_dir = default_session_dir()?; + if !session_dir.exists() { + return Ok(Vec::new()); + } + + let query = query.map(str::to_lowercase); + let mut entries = Vec::new(); + for entry in fs::read_dir(session_dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + + let Ok(session) = Session::load_from_path(&path) else { + continue; + }; + + let preview = session_preview(&session); + let id = path + .file_stem() + .map_or_else(String::new, |stem| stem.to_string_lossy().into_owned()); + let searchable = format!("{} {}", id.to_lowercase(), preview.to_lowercase()); + if let Some(query) = &query { + if !searchable.contains(query) { + continue; + } + } + + let updated_unix = entry + .metadata() + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) + .map_or(0, |duration| duration.as_secs()); + + entries.push(SessionListEntry { + id, + path, + updated_unix, + message_count: session.messages.len(), + total_tokens: runtime::UsageTracker::from_session(&session) + .cumulative_usage() + .total_tokens(), + preview, + }); + } + + entries.sort_by(|left, right| right.updated_unix.cmp(&left.updated_unix)); + if limit < entries.len() { + entries.truncate(limit); + } + Ok(entries) +} + +fn session_preview(session: &Session) -> String { + for message in session.messages.iter().rev() { + for block in &message.blocks { + if let ContentBlock::Text { text } = block { + let trimmed = text.trim(); + if !trimmed.is_empty() { + return truncate_preview(trimmed, 80); + } + } + } + } + "No text preview available".to_string() +} + +fn truncate_preview(text: &str, max_chars: usize) -> String { + if text.chars().count() <= max_chars { + return text.to_string(); + } + let mut output = text.chars().take(max_chars).collect::(); + output.push('…'); + output } fn build_system_prompt() -> Result, Box> { @@ -671,15 +911,19 @@ fn print_help() { ); println!(" rusty-claude-cli dump-manifests"); println!(" rusty-claude-cli bootstrap-plan"); + println!(" rusty-claude-cli sessions [--query TEXT] [--limit N]"); + println!(" rusty-claude-cli resume [/compact]"); println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]"); println!(" rusty-claude-cli --resume SESSION.json [/compact]"); } #[cfg(test)] mod tests { - use super::{parse_args, CliAction, DEFAULT_MODEL}; - use runtime::{ContentBlock, ConversationMessage, MessageRole}; + use super::{parse_args, resolve_session_target, session_preview, CliAction, DEFAULT_MODEL}; + use runtime::{ContentBlock, ConversationMessage, MessageRole, Session}; + use std::fs; use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; #[test] fn defaults_to_repl_when_no_args() { @@ -741,6 +985,40 @@ mod tests { ); } + #[test] + fn parses_sessions_subcommand() { + let args = vec![ + "sessions".to_string(), + "--query".to_string(), + "compact".to_string(), + "--limit".to_string(), + "5".to_string(), + ]; + assert_eq!( + parse_args(&args).expect("args should parse"), + CliAction::ListSessions { + query: Some("compact".to_string()), + limit: 5, + } + ); + } + + #[test] + fn parses_named_resume_subcommand() { + let args = vec![ + "resume".to_string(), + "latest".to_string(), + "/compact".to_string(), + ]; + assert_eq!( + parse_args(&args).expect("args should parse"), + CliAction::ResumeNamed { + target: "latest".to_string(), + command: Some("/compact".to_string()), + } + ); + } + #[test] fn converts_tool_roundtrip_messages() { let messages = vec![ @@ -767,4 +1045,30 @@ mod tests { assert_eq!(converted[1].role, "assistant"); assert_eq!(converted[2].role, "user"); } + + #[test] + fn builds_preview_from_latest_text_block() { + let session = Session { + version: 1, + messages: vec![ + ConversationMessage::user_text("first"), + ConversationMessage::assistant(vec![ContentBlock::Text { + text: "latest preview".to_string(), + }]), + ], + }; + assert_eq!(session_preview(&session), "latest preview"); + } + + #[test] + fn resolves_direct_session_path() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + let path = std::env::temp_dir().join(format!("rusty-claude-session-{unique}.json")); + fs::write(&path, "{\"version\":1,\"messages\":[]}").expect("temp session"); + let resolved = resolve_session_target(path.to_string_lossy().as_ref()).expect("resolve"); + assert_eq!(resolved, path); + fs::remove_file(resolved).expect("cleanup"); + } }