Files
claw-code/rust/crates/rusty-claude-cli/src/main.rs
Yeachan-Heo 771f716625 Make the Rust CLI clone-and-run deliverable-ready
Polish the integrated Rust CLI so the branch ships like a usable deliverable instead of a scaffold. This adds explicit version handling, expands the built-in help surface with environment and workflow guidance, and replaces the placeholder rust README with practical build, test, prompt, REPL, and resume instructions. It also ignores OMX and agent scratch directories so local orchestration state stays out of the shipped branch.\n\nConstraint: Must keep the existing workspace shape and avoid adding new dependencies\nConstraint: Must not commit .omx or other local orchestration artifacts\nRejected: Introduce clap-based top-level parsing for the main binary | larger refactor than needed for release-readiness\nRejected: Leave help and version behavior implicit | too rough for a clone-and-use deliverable\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep README examples and --help output aligned whenever CLI commands or env vars change\nTested: cargo fmt --all; cargo build --release -p rusty-claude-cli; cargo test --workspace --exclude compat-harness; cargo run -p rusty-claude-cli -- --help; cargo run -p rusty-claude-cli -- --version\nNot-tested: Live Anthropic API prompt/REPL execution without credentials in this session
2026-03-31 22:44:06 +00:00

1934 lines
63 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 api::{
AnthropicClient, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
ToolResultContentBlock,
};
use commands::{render_slash_command_help, resume_supported_slash_commands, SlashCommand};
use compat_harness::{extract_manifest, UpstreamPaths};
use render::{Spinner, TerminalRenderer};
use runtime::{
load_system_prompt, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader,
ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole,
PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, Session, TokenUsage, ToolError,
ToolExecutor, UsageTracker,
};
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");
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,
commands,
} => resume_session(&session_path, &commands),
CliAction::Prompt { prompt, model } => LiveCli::new(model, false)?.run_turn(&prompt)?,
CliAction::Repl { model } => run_repl(model)?,
CliAction::Help => print_help(),
CliAction::Version => print_version(),
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum CliAction {
DumpManifests,
BootstrapPlan,
PrintSystemPrompt {
cwd: PathBuf,
date: String,
},
ResumeSession {
session_path: PathBuf,
commands: Vec<String>,
},
Prompt {
prompt: String,
model: String,
},
Repl {
model: String,
},
Help,
Version,
}
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 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..]);
}
match rest[0].as_str() {
"dump-manifests" => Ok(CliAction::DumpManifests),
"bootstrap-plan" => Ok(CliAction::BootstrapPlan),
"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_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 commands = args[1..].to_vec();
if commands
.iter()
.any(|command| !command.trim_start().starts_with('/'))
{
return Err("--resume trailing arguments must be slash commands".to_string());
}
Ok(CliAction::ResumeSession {
session_path,
commands,
})
}
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, commands: &[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);
}
};
if commands.is_empty() {
println!(
"Restored session from {} ({} messages).",
session_path.display(),
session.messages.len()
);
return;
}
let mut session = session;
for raw_command in commands {
let Some(command) = SlashCommand::parse(raw_command) else {
eprintln!("unsupported resumed command: {raw_command}");
std::process::exit(2);
};
match run_resume_command(session_path, &session, &command) {
Ok(ResumeCommandOutcome {
session: next_session,
message,
}) => {
session = next_session;
if let Some(message) = message {
println!("{message}");
}
}
Err(error) => {
eprintln!("{error}");
std::process::exit(2);
}
}
}
}
#[derive(Debug, Clone)]
struct ResumeCommandOutcome {
session: Session,
message: Option<String>,
}
#[derive(Debug, Clone)]
struct StatusContext {
cwd: PathBuf,
session_path: Option<PathBuf>,
loaded_config_files: usize,
discovered_config_files: usize,
memory_file_count: usize,
project_root: Option<PathBuf>,
git_branch: Option<String>,
}
#[derive(Debug, Clone, Copy)]
struct StatusUsage {
message_count: usize,
turns: u32,
latest: TokenUsage,
cumulative: TokenUsage,
estimated_tokens: usize,
}
fn format_model_report(model: &str, message_count: usize, turns: u32) -> String {
format!(
"Model
Current model {model}
Session messages {message_count}
Session turns {turns}
Usage
Inspect current model with /model
Switch models with /model <name>"
)
}
fn format_model_switch_report(previous: &str, next: &str, message_count: usize) -> String {
format!(
"Model updated
Previous {previous}
Current {next}
Preserved msgs {message_count}"
)
}
fn format_permissions_report(mode: &str) -> String {
let modes = [
("read-only", "Read/search tools only", mode == "read-only"),
(
"workspace-write",
"Edit files inside the workspace",
mode == "workspace-write",
),
(
"danger-full-access",
"Unrestricted tool access",
mode == "danger-full-access",
),
]
.into_iter()
.map(|(name, description, is_current)| {
let marker = if is_current {
"● current"
} else {
"○ available"
};
format!(" {name:<18} {marker:<11} {description}")
})
.collect::<Vec<_>>()
.join(
"
",
);
format!(
"Permissions
Active mode {mode}
Mode status live session default
Modes
{modes}
Usage
Inspect current mode with /permissions
Switch modes with /permissions <mode>"
)
}
fn format_permissions_switch_report(previous: &str, next: &str) -> String {
format!(
"Permissions updated
Result mode switched
Previous mode {previous}
Active mode {next}
Applies to subsequent tool calls
Usage /permissions to inspect current mode"
)
}
fn format_cost_report(usage: TokenUsage) -> String {
format!(
"Cost
Input tokens {}
Output tokens {}
Cache create {}
Cache read {}
Total tokens {}",
usage.input_tokens,
usage.output_tokens,
usage.cache_creation_input_tokens,
usage.cache_read_input_tokens,
usage.total_tokens(),
)
}
fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String {
format!(
"Session resumed
Session file {session_path}
Messages {message_count}
Turns {turns}"
)
}
fn format_init_report(path: &Path, created: bool) -> String {
if created {
format!(
"Init
CLAUDE.md {}
Result created
Next step Review and tailor the generated guidance",
path.display()
)
} else {
format!(
"Init
CLAUDE.md {}
Result skipped (already exists)
Next step Edit the existing file intentionally if workflows changed",
path.display()
)
}
}
fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String {
if skipped {
format!(
"Compact
Result skipped
Reason session below compaction threshold
Messages kept {resulting_messages}"
)
} else {
format!(
"Compact
Result compacted
Messages removed {removed}
Messages kept {resulting_messages}"
)
}
}
fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<String>) {
let Some(status) = status else {
return (None, None);
};
let branch = status.lines().next().and_then(|line| {
line.strip_prefix("## ")
.map(|line| {
line.split(['.', ' '])
.next()
.unwrap_or_default()
.to_string()
})
.filter(|value| !value.is_empty())
});
let project_root = find_git_root().ok();
(project_root, branch)
}
fn find_git_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(env::current_dir()?)
.output()?;
if !output.status.success() {
return Err("not a git repository".into());
}
let path = String::from_utf8(output.stdout)?.trim().to_string();
if path.is_empty() {
return Err("empty git root".into());
}
Ok(PathBuf::from(path))
}
fn run_resume_command(
session_path: &Path,
session: &Session,
command: &SlashCommand,
) -> Result<ResumeCommandOutcome, Box<dyn std::error::Error>> {
match command {
SlashCommand::Help => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_repl_help()),
}),
SlashCommand::Compact => {
let result = runtime::compact_session(
session,
CompactionConfig {
max_estimated_tokens: 0,
..CompactionConfig::default()
},
);
let removed = result.removed_message_count;
let kept = result.compacted_session.messages.len();
let skipped = removed == 0;
result.compacted_session.save_to_path(session_path)?;
Ok(ResumeCommandOutcome {
session: result.compacted_session,
message: Some(format_compact_report(removed, kept, skipped)),
})
}
SlashCommand::Clear { confirm } => {
if !confirm {
return Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(
"clear: confirmation required; rerun with /clear --confirm".to_string(),
),
});
}
let cleared = Session::new();
cleared.save_to_path(session_path)?;
Ok(ResumeCommandOutcome {
session: cleared,
message: Some(format!(
"Cleared resumed session file {}.",
session_path.display()
)),
})
}
SlashCommand::Status => {
let tracker = UsageTracker::from_session(session);
let usage = tracker.cumulative_usage();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_status_report(
"restored-session",
StatusUsage {
message_count: session.messages.len(),
turns: tracker.turns(),
latest: tracker.current_turn_usage(),
cumulative: usage,
estimated_tokens: 0,
},
permission_mode_label(),
&status_context(Some(session_path))?,
)),
})
}
SlashCommand::Cost => {
let usage = UsageTracker::from_session(session).cumulative_usage();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_cost_report(usage)),
})
}
SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_config_report(section.as_deref())?),
}),
SlashCommand::Memory => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_memory_report()?),
}),
SlashCommand::Init => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(init_claude_md()?),
}),
SlashCommand::Resume { .. }
| SlashCommand::Model { .. }
| SlashCommand::Permissions { .. }
| SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
}
}
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;
}
if matches!(trimmed, "/exit" | "/quit") {
break;
}
if let Some(command) = SlashCommand::parse(trimmed) {
cli.handle_repl_command(command)?;
continue;
}
cli.run_turn(trimmed)?;
}
Ok(())
}
struct LiveCli {
model: String,
system_prompt: Vec<String>,
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
}
impl LiveCli {
fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
let system_prompt = build_system_prompt()?;
let runtime = build_runtime(
Session::new(),
model.clone(),
system_prompt.clone(),
enable_tools,
)?;
Ok(Self {
model,
system_prompt,
runtime,
})
}
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(_) => {
spinner.finish(
"Claude response complete",
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
println!();
Ok(())
}
Err(error) => {
spinner.fail(
"Claude request failed",
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
Err(Box::new(error))
}
}
}
fn handle_repl_command(
&mut self,
command: SlashCommand,
) -> Result<(), Box<dyn std::error::Error>> {
match command {
SlashCommand::Help => println!("{}", render_repl_help()),
SlashCommand::Status => self.print_status(),
SlashCommand::Compact => self.compact()?,
SlashCommand::Model { model } => self.set_model(model)?,
SlashCommand::Permissions { mode } => self.set_permissions(mode)?,
SlashCommand::Clear { confirm } => self.clear_session(confirm)?,
SlashCommand::Cost => self.print_cost(),
SlashCommand::Resume { session_path } => self.resume_session(session_path)?,
SlashCommand::Config { section } => Self::print_config(section.as_deref())?,
SlashCommand::Memory => Self::print_memory()?,
SlashCommand::Init => Self::run_init()?,
SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
}
Ok(())
}
fn print_status(&self) {
let cumulative = self.runtime.usage().cumulative_usage();
let latest = self.runtime.usage().current_turn_usage();
println!(
"{}",
format_status_report(
&self.model,
StatusUsage {
message_count: self.runtime.session().messages.len(),
turns: self.runtime.usage().turns(),
latest,
cumulative,
estimated_tokens: self.runtime.estimated_tokens(),
},
permission_mode_label(),
&status_context(None).expect("status context should load"),
)
);
}
fn set_model(&mut self, model: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
let Some(model) = model else {
println!(
"{}",
format_model_report(
&self.model,
self.runtime.session().messages.len(),
self.runtime.usage().turns(),
)
);
return Ok(());
};
if model == self.model {
println!(
"{}",
format_model_report(
&self.model,
self.runtime.session().messages.len(),
self.runtime.usage().turns(),
)
);
return Ok(());
}
let previous = self.model.clone();
let session = self.runtime.session().clone();
let message_count = session.messages.len();
self.runtime = build_runtime(session, model.clone(), self.system_prompt.clone(), true)?;
self.model.clone_from(&model);
println!(
"{}",
format_model_switch_report(&previous, &model, message_count)
);
Ok(())
}
fn set_permissions(&mut self, mode: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
let Some(mode) = mode else {
println!("{}", format_permissions_report(permission_mode_label()));
return Ok(());
};
let normalized = normalize_permission_mode(&mode).ok_or_else(|| {
format!(
"Unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access."
)
})?;
if normalized == permission_mode_label() {
println!("{}", format_permissions_report(normalized));
return Ok(());
}
let previous = permission_mode_label().to_string();
let session = self.runtime.session().clone();
self.runtime = build_runtime_with_permission_mode(
session,
self.model.clone(),
self.system_prompt.clone(),
true,
normalized,
)?;
println!(
"{}",
format_permissions_switch_report(&previous, normalized)
);
Ok(())
}
fn clear_session(&mut self, confirm: bool) -> Result<(), Box<dyn std::error::Error>> {
if !confirm {
println!(
"clear: confirmation required; run /clear --confirm to start a fresh session."
);
return Ok(());
}
self.runtime = build_runtime_with_permission_mode(
Session::new(),
self.model.clone(),
self.system_prompt.clone(),
true,
permission_mode_label(),
)?;
println!(
"Session cleared
Mode fresh session
Preserved model {}
Permission mode {}",
self.model,
permission_mode_label()
);
Ok(())
}
fn print_cost(&self) {
let cumulative = self.runtime.usage().cumulative_usage();
println!("{}", format_cost_report(cumulative));
}
fn resume_session(
&mut self,
session_path: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let Some(session_path) = session_path else {
println!("Usage: /resume <session-path>");
return Ok(());
};
let session = Session::load_from_path(&session_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(),
)?;
println!(
"{}",
format_resume_report(&session_path, message_count, self.runtime.usage().turns())
);
Ok(())
}
fn print_config(section: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_config_report(section)?);
Ok(())
}
fn print_memory() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_memory_report()?);
Ok(())
}
fn run_init() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", init_claude_md()?);
Ok(())
}
fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let result = self.runtime.compact(CompactionConfig::default());
let removed = result.removed_message_count;
let kept = result.compacted_session.messages.len();
let skipped = removed == 0;
self.runtime = build_runtime_with_permission_mode(
result.compacted_session,
self.model.clone(),
self.system_prompt.clone(),
true,
permission_mode_label(),
)?;
println!("{}", format_compact_report(removed, kept, skipped));
Ok(())
}
}
fn render_repl_help() -> String {
[
"REPL".to_string(),
" /exit Quit the REPL".to_string(),
" /quit Quit the REPL".to_string(),
String::new(),
render_slash_command_help(),
]
.join(
"
",
)
}
fn status_context(
session_path: Option<&Path>,
) -> Result<StatusContext, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let discovered_config_files = loader.discover().len();
let runtime_config = loader.load()?;
let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
let (project_root, git_branch) =
parse_git_status_metadata(project_context.git_status.as_deref());
Ok(StatusContext {
cwd,
session_path: session_path.map(Path::to_path_buf),
loaded_config_files: runtime_config.loaded_entries().len(),
discovered_config_files,
memory_file_count: project_context.instruction_files.len(),
project_root,
git_branch,
})
}
fn format_status_report(
model: &str,
usage: StatusUsage,
permission_mode: &str,
context: &StatusContext,
) -> String {
[
format!(
"Status
Model {model}
Permission mode {permission_mode}
Messages {}
Turns {}
Estimated tokens {}",
usage.message_count, usage.turns, usage.estimated_tokens,
),
format!(
"Usage
Latest total {}
Cumulative input {}
Cumulative output {}
Cumulative total {}",
usage.latest.total_tokens(),
usage.cumulative.input_tokens,
usage.cumulative.output_tokens,
usage.cumulative.total_tokens(),
),
format!(
"Workspace
Cwd {}
Project root {}
Git branch {}
Session {}
Config files loaded {}/{}
Memory files {}",
context.cwd.display(),
context
.project_root
.as_ref()
.map_or_else(|| "unknown".to_string(), |path| path.display().to_string()),
context.git_branch.as_deref().unwrap_or("unknown"),
context.session_path.as_ref().map_or_else(
|| "live-repl".to_string(),
|path| path.display().to_string()
),
context.loaded_config_files,
context.discovered_config_files,
context.memory_file_count,
),
]
.join(
"
",
)
}
fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let discovered = loader.discover();
let runtime_config = loader.load()?;
let mut lines = vec![
format!(
"Config
Working directory {}
Loaded files {}
Merged keys {}",
cwd.display(),
runtime_config.loaded_entries().len(),
runtime_config.merged().len()
),
"Discovered files".to_string(),
];
for entry in discovered {
let source = match entry.source {
ConfigSource::User => "user",
ConfigSource::Project => "project",
ConfigSource::Local => "local",
};
let status = if runtime_config
.loaded_entries()
.iter()
.any(|loaded_entry| loaded_entry.path == entry.path)
{
"loaded"
} else {
"missing"
};
lines.push(format!(
" {source:<7} {status:<7} {}",
entry.path.display()
));
}
if let Some(section) = section {
lines.push(format!("Merged section: {section}"));
let value = match section {
"env" => runtime_config.get("env"),
"hooks" => runtime_config.get("hooks"),
"model" => runtime_config.get("model"),
other => {
lines.push(format!(
" Unsupported config section '{other}'. Use env, hooks, or model."
));
return Ok(lines.join(
"
",
));
}
};
lines.push(format!(
" {}",
match value {
Some(value) => value.render(),
None => "<unset>".to_string(),
}
));
return Ok(lines.join(
"
",
));
}
lines.push("Merged JSON".to_string());
lines.push(format!(" {}", runtime_config.as_json().render()));
Ok(lines.join(
"
",
))
}
fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
let mut lines = vec![format!(
"Memory
Working directory {}
Instruction files {}",
cwd.display(),
project_context.instruction_files.len()
)];
if project_context.instruction_files.is_empty() {
lines.push("Discovered files".to_string());
lines.push(
" No CLAUDE instruction files discovered in the current directory ancestry."
.to_string(),
);
} else {
lines.push("Discovered files".to_string());
for (index, file) in project_context.instruction_files.iter().enumerate() {
let preview = file.content.lines().next().unwrap_or("").trim();
let preview = if preview.is_empty() {
"<empty>"
} else {
preview
};
lines.push(format!(" {}. {}", index + 1, file.path.display(),));
lines.push(format!(
" lines={} preview={}",
file.content.lines().count(),
preview
));
}
}
Ok(lines.join(
"
",
))
}
fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let claude_md = cwd.join("CLAUDE.md");
if claude_md.exists() {
return Ok(format_init_report(&claude_md, false));
}
let content = render_init_claude_md(&cwd);
fs::write(&claude_md, content)?;
Ok(format_init_report(&claude_md, true))
}
fn render_init_claude_md(cwd: &Path) -> String {
let mut lines = vec![
"# CLAUDE.md".to_string(),
String::new(),
"This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.".to_string(),
String::new(),
];
let mut command_lines = Vec::new();
if cwd.join("rust").join("Cargo.toml").is_file() {
command_lines.push("- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
} else if cwd.join("Cargo.toml").is_file() {
command_lines.push("- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
}
if cwd.join("tests").is_dir() && cwd.join("src").is_dir() {
command_lines.push("- `src/` and `tests/` are also present; check those surfaces before removing or renaming Python-era compatibility assets.".to_string());
}
if !command_lines.is_empty() {
lines.push("## Verification".to_string());
lines.extend(command_lines);
lines.push(String::new());
}
let mut structure_lines = Vec::new();
if cwd.join("rust").is_dir() {
structure_lines.push(
"- `rust/` contains the Rust workspace and the active CLI/runtime implementation."
.to_string(),
);
}
if cwd.join("src").is_dir() {
structure_lines.push("- `src/` contains the older Python-first workspace artifacts referenced by the repo history and tests.".to_string());
}
if cwd.join("tests").is_dir() {
structure_lines.push("- `tests/` exercises compatibility and porting behavior across the repository surfaces.".to_string());
}
if !structure_lines.is_empty() {
lines.push("## Repository shape".to_string());
lines.extend(structure_lines);
lines.push(String::new());
}
lines.push("## Working agreement".to_string());
lines.push("- Prefer small, reviewable Rust changes and keep slash-command behavior aligned between the shared command registry and the CLI entrypoints.".to_string());
lines.push("- Do not overwrite existing CLAUDE.md content automatically; update it intentionally when repo workflows change.".to_string());
lines.push(String::new());
lines.join(
"
",
)
}
fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
match mode.trim() {
"read-only" => Some("read-only"),
"workspace-write" => Some("workspace-write"),
"danger-full-access" => Some("danger-full-access"),
_ => None,
}
}
fn permission_mode_label() -> &'static str {
match env::var("RUSTY_CLAUDE_PERMISSION_MODE") {
Ok(value) if value == "read-only" => "read-only",
Ok(value) if value == "danger-full-access" => "danger-full-access",
_ => "workspace-write",
}
}
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>>
{
build_runtime_with_permission_mode(
session,
model,
system_prompt,
enable_tools,
permission_mode_label(),
)
}
fn build_runtime_with_permission_mode(
session: Session,
model: String,
system_prompt: Vec<String>,
enable_tools: bool,
permission_mode: &str,
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
{
Ok(ConversationRuntime::new(
session,
AnthropicRuntimeClient::new(model, enable_tools)?,
CliToolExecutor::new(),
permission_policy(permission_mode),
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(mode: &str) -> PermissionPolicy {
if normalize_permission_mode(mode) == Some("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)
} else {
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() {
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)?;
let resume_commands = resume_supported_slash_commands()
.into_iter()
.map(|spec| match spec.argument_hint {
Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
None => format!("/{}", spec.name),
})
.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}");
}
#[cfg(test)]
mod tests {
use super::{
format_compact_report, format_cost_report, format_init_report, format_model_report,
format_model_switch_report, format_permissions_report, format_permissions_switch_report,
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,
};
use runtime::{ContentBlock, ConversationMessage, MessageRole};
use std::path::{Path, PathBuf};
#[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"),
commands: vec!["/compact".to_string()],
}
);
}
#[test]
fn parses_resume_flag_with_multiple_slash_commands() {
let args = vec![
"--resume".to_string(),
"session.json".to_string(),
"/status".to_string(),
"/compact".to_string(),
"/cost".to_string(),
];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::ResumeSession {
session_path: PathBuf::from("session.json"),
commands: vec![
"/status".to_string(),
"/compact".to_string(),
"/cost".to_string(),
],
}
);
}
#[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();
assert!(help.contains("Slash commands"));
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();
assert!(help.contains("REPL"));
assert!(help.contains("/help"));
assert!(help.contains("/status"));
assert!(help.contains("/model [model]"));
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-path>"));
assert!(help.contains("/config [env|hooks|model]"));
assert!(help.contains("/memory"));
assert!(help.contains("/init"));
assert!(help.contains("/exit"));
assert!(help.contains("slash commands"));
}
#[test]
fn resume_supported_command_list_matches_expected_surface() {
let names = resume_supported_slash_commands()
.into_iter()
.map(|spec| spec.name)
.collect::<Vec<_>>();
assert_eq!(
names,
vec!["help", "status", "compact", "clear", "cost", "config", "memory", "init",]
);
}
#[test]
fn resume_report_uses_sectioned_layout() {
let report = format_resume_report("session.json", 14, 6);
assert!(report.contains("Session resumed"));
assert!(report.contains("Session file session.json"));
assert!(report.contains("Messages 14"));
assert!(report.contains("Turns 6"));
}
#[test]
fn compact_report_uses_structured_output() {
let compacted = format_compact_report(8, 5, false);
assert!(compacted.contains("Compact"));
assert!(compacted.contains("Result compacted"));
assert!(compacted.contains("Messages removed 8"));
let skipped = format_compact_report(0, 3, true);
assert!(skipped.contains("Result skipped"));
}
#[test]
fn cost_report_uses_sectioned_layout() {
let report = format_cost_report(runtime::TokenUsage {
input_tokens: 20,
output_tokens: 8,
cache_creation_input_tokens: 3,
cache_read_input_tokens: 1,
});
assert!(report.contains("Cost"));
assert!(report.contains("Input tokens 20"));
assert!(report.contains("Output tokens 8"));
assert!(report.contains("Cache create 3"));
assert!(report.contains("Cache read 1"));
assert!(report.contains("Total tokens 32"));
}
#[test]
fn permissions_report_uses_sectioned_layout() {
let report = format_permissions_report("workspace-write");
assert!(report.contains("Permissions"));
assert!(report.contains("Active mode workspace-write"));
assert!(report.contains("Modes"));
assert!(report.contains("read-only ○ available Read/search tools only"));
assert!(report.contains("workspace-write ● current Edit files inside the workspace"));
assert!(report.contains("danger-full-access ○ available Unrestricted tool access"));
}
#[test]
fn permissions_switch_report_is_structured() {
let report = format_permissions_switch_report("read-only", "workspace-write");
assert!(report.contains("Permissions updated"));
assert!(report.contains("Result mode switched"));
assert!(report.contains("Previous mode read-only"));
assert!(report.contains("Active mode workspace-write"));
assert!(report.contains("Applies to subsequent tool calls"));
}
#[test]
fn init_report_uses_structured_output() {
let created = format_init_report(Path::new("/tmp/CLAUDE.md"), true);
assert!(created.contains("Init"));
assert!(created.contains("Result created"));
let skipped = format_init_report(Path::new("/tmp/CLAUDE.md"), false);
assert!(skipped.contains("skipped (already exists)"));
}
#[test]
fn model_report_uses_sectioned_layout() {
let report = format_model_report("claude-sonnet", 12, 4);
assert!(report.contains("Model"));
assert!(report.contains("Current model claude-sonnet"));
assert!(report.contains("Session messages 12"));
assert!(report.contains("Switch models with /model <name>"));
}
#[test]
fn model_switch_report_preserves_context_summary() {
let report = format_model_switch_report("claude-sonnet", "claude-opus", 9);
assert!(report.contains("Model updated"));
assert!(report.contains("Previous claude-sonnet"));
assert!(report.contains("Current claude-opus"));
assert!(report.contains("Preserved msgs 9"));
}
#[test]
fn status_line_reports_model_and_token_totals() {
let status = format_status_report(
"claude-sonnet",
StatusUsage {
message_count: 7,
turns: 3,
latest: runtime::TokenUsage {
input_tokens: 5,
output_tokens: 4,
cache_creation_input_tokens: 1,
cache_read_input_tokens: 0,
},
cumulative: runtime::TokenUsage {
input_tokens: 20,
output_tokens: 8,
cache_creation_input_tokens: 2,
cache_read_input_tokens: 1,
},
estimated_tokens: 128,
},
"workspace-write",
&super::StatusContext {
cwd: PathBuf::from("/tmp/project"),
session_path: Some(PathBuf::from("session.json")),
loaded_config_files: 2,
discovered_config_files: 3,
memory_file_count: 4,
project_root: Some(PathBuf::from("/tmp")),
git_branch: Some("main".to_string()),
},
);
assert!(status.contains("Status"));
assert!(status.contains("Model claude-sonnet"));
assert!(status.contains("Permission mode workspace-write"));
assert!(status.contains("Messages 7"));
assert!(status.contains("Latest total 10"));
assert!(status.contains("Cumulative total 31"));
assert!(status.contains("Cwd /tmp/project"));
assert!(status.contains("Project root /tmp"));
assert!(status.contains("Git branch main"));
assert!(status.contains("Session session.json"));
assert!(status.contains("Config files loaded 2/3"));
assert!(status.contains("Memory files 4"));
}
#[test]
fn config_report_supports_section_views() {
let report = render_config_report(Some("env")).expect("config report should render");
assert!(report.contains("Merged section: env"));
}
#[test]
fn memory_report_uses_sectioned_layout() {
let report = render_memory_report().expect("memory report should render");
assert!(report.contains("Memory"));
assert!(report.contains("Working directory"));
assert!(report.contains("Instruction files"));
assert!(report.contains("Discovered files"));
}
#[test]
fn config_report_uses_sectioned_layout() {
let report = render_config_report(None).expect("config report should render");
assert!(report.contains("Config"));
assert!(report.contains("Discovered files"));
assert!(report.contains("Merged JSON"));
}
#[test]
fn parses_git_status_metadata() {
let (root, branch) = parse_git_status_metadata(Some(
"## rcc/cli...origin/rcc/cli
M src/main.rs",
));
assert_eq!(branch.as_deref(), Some("rcc/cli"));
let _ = root;
}
#[test]
fn status_context_reads_real_workspace_metadata() {
let context = status_context(None).expect("status context should load");
assert!(context.cwd.is_absolute());
assert_eq!(context.discovered_config_files, 3);
assert!(context.loaded_config_files <= context.discovered_config_files);
}
#[test]
fn normalizes_supported_permission_modes() {
assert_eq!(normalize_permission_mode("read-only"), Some("read-only"));
assert_eq!(
normalize_permission_mode("workspace-write"),
Some("workspace-write")
);
assert_eq!(
normalize_permission_mode("danger-full-access"),
Some("danger-full-access")
);
assert_eq!(normalize_permission_mode("unknown"), None);
}
#[test]
fn clear_command_requires_explicit_confirmation_flag() {
assert_eq!(
SlashCommand::parse("/clear"),
Some(SlashCommand::Clear { confirm: false })
);
assert_eq!(
SlashCommand::parse("/clear --confirm"),
Some(SlashCommand::Clear { confirm: true })
);
}
#[test]
fn parses_resume_and_config_slash_commands() {
assert_eq!(
SlashCommand::parse("/resume saved-session.json"),
Some(SlashCommand::Resume {
session_path: Some("saved-session.json".to_string())
})
);
assert_eq!(
SlashCommand::parse("/clear --confirm"),
Some(SlashCommand::Clear { confirm: true })
);
assert_eq!(
SlashCommand::parse("/config"),
Some(SlashCommand::Config { section: None })
);
assert_eq!(
SlashCommand::parse("/config env"),
Some(SlashCommand::Config {
section: Some("env".to_string())
})
);
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
}
#[test]
fn init_template_mentions_detected_rust_workspace() {
let rendered = render_init_claude_md(Path::new("."));
assert!(rendered.contains("# CLAUDE.md"));
assert!(rendered.contains("cargo clippy --workspace --all-targets -- -D warnings"));
}
#[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");
}
}