Files
claw-code-parity/rust/crates/rusty-claude-cli/src/main.rs
T
Yeachan-Heo 95f1e2ab6b 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
2026-03-31 19:22:56 +00:00

1075 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
ToolResultContentBlock,
};
use commands::handle_slash_command;
use compat_harness::{extract_manifest, UpstreamPaths};
use render::{Spinner, TerminalRenderer};
use runtime::{
estimate_session_tokens, load_system_prompt, ApiClient, ApiRequest, AssistantEvent,
CompactionConfig, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole,
PermissionMode, PermissionPolicy, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
};
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() {
eprintln!("{error}");
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().skip(1).collect();
match parse_args(&args)? {
CliAction::DumpManifests => dump_manifests(),
CliAction::BootstrapPlan => print_bootstrap_plan(),
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
CliAction::ResumeSession {
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(),
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum CliAction {
DumpManifests,
BootstrapPlan,
PrintSystemPrompt {
cwd: PathBuf,
date: String,
},
ResumeSession {
session_path: PathBuf,
command: Option<String>,
},
ResumeNamed {
target: String,
command: Option<String>,
},
ListSessions {
query: Option<String>,
limit: usize,
},
Prompt {
prompt: String,
model: String,
},
Repl {
model: String,
},
Help,
}
fn parse_args(args: &[String]) -> Result<CliAction, String> {
let mut model = DEFAULT_MODEL.to_string();
let mut rest = Vec::new();
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--model" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --model".to_string())?;
model.clone_from(value);
index += 2;
}
flag if flag.starts_with("--model=") => {
model = flag[8..].to_string();
index += 1;
}
other => {
rest.push(other.to_string());
index += 1;
}
}
}
if rest.is_empty() {
return Ok(CliAction::Repl { model });
}
if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
return Ok(CliAction::Help);
}
if rest.first().map(String::as_str) == Some("--resume") {
return parse_resume_args(&rest[1..]);
}
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(" ");
if prompt.trim().is_empty() {
return Err("prompt subcommand requires a prompt string".to_string());
}
Ok(CliAction::Prompt { prompt, model })
}
other => Err(format!("unknown subcommand: {other}")),
}
}
fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
let mut date = DEFAULT_DATE.to_string();
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--cwd" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --cwd".to_string())?;
cwd = PathBuf::from(value);
index += 2;
}
"--date" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --date".to_string())?;
date.clone_from(value);
index += 2;
}
other => return Err(format!("unknown system-prompt option: {other}")),
}
}
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()
.ok_or_else(|| "missing session path for --resume".to_string())
.map(PathBuf::from)?;
let command = args.get(1).cloned();
if args.len() > 2 {
return Err("--resume accepts at most one trailing slash command".to_string());
}
Ok(CliAction::ResumeSession {
session_path,
command,
})
}
fn dump_manifests() {
let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let paths = UpstreamPaths::from_workspace_dir(&workspace_dir);
match extract_manifest(&paths) {
Ok(manifest) => {
println!("commands: {}", manifest.commands.entries().len());
println!("tools: {}", manifest.tools.entries().len());
println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
}
Err(error) => {
eprintln!("failed to extract manifests: {error}");
std::process::exit(1);
}
}
}
fn print_bootstrap_plan() {
for phase in runtime::BootstrapPlan::claude_code_default().phases() {
println!("- {phase:?}");
}
}
fn print_system_prompt(cwd: PathBuf, date: String) {
match load_system_prompt(cwd, date, env::consts::OS, "unknown") {
Ok(sections) => println!("{}", sections.join("\n\n")),
Err(error) => {
eprintln!("failed to build system prompt: {error}");
std::process::exit(1);
}
}
}
fn resume_session(session_path: &Path, command: Option<String>) {
let session = match Session::load_from_path(session_path) {
Ok(session) => session,
Err(error) => {
eprintln!("failed to restore session: {error}");
std::process::exit(1);
}
};
match command {
Some(command) if command.starts_with('/') => {
let Some(result) = handle_slash_command(
&command,
&session,
CompactionConfig {
max_estimated_tokens: 0,
..CompactionConfig::default()
},
) else {
eprintln!("unknown slash command: {command}");
std::process::exit(2);
};
if let Err(error) = result.session.save_to_path(session_path) {
eprintln!("failed to persist resumed session: {error}");
std::process::exit(1);
}
println!("{}", result.message);
}
Some(other) => {
eprintln!("unsupported resumed command: {other}");
std::process::exit(2);
}
None => {
println!(
"Restored session from {} ({} messages).",
session_path.display(),
session.messages.len()
);
}
}
}
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(" ");
println!("Rusty Claude CLI interactive mode");
println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
while let Some(input) = editor.read_line()? {
let trimmed = input.trim();
if trimmed.is_empty() {
continue;
}
match trimmed {
"/exit" | "/quit" => break,
"/help" => {
println!("Available commands:");
println!(" /help Show help");
println!(" /status Show session status");
println!(" /compact Compact session history");
println!(" /exit Quit the REPL");
}
"/status" => cli.print_status(),
"/compact" => cli.compact()?,
_ => cli.run_turn(trimmed)?,
}
}
Ok(())
}
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(),
system_prompt.clone(),
enable_tools,
)?;
Ok(Self {
model,
system_prompt,
runtime,
session_path,
})
}
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let mut spinner = Spinner::new();
let mut stdout = io::stdout();
spinner.tick(
"Waiting for Claude",
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
let result = self.runtime.run_turn(input, None);
match result {
Ok(turn) => {
spinner.finish(
"Claude response complete",
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
println!();
self.persist_session()?;
self.print_turn_usage(turn.usage);
Ok(())
}
Err(error) => {
spinner.fail(
"Claude request failed",
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
Err(Box::new(error))
}
}
}
fn print_status(&self) {
let usage = self.runtime.usage().cumulative_usage();
println!(
"status: messages={} turns={} estimated_session_tokens={}",
self.runtime.session().messages.len(),
self.runtime.usage().turns(),
self.runtime.estimated_tokens()
);
for line in usage.summary_lines("usage") {
println!("{line}");
}
}
fn print_turn_usage(&self, cumulative_usage: TokenUsage) {
let latest = self.runtime.usage().current_turn_usage();
println!("\nTurn usage:");
for line in latest.summary_lines(" latest") {
println!("{line}");
}
println!("Cumulative usage:");
for line in cumulative_usage.summary_lines(" total") {
println!("{line}");
}
}
fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let estimated_before = self.runtime.estimated_tokens();
let result = self.runtime.compact(CompactionConfig::default());
let removed = result.removed_message_count;
let estimated_after = estimate_session_tokens(&result.compacted_session);
let formatted_summary = result.formatted_summary.clone();
let compacted_session = result.compacted_session;
self.runtime = build_runtime(
compacted_session,
self.model.clone(),
self.system_prompt.clone(),
true,
)?;
if removed == 0 {
println!("Compaction skipped: session is below the compaction threshold.");
} else {
println!("Compacted {removed} messages into a resumable system summary.");
if !formatted_summary.is_empty() {
println!("\n{formatted_summary}");
}
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>> {
Ok(load_system_prompt(
env::current_dir()?,
DEFAULT_DATE,
env::consts::OS,
"unknown",
)?)
}
fn build_runtime(
session: Session,
model: String,
system_prompt: Vec<String>,
enable_tools: bool,
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
{
Ok(ConversationRuntime::new(
session,
AnthropicRuntimeClient::new(model, enable_tools)?,
CliToolExecutor::new(),
permission_policy_from_env(),
system_prompt,
))
}
struct AnthropicRuntimeClient {
runtime: tokio::runtime::Runtime,
client: AnthropicClient,
model: String,
enable_tools: bool,
}
impl AnthropicRuntimeClient {
fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
Ok(Self {
runtime: tokio::runtime::Runtime::new()?,
client: AnthropicClient::from_env()?,
model,
enable_tools,
})
}
}
impl ApiClient for AnthropicRuntimeClient {
#[allow(clippy::too_many_lines)]
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
let message_request = MessageRequest {
model: self.model.clone(),
max_tokens: DEFAULT_MAX_TOKENS,
messages: convert_messages(&request.messages),
system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
tools: self.enable_tools.then(|| {
mvp_tool_specs()
.into_iter()
.map(|spec| ToolDefinition {
name: spec.name.to_string(),
description: Some(spec.description.to_string()),
input_schema: spec.input_schema,
})
.collect()
}),
tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
stream: true,
};
self.runtime.block_on(async {
let mut stream = self
.client
.stream_message(&message_request)
.await
.map_err(|error| RuntimeError::new(error.to_string()))?;
let mut stdout = io::stdout();
let mut events = Vec::new();
let mut pending_tool: Option<(String, String, String)> = None;
let mut saw_stop = false;
while let Some(event) = stream
.next_event()
.await
.map_err(|error| RuntimeError::new(error.to_string()))?
{
match event {
ApiStreamEvent::MessageStart(start) => {
for block in start.message.content {
push_output_block(block, &mut stdout, &mut events, &mut pending_tool)?;
}
}
ApiStreamEvent::ContentBlockStart(start) => {
push_output_block(
start.content_block,
&mut stdout,
&mut events,
&mut pending_tool,
)?;
}
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
ContentBlockDelta::TextDelta { text } => {
if !text.is_empty() {
write!(stdout, "{text}")
.and_then(|()| stdout.flush())
.map_err(|error| RuntimeError::new(error.to_string()))?;
events.push(AssistantEvent::TextDelta(text));
}
}
ContentBlockDelta::InputJsonDelta { partial_json } => {
if let Some((_, _, input)) = &mut pending_tool {
input.push_str(&partial_json);
}
}
},
ApiStreamEvent::ContentBlockStop(_) => {
if let Some((id, name, input)) = pending_tool.take() {
events.push(AssistantEvent::ToolUse { id, name, input });
}
}
ApiStreamEvent::MessageDelta(delta) => {
events.push(AssistantEvent::Usage(TokenUsage {
input_tokens: delta.usage.input_tokens,
output_tokens: delta.usage.output_tokens,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
}));
}
ApiStreamEvent::MessageStop(_) => {
saw_stop = true;
events.push(AssistantEvent::MessageStop);
}
}
}
if !saw_stop
&& events.iter().any(|event| {
matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
|| matches!(event, AssistantEvent::ToolUse { .. })
})
{
events.push(AssistantEvent::MessageStop);
}
if events
.iter()
.any(|event| matches!(event, AssistantEvent::MessageStop))
{
return Ok(events);
}
let response = self
.client
.send_message(&MessageRequest {
stream: false,
..message_request.clone()
})
.await
.map_err(|error| RuntimeError::new(error.to_string()))?;
response_to_events(response, &mut stdout)
})
}
}
fn push_output_block(
block: OutputContentBlock,
out: &mut impl Write,
events: &mut Vec<AssistantEvent>,
pending_tool: &mut Option<(String, String, String)>,
) -> Result<(), RuntimeError> {
match block {
OutputContentBlock::Text { text } => {
if !text.is_empty() {
write!(out, "{text}")
.and_then(|()| out.flush())
.map_err(|error| RuntimeError::new(error.to_string()))?;
events.push(AssistantEvent::TextDelta(text));
}
}
OutputContentBlock::ToolUse { id, name, input } => {
*pending_tool = Some((id, name, input.to_string()));
}
}
Ok(())
}
fn response_to_events(
response: MessageResponse,
out: &mut impl Write,
) -> Result<Vec<AssistantEvent>, RuntimeError> {
let mut events = Vec::new();
let mut pending_tool = None;
for block in response.content {
push_output_block(block, out, &mut events, &mut pending_tool)?;
if let Some((id, name, input)) = pending_tool.take() {
events.push(AssistantEvent::ToolUse { id, name, input });
}
}
events.push(AssistantEvent::Usage(TokenUsage {
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,
}));
events.push(AssistantEvent::MessageStop);
Ok(events)
}
struct CliToolExecutor {
renderer: TerminalRenderer,
}
impl CliToolExecutor {
fn new() -> Self {
Self {
renderer: TerminalRenderer::new(),
}
}
}
impl ToolExecutor for CliToolExecutor {
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
let value = serde_json::from_str(input)
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
match execute_tool(tool_name, &value) {
Ok(output) => {
let markdown = format!("### Tool `{tool_name}`\n\n```json\n{output}\n```\n");
self.renderer
.stream_markdown(&markdown, &mut io::stdout())
.map_err(|error| ToolError::new(error.to_string()))?;
Ok(output)
}
Err(error) => Err(ToolError::new(error)),
}
}
}
fn permission_policy_from_env() -> PermissionPolicy {
let mode =
env::var("RUSTY_CLAUDE_PERMISSION_MODE").unwrap_or_else(|_| "workspace-write".to_string());
match mode.as_str() {
"read-only" => PermissionPolicy::new(PermissionMode::Deny)
.with_tool_mode("read_file", PermissionMode::Allow)
.with_tool_mode("glob_search", PermissionMode::Allow)
.with_tool_mode("grep_search", PermissionMode::Allow),
_ => PermissionPolicy::new(PermissionMode::Allow),
}
}
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
messages
.iter()
.filter_map(|message| {
let role = match message.role {
MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
MessageRole::Assistant => "assistant",
};
let content = message
.blocks
.iter()
.map(|block| match block {
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: serde_json::from_str(input)
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
},
ContentBlock::ToolResult {
tool_use_id,
output,
is_error,
..
} => InputContentBlock::ToolResult {
tool_use_id: tool_use_id.clone(),
content: vec![ToolResultContentBlock::Text {
text: output.clone(),
}],
is_error: *is_error,
},
})
.collect::<Vec<_>>();
(!content.is_empty()).then(|| InputMessage {
role: role.to_string(),
content,
})
})
.collect()
}
fn print_help() {
println!("rusty-claude-cli");
println!();
println!("Usage:");
println!(" rusty-claude-cli [--model MODEL] Start interactive REPL");
println!(
" rusty-claude-cli [--model MODEL] prompt TEXT Send one prompt and stream the response"
);
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, 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() {
assert_eq!(
parse_args(&[]).expect("args should parse"),
CliAction::Repl {
model: DEFAULT_MODEL.to_string(),
}
);
}
#[test]
fn parses_prompt_subcommand() {
let args = vec![
"prompt".to_string(),
"hello".to_string(),
"world".to_string(),
];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::Prompt {
prompt: "hello world".to_string(),
model: DEFAULT_MODEL.to_string(),
}
);
}
#[test]
fn parses_system_prompt_options() {
let args = vec![
"system-prompt".to_string(),
"--cwd".to_string(),
"/tmp/project".to_string(),
"--date".to_string(),
"2026-04-01".to_string(),
];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::PrintSystemPrompt {
cwd: PathBuf::from("/tmp/project"),
date: "2026-04-01".to_string(),
}
);
}
#[test]
fn parses_resume_flag_with_slash_command() {
let args = vec![
"--resume".to_string(),
"session.json".to_string(),
"/compact".to_string(),
];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::ResumeSession {
session_path: PathBuf::from("session.json"),
command: Some("/compact".to_string()),
}
);
}
#[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![
ConversationMessage::user_text("hello"),
ConversationMessage::assistant(vec![ContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "bash".to_string(),
input: "{\"command\":\"pwd\"}".to_string(),
}]),
ConversationMessage {
role: MessageRole::Tool,
blocks: vec![ContentBlock::ToolResult {
tool_use_id: "tool-1".to_string(),
tool_name: "bash".to_string(),
output: "ok".to_string(),
is_error: false,
}],
usage: None,
},
];
let converted = super::convert_messages(&messages);
assert_eq!(converted.len(), 3);
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");
}
}