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,
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)]
@@ -112,14 +136,33 @@ pub enum SlashCommand {
Help,
Status,
Compact,
Model { model: Option<String> },
Permissions { mode: Option<String> },
Clear { confirm: bool },
Model {
model: Option<String>,
},
Permissions {
mode: Option<String>,
},
Clear {
confirm: bool,
},
Cost,
Resume { session_path: Option<String> },
Config { section: Option<String> },
Resume {
session_path: Option<String>,
},
Config {
section: Option<String>,
},
Memory,
Init,
Diff,
Version,
Export {
path: Option<String>,
},
Session {
action: Option<String>,
target: Option<String>,
},
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 <session-id>]"));
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()
);
}
}

View File

@@ -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<dyn std::error::Error>> {
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<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> {
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<CliAction, String> {
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<CliAction, String> {
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<CliAction, String> {
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<PathBuf, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<String>,
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
session: SessionHandle,
}
impl LiveCli {
fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
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(
|_| "<unknown>".to_string(),
|path| path.display().to_string(),
),
self.session.id,
)
}
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
@@ -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<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(
&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<dyn std::error::Error>> {
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<String>,
) -> 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>");
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<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>> {
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<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 {
[
"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>> {
Ok(load_system_prompt(
env::current_dir()?,
@@ -1406,91 +1861,28 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
}
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::<Vec<_>>()
.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 <session-id>]"));
assert!(help.contains("/exit"));
assert!(help.contains("slash commands"));
}
#[test]
@@ -1671,7 +2049,10 @@ mod tests {
.collect::<Vec<_>>();
assert_eq!(
names,
vec!["help", "status", "compact", "clear", "cost", "config", "memory", "init",]
vec![
"help", "status", "compact", "clear", "cost", "config", "memory", "init", "diff",
"version", "export",
]
);
}