Make Rust sessions easier to find and resume
This adds a lightweight session home for the Rust CLI, auto-persists REPL state, and exposes list, search, show, and named resume flows so users no longer need to remember raw JSON paths. The change keeps the old --resume SESSION.json path working while adding friendlier session discovery. It also makes API env-based tests hermetic so workspace verification remains stable regardless of shell environment. Constraint: Keep session UX incremental and CLI-native without introducing a new database or TUI layer Constraint: Preserve backward compatibility for the existing --resume SESSION.json workflow Rejected: Build a richer interactive picker now | higher implementation cost than needed for this parity slice Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep human-friendly session lookup additive; do not remove explicit path-based resume support Tested: cargo fmt; cargo clippy --all-targets --all-features -- -D warnings; cargo test -q Not-tested: Manual multi-session interactive REPL behavior across multiple terminals
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
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<String>,
|
||||
},
|
||||
ResumeNamed {
|
||||
target: String,
|
||||
command: Option<String>,
|
||||
},
|
||||
ListSessions {
|
||||
query: Option<String>,
|
||||
limit: usize,
|
||||
},
|
||||
Prompt {
|
||||
prompt: String,
|
||||
model: String,
|
||||
@@ -109,6 +122,8 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
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<CliAction, String> {
|
||||
Ok(CliAction::PrintSystemPrompt { cwd, date })
|
||||
}
|
||||
|
||||
fn parse_named_resume_args(args: &[String]) -> Result<CliAction, String> {
|
||||
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<CliAction, String> {
|
||||
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::<usize>()
|
||||
.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<CliAction, String> {
|
||||
let session_path = args
|
||||
.first()
|
||||
@@ -238,6 +295,43 @@ fn resume_session(session_path: &Path, command: Option<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn resume_named_session(target: &str, command: Option<String>) {
|
||||
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<dyn std::error::Error>> {
|
||||
let mut cli = LiveCli::new(model, true)?;
|
||||
let editor = input::LineEditor::new("› ");
|
||||
@@ -271,11 +365,13 @@ struct LiveCli {
|
||||
model: String,
|
||||
system_prompt: Vec<String>,
|
||||
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
|
||||
session_path: PathBuf,
|
||||
}
|
||||
|
||||
impl LiveCli {
|
||||
fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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::<Vec<_>>();
|
||||
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<Vec<SessionListEntry>> {
|
||||
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::<String>();
|
||||
output.push('…');
|
||||
output
|
||||
}
|
||||
|
||||
fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
@@ -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 <latest|SESSION|PATH> [/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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user