Merge remote-tracking branch 'origin/rcc/cli' into dev/rust

# Conflicts:
#	rust/crates/rusty-claude-cli/src/main.rs
This commit is contained in:
Yeachan-Heo
2026-03-31 23:09:30 +00:00
2 changed files with 624 additions and 159 deletions

View File

@@ -105,6 +105,30 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: None, argument_hint: None,
resume_supported: true, 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 <session-id>]"),
resume_supported: false,
},
]; ];
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -112,14 +136,33 @@ pub enum SlashCommand {
Help, Help,
Status, Status,
Compact, Compact,
Model { model: Option<String> }, Model {
Permissions { mode: Option<String> }, model: Option<String>,
Clear { confirm: bool }, },
Permissions {
mode: Option<String>,
},
Clear {
confirm: bool,
},
Cost, Cost,
Resume { session_path: Option<String> }, Resume {
Config { section: Option<String> }, session_path: Option<String>,
},
Config {
section: Option<String>,
},
Memory, Memory,
Init, Init,
Diff,
Version,
Export {
path: Option<String>,
},
Session {
action: Option<String>,
target: Option<String>,
},
Unknown(String), Unknown(String),
} }
@@ -155,6 +198,15 @@ impl SlashCommand {
}, },
"memory" => Self::Memory, "memory" => Self::Memory,
"init" => Self::Init, "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()), other => Self::Unknown(other.to_string()),
}) })
} }
@@ -235,6 +287,10 @@ pub fn handle_slash_command(
| SlashCommand::Config { .. } | SlashCommand::Config { .. }
| SlashCommand::Memory | SlashCommand::Memory
| SlashCommand::Init | SlashCommand::Init
| SlashCommand::Diff
| SlashCommand::Version
| SlashCommand::Export { .. }
| SlashCommand::Session { .. }
| SlashCommand::Unknown(_) => None, | SlashCommand::Unknown(_) => None,
} }
} }
@@ -294,6 +350,21 @@ mod tests {
); );
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init)); 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] #[test]
@@ -311,8 +382,12 @@ mod tests {
assert!(help.contains("/config [env|hooks|model]")); assert!(help.contains("/config [env|hooks|model]"));
assert!(help.contains("/memory")); assert!(help.contains("/memory"));
assert!(help.contains("/init")); assert!(help.contains("/init"));
assert_eq!(slash_command_specs().len(), 11); assert!(help.contains("/diff"));
assert_eq!(resume_supported_slash_commands().len(), 8); assert!(help.contains("/version"));
assert!(help.contains("/export [file]"));
assert!(help.contains("/session [list|switch <session-id>]"));
assert_eq!(slash_command_specs().len(), 15);
assert_eq!(resume_supported_slash_commands().len(), 11);
} }
#[test] #[test]
@@ -384,5 +459,14 @@ mod tests {
assert!( assert!(
handle_slash_command("/config env", &session, CompactionConfig::default()).is_none() 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()
);
} }
} }

View File

@@ -5,6 +5,7 @@ use std::env;
use std::fs; use std::fs;
use std::io::{self, Write}; use std::io::{self, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use api::{ use api::{
AnthropicClient, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, AnthropicClient, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
@@ -21,16 +22,23 @@ use runtime::{
PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, Session, TokenUsage, ToolError, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, Session, TokenUsage, ToolError,
ToolExecutor, UsageTracker, ToolExecutor, UsageTracker,
}; };
use serde_json::json;
use tools::{execute_tool, mvp_tool_specs}; use tools::{execute_tool, mvp_tool_specs};
const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514"; const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
const DEFAULT_MAX_TOKENS: u32 = 32; const DEFAULT_MAX_TOKENS: u32 = 32;
const DEFAULT_DATE: &str = "2026-03-31"; const DEFAULT_DATE: &str = "2026-03-31";
const VERSION: &str = env!("CARGO_PKG_VERSION"); 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() { fn main() {
if let Err(error) = run() { if let Err(error) = run() {
eprintln!("{error}"); eprintln!(
"error: {error}
Run `rusty-claude-cli --help` for usage."
);
std::process::exit(1); std::process::exit(1);
} }
} }
@@ -45,10 +53,13 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
session_path, session_path,
commands, commands,
} => resume_session(&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::Repl { model } => run_repl(model)?,
CliAction::Help => print_help(), CliAction::Help => print_help(),
CliAction::Version => print_version(),
} }
Ok(()) Ok(())
} }
@@ -68,16 +79,36 @@ enum CliAction {
Prompt { Prompt {
prompt: String, prompt: String,
model: String, model: String,
output_format: CliOutputFormat,
}, },
Repl { Repl {
model: String, model: String,
}, },
// prompt-mode formatting is only supported for non-interactive runs
Help, Help,
Version, }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CliOutputFormat {
Text,
Json,
}
impl CliOutputFormat {
fn parse(value: &str) -> Result<Self, String> {
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<CliAction, String> { fn parse_args(args: &[String]) -> Result<CliAction, String> {
let mut model = DEFAULT_MODEL.to_string(); let mut model = DEFAULT_MODEL.to_string();
let mut output_format = CliOutputFormat::Text;
let mut rest = Vec::new(); let mut rest = Vec::new();
let mut index = 0; let mut index = 0;
@@ -94,6 +125,17 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
model = flag[8..].to_string(); model = flag[8..].to_string();
index += 1; 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 => { other => {
rest.push(other.to_string()); rest.push(other.to_string());
index += 1; index += 1;
@@ -107,9 +149,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) { if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
return Ok(CliAction::Help); 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") { if rest.first().map(String::as_str) == Some("--resume") {
return parse_resume_args(&rest[1..]); return parse_resume_args(&rest[1..]);
} }
@@ -123,8 +162,17 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
if prompt.trim().is_empty() { if prompt.trim().is_empty() {
return Err("prompt subcommand requires a prompt string".to_string()); 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}")), other => Err(format!("unknown subcommand: {other}")),
} }
} }
@@ -447,6 +495,7 @@ fn find_git_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
Ok(PathBuf::from(path)) Ok(PathBuf::from(path))
} }
#[allow(clippy::too_many_lines)]
fn run_resume_command( fn run_resume_command(
session_path: &Path, session_path: &Path,
session: &Session, session: &Session,
@@ -531,9 +580,30 @@ fn run_resume_command(
session: session.clone(), session: session.clone(),
message: Some(init_claude_md()?), 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::Resume { .. }
| SlashCommand::Model { .. } | SlashCommand::Model { .. }
| SlashCommand::Permissions { .. } | SlashCommand::Permissions { .. }
| SlashCommand::Session { .. }
| SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
} }
} }
@@ -541,8 +611,7 @@ fn run_resume_command(
fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> { fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> {
let mut cli = LiveCli::new(model, true)?; let mut cli = LiveCli::new(model, true)?;
let editor = input::LineEditor::new(" "); let editor = input::LineEditor::new(" ");
println!("Rusty Claude CLI interactive mode"); println!("{}", cli.startup_banner());
println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
while let Some(input) = editor.read_line()? { while let Some(input) = editor.read_line()? {
let trimmed = input.trim(); let trimmed = input.trim();
@@ -562,26 +631,57 @@ fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> {
Ok(()) 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 { struct LiveCli {
model: String, model: String,
system_prompt: Vec<String>, system_prompt: Vec<String>,
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
session: SessionHandle,
} }
impl LiveCli { impl LiveCli {
fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> { fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
let system_prompt = build_system_prompt()?; let system_prompt = build_system_prompt()?;
let session = create_managed_session_handle()?;
let runtime = build_runtime( let runtime = build_runtime(
Session::new(), Session::new(),
model.clone(), model.clone(),
system_prompt.clone(), system_prompt.clone(),
enable_tools, enable_tools,
)?; )?;
Ok(Self { let cli = Self {
model, model,
system_prompt, system_prompt,
runtime, 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(
|_| "<unknown>".to_string(),
|path| path.display().to_string(),
),
self.session.id,
)
} }
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> { fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
@@ -601,6 +701,7 @@ impl LiveCli {
&mut stdout, &mut stdout,
)?; )?;
println!(); println!();
self.persist_session()?;
Ok(()) Ok(())
} }
Err(error) => { Err(error) => {
@@ -614,6 +715,60 @@ impl LiveCli {
} }
} }
fn run_turn_with_output(
&mut self,
input: &str,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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::<Vec<_>>()
.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( fn handle_repl_command(
&mut self, &mut self,
command: SlashCommand, command: SlashCommand,
@@ -630,11 +785,22 @@ impl LiveCli {
SlashCommand::Config { section } => Self::print_config(section.as_deref())?, SlashCommand::Config { section } => Self::print_config(section.as_deref())?,
SlashCommand::Memory => Self::print_memory()?, SlashCommand::Memory => Self::print_memory()?,
SlashCommand::Init => Self::run_init()?, 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}"), SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
} }
Ok(()) Ok(())
} }
fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
self.runtime.session().save_to_path(&self.session.path)?;
Ok(())
}
fn print_status(&self) { fn print_status(&self) {
let cumulative = self.runtime.usage().cumulative_usage(); let cumulative = self.runtime.usage().cumulative_usage();
let latest = self.runtime.usage().current_turn_usage(); let latest = self.runtime.usage().current_turn_usage();
@@ -650,7 +816,7 @@ impl LiveCli {
estimated_tokens: self.runtime.estimated_tokens(), estimated_tokens: self.runtime.estimated_tokens(),
}, },
permission_mode_label(), 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(); let message_count = session.messages.len();
self.runtime = build_runtime(session, model.clone(), self.system_prompt.clone(), true)?; self.runtime = build_runtime(session, model.clone(), self.system_prompt.clone(), true)?;
self.model.clone_from(&model); self.model.clone_from(&model);
self.persist_session()?;
println!( println!(
"{}", "{}",
format_model_switch_report(&previous, &model, message_count) format_model_switch_report(&previous, &model, message_count)
@@ -700,7 +867,7 @@ impl LiveCli {
let normalized = normalize_permission_mode(&mode).ok_or_else(|| { let normalized = normalize_permission_mode(&mode).ok_or_else(|| {
format!( 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, true,
normalized, normalized,
)?; )?;
self.persist_session()?;
println!( println!(
"{}", "{}",
format_permissions_switch_report(&previous, normalized) format_permissions_switch_report(&previous, normalized)
@@ -733,6 +901,7 @@ impl LiveCli {
return Ok(()); return Ok(());
} }
self.session = create_managed_session_handle()?;
self.runtime = build_runtime_with_permission_mode( self.runtime = build_runtime_with_permission_mode(
Session::new(), Session::new(),
self.model.clone(), self.model.clone(),
@@ -740,13 +909,12 @@ impl LiveCli {
true, true,
permission_mode_label(), permission_mode_label(),
)?; )?;
self.persist_session()?;
println!( println!(
"Session cleared "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}",
Mode fresh session
Preserved model {}
Permission mode {}",
self.model, self.model,
permission_mode_label() permission_mode_label(),
self.session.id,
); );
Ok(()) Ok(())
} }
@@ -760,12 +928,13 @@ impl LiveCli {
&mut self, &mut self,
session_path: Option<String>, session_path: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let Some(session_path) = session_path else { let Some(session_ref) = session_path else {
println!("Usage: /resume <session-path>"); println!("Usage: /resume <session-path>");
return Ok(()); 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(); let message_count = session.messages.len();
self.runtime = build_runtime_with_permission_mode( self.runtime = build_runtime_with_permission_mode(
session, session,
@@ -774,9 +943,15 @@ impl LiveCli {
true, true,
permission_mode_label(), permission_mode_label(),
)?; )?;
self.session = handle;
self.persist_session()?;
println!( 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(()) Ok(())
} }
@@ -796,6 +971,71 @@ impl LiveCli {
Ok(()) Ok(())
} }
fn print_diff() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_diff_report()?);
Ok(())
}
fn print_version() {
println!("{}", render_version_report());
}
fn export_session(
&self,
requested_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
match action {
None | Some("list") => {
println!("{}", render_session_list(&self.session.id)?);
Ok(())
}
Some("switch") => {
let Some(target) = target else {
println!("Usage: /session switch <session-id>");
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 <session-id>.");
Ok(())
}
}
}
fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> { fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let result = self.runtime.compact(CompactionConfig::default()); let result = self.runtime.compact(CompactionConfig::default());
let removed = result.removed_message_count; let removed = result.removed_message_count;
@@ -808,11 +1048,112 @@ impl LiveCli {
true, true,
permission_mode_label(), permission_mode_label(),
)?; )?;
self.persist_session()?;
println!("{}", format_compact_report(removed, kept, skipped)); println!("{}", format_compact_report(removed, kept, skipped));
Ok(()) Ok(())
} }
} }
fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
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<SessionHandle, Box<dyn std::error::Error>> {
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<SessionHandle, Box<dyn std::error::Error>> {
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<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> {
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<String, Box<dyn std::error::Error>> {
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 { fn render_repl_help() -> String {
[ [
"REPL".to_string(), "REPL".to_string(),
@@ -1102,6 +1443,120 @@ fn permission_mode_label() -> &'static str {
} }
} }
fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
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::<String>()
.split('-')
.filter(|part| !part.is_empty())
.take(8)
.collect::<Vec<_>>()
.join("-");
let fallback = if stem.is_empty() {
"conversation"
} else {
&stem
};
format!("{fallback}.txt")
}
fn resolve_export_path(
requested_path: Option<&str>,
session: &Session,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
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<Vec<String>, Box<dyn std::error::Error>> { fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
Ok(load_system_prompt( Ok(load_system_prompt(
env::current_dir()?, env::current_dir()?,
@@ -1406,91 +1861,28 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
} }
fn print_help() { fn print_help() {
let mut stdout = io::stdout(); println!("rusty-claude-cli v{VERSION}");
let _ = print_help_to(&mut stdout); println!();
} println!("Usage:");
println!(" rusty-claude-cli [--model MODEL]");
fn print_help_to(out: &mut impl Write) -> io::Result<()> { println!(" Start the interactive REPL");
writeln!(out, "rusty-claude-cli")?; println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] prompt TEXT");
writeln!(out, "Version: {VERSION}")?; println!(" Send one prompt and exit");
writeln!(out)?; println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] TEXT");
writeln!( println!(" Shorthand non-interactive prompt mode");
out, println!(" rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]");
"Rust-first Claude Code-style CLI for prompt, REPL, and saved-session workflows." println!(" Inspect or maintain a saved session without entering the REPL");
)?; println!(" rusty-claude-cli dump-manifests");
writeln!(out)?; println!(" rusty-claude-cli bootstrap-plan");
writeln!(out, "Usage:")?; println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
writeln!(out, " rusty-claude-cli [--model MODEL]")?; println!();
writeln!(out, " Start interactive REPL")?; println!("Flags:");
writeln!(out, " rusty-claude-cli [--model MODEL] prompt TEXT")?; println!(" --model MODEL Override the active model");
writeln!(out, " Send one prompt and stream the response")?; println!(" --output-format FORMAT Non-interactive output format: text or json");
writeln!( println!();
out, println!("Interactive slash commands:");
" rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]" println!("{}", render_slash_command_help());
)?; println!();
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)?;
let resume_commands = resume_supported_slash_commands() let resume_commands = resume_supported_slash_commands()
.into_iter() .into_iter()
.map(|spec| match spec.argument_hint { .map(|spec| match spec.argument_hint {
@@ -1499,26 +1891,11 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
writeln!(out, "Resume-safe commands: {resume_commands}")?; println!("Resume-safe commands: {resume_commands}");
writeln!(out, "Examples:")?; println!("Examples:");
writeln!( println!(" rusty-claude-cli --model claude-opus \"summarize this repo\"");
out, println!(" rusty-claude-cli --output-format json prompt \"explain src/main.rs\"");
" rusty-claude-cli prompt \"Summarize the repo architecture\"" println!(" rusty-claude-cli --resume session.json /status /diff /export notes.txt");
)?;
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}");
} }
#[cfg(test)] #[cfg(test)]
@@ -1529,7 +1906,7 @@ mod tests {
format_resume_report, format_status_report, normalize_permission_mode, parse_args, format_resume_report, format_status_report, normalize_permission_mode, parse_args,
parse_git_status_metadata, render_config_report, render_init_claude_md, parse_git_status_metadata, render_config_report, render_init_claude_md,
render_memory_report, render_repl_help, resume_supported_slash_commands, status_context, 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 runtime::{ContentBlock, ConversationMessage, MessageRole};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -1556,6 +1933,26 @@ mod tests {
CliAction::Prompt { CliAction::Prompt {
prompt: "hello world".to_string(), prompt: "hello world".to_string(),
model: DEFAULT_MODEL.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] #[test]
fn shared_help_uses_resume_annotation_copy() { fn shared_help_uses_resume_annotation_copy() {
let help = commands::render_slash_command_help(); let help = commands::render_slash_command_help();
@@ -1635,16 +2020,6 @@ mod tests {
assert!(help.contains("works with --resume SESSION.json")); 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] #[test]
fn repl_help_includes_shared_commands_and_exit() { fn repl_help_includes_shared_commands_and_exit() {
let help = render_repl_help(); let help = render_repl_help();
@@ -1659,8 +2034,11 @@ mod tests {
assert!(help.contains("/config [env|hooks|model]")); assert!(help.contains("/config [env|hooks|model]"));
assert!(help.contains("/memory")); assert!(help.contains("/memory"));
assert!(help.contains("/init")); assert!(help.contains("/init"));
assert!(help.contains("/diff"));
assert!(help.contains("/version"));
assert!(help.contains("/export [file]"));
assert!(help.contains("/session [list|switch <session-id>]"));
assert!(help.contains("/exit")); assert!(help.contains("/exit"));
assert!(help.contains("slash commands"));
} }
#[test] #[test]
@@ -1671,7 +2049,10 @@ mod tests {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert_eq!( assert_eq!(
names, names,
vec!["help", "status", "compact", "clear", "cost", "config", "memory", "init",] vec![
"help", "status", "compact", "clear", "cost", "config", "memory", "init", "diff",
"version", "export",
]
); );
} }