The custom crossterm editor now supports prompt history, slash-command tab completion, multiline editing, and Ctrl-C semantics that clear partial input without always terminating the session. The live REPL loop now distinguishes buffer cancellation from clean exit, persists session state on meaningful boundaries, and renders tool activity in a more structured way for terminal use. Constraint: Keep the active REPL on the existing crossterm path without adding a line-editor dependency Rejected: Swap to rustyline or reedline | broader integration risk than this polish pass justifies Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep editor state logic generic in input.rs and leave REPL policy decisions in main.rs Tested: cargo fmt --manifest-path rust/Cargo.toml --all; cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features -- -D warnings; cargo test --manifest-path rust/Cargo.toml Not-tested: Interactive manual terminal smoke test for arrow keys/tab/Ctrl-C in a live TTY
2658 lines
87 KiB
Rust
2658 lines
87 KiB
Rust
mod input;
|
||
mod render;
|
||
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
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::{
|
||
render_slash_command_help, resume_supported_slash_commands, slash_command_specs, 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 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");
|
||
|
||
type AllowedToolSet = BTreeSet<String>;
|
||
|
||
fn main() {
|
||
if let Err(error) = run() {
|
||
eprintln!(
|
||
"error: {error}
|
||
|
||
Run `rusty-claude-cli --help` for usage."
|
||
);
|
||
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::Version => print_version(),
|
||
CliAction::ResumeSession {
|
||
session_path,
|
||
commands,
|
||
} => resume_session(&session_path, &commands),
|
||
CliAction::Prompt {
|
||
prompt,
|
||
model,
|
||
output_format,
|
||
allowed_tools,
|
||
} => LiveCli::new(model, false, allowed_tools)?
|
||
.run_turn_with_output(&prompt, output_format)?,
|
||
CliAction::Repl {
|
||
model,
|
||
allowed_tools,
|
||
} => run_repl(model, allowed_tools)?,
|
||
CliAction::Help => print_help(),
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
enum CliAction {
|
||
DumpManifests,
|
||
BootstrapPlan,
|
||
PrintSystemPrompt {
|
||
cwd: PathBuf,
|
||
date: String,
|
||
},
|
||
Version,
|
||
ResumeSession {
|
||
session_path: PathBuf,
|
||
commands: Vec<String>,
|
||
},
|
||
Prompt {
|
||
prompt: String,
|
||
model: String,
|
||
output_format: CliOutputFormat,
|
||
allowed_tools: Option<AllowedToolSet>,
|
||
},
|
||
Repl {
|
||
model: String,
|
||
allowed_tools: Option<AllowedToolSet>,
|
||
},
|
||
// prompt-mode formatting is only supported for non-interactive runs
|
||
Help,
|
||
}
|
||
|
||
#[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 wants_version = false;
|
||
let mut allowed_tool_values = Vec::new();
|
||
let mut rest = Vec::new();
|
||
let mut index = 0;
|
||
|
||
while index < args.len() {
|
||
match args[index].as_str() {
|
||
"--version" | "-V" => {
|
||
wants_version = true;
|
||
index += 1;
|
||
}
|
||
"--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;
|
||
}
|
||
"--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;
|
||
}
|
||
"--allowedTools" | "--allowed-tools" => {
|
||
let value = args
|
||
.get(index + 1)
|
||
.ok_or_else(|| "missing value for --allowedTools".to_string())?;
|
||
allowed_tool_values.push(value.clone());
|
||
index += 2;
|
||
}
|
||
flag if flag.starts_with("--allowedTools=") => {
|
||
allowed_tool_values.push(flag[15..].to_string());
|
||
index += 1;
|
||
}
|
||
flag if flag.starts_with("--allowed-tools=") => {
|
||
allowed_tool_values.push(flag[16..].to_string());
|
||
index += 1;
|
||
}
|
||
other => {
|
||
rest.push(other.to_string());
|
||
index += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
if wants_version {
|
||
return Ok(CliAction::Version);
|
||
}
|
||
|
||
let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?;
|
||
|
||
if rest.is_empty() {
|
||
return Ok(CliAction::Repl {
|
||
model,
|
||
allowed_tools,
|
||
});
|
||
}
|
||
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),
|
||
"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,
|
||
output_format,
|
||
allowed_tools,
|
||
})
|
||
}
|
||
other if !other.starts_with('/') => Ok(CliAction::Prompt {
|
||
prompt: rest.join(" "),
|
||
model,
|
||
output_format,
|
||
allowed_tools,
|
||
}),
|
||
other => Err(format!("unknown subcommand: {other}")),
|
||
}
|
||
}
|
||
|
||
fn normalize_allowed_tools(values: &[String]) -> Result<Option<AllowedToolSet>, String> {
|
||
if values.is_empty() {
|
||
return Ok(None);
|
||
}
|
||
|
||
let canonical_names = mvp_tool_specs()
|
||
.into_iter()
|
||
.map(|spec| spec.name.to_string())
|
||
.collect::<Vec<_>>();
|
||
let mut name_map = canonical_names
|
||
.iter()
|
||
.map(|name| (normalize_tool_name(name), name.clone()))
|
||
.collect::<BTreeMap<_, _>>();
|
||
|
||
for (alias, canonical) in [
|
||
("read", "read_file"),
|
||
("write", "write_file"),
|
||
("edit", "edit_file"),
|
||
("glob", "glob_search"),
|
||
("grep", "grep_search"),
|
||
] {
|
||
name_map.insert(alias.to_string(), canonical.to_string());
|
||
}
|
||
|
||
let mut allowed = AllowedToolSet::new();
|
||
for value in values {
|
||
for token in value
|
||
.split(|ch: char| ch == ',' || ch.is_whitespace())
|
||
.filter(|token| !token.is_empty())
|
||
{
|
||
let normalized = normalize_tool_name(token);
|
||
let canonical = name_map.get(&normalized).ok_or_else(|| {
|
||
format!(
|
||
"unsupported tool in --allowedTools: {token} (expected one of: {})",
|
||
canonical_names.join(", ")
|
||
)
|
||
})?;
|
||
allowed.insert(canonical.clone());
|
||
}
|
||
}
|
||
|
||
Ok(Some(allowed))
|
||
}
|
||
|
||
fn normalize_tool_name(value: &str) -> String {
|
||
value.trim().replace('-', "_").to_ascii_lowercase()
|
||
}
|
||
|
||
fn filter_tool_specs(allowed_tools: Option<&AllowedToolSet>) -> Vec<tools::ToolSpec> {
|
||
mvp_tool_specs()
|
||
.into_iter()
|
||
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
|
||
.collect()
|
||
}
|
||
|
||
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 print_version() {
|
||
println!("{}", render_version_report());
|
||
}
|
||
|
||
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))
|
||
}
|
||
|
||
#[allow(clippy::too_many_lines)]
|
||
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::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()),
|
||
}
|
||
}
|
||
|
||
fn run_repl(
|
||
model: String,
|
||
allowed_tools: Option<AllowedToolSet>,
|
||
) -> Result<(), Box<dyn std::error::Error>> {
|
||
let mut cli = LiveCli::new(model, true, allowed_tools)?;
|
||
let mut editor = input::LineEditor::new("› ", slash_command_completion_candidates());
|
||
println!("{}", cli.startup_banner());
|
||
|
||
loop {
|
||
match editor.read_line()? {
|
||
input::ReadOutcome::Submit(input) => {
|
||
let trimmed = input.trim().to_string();
|
||
if trimmed.is_empty() {
|
||
continue;
|
||
}
|
||
if matches!(trimmed.as_str(), "/exit" | "/quit") {
|
||
cli.persist_session()?;
|
||
break;
|
||
}
|
||
if let Some(command) = SlashCommand::parse(&trimmed) {
|
||
if cli.handle_repl_command(command)? {
|
||
cli.persist_session()?;
|
||
}
|
||
continue;
|
||
}
|
||
editor.push_history(input);
|
||
cli.run_turn(&trimmed)?;
|
||
}
|
||
input::ReadOutcome::Cancel => {}
|
||
input::ReadOutcome::Exit => {
|
||
cli.persist_session()?;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
allowed_tools: Option<AllowedToolSet>,
|
||
system_prompt: Vec<String>,
|
||
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
|
||
session: SessionHandle,
|
||
}
|
||
|
||
impl LiveCli {
|
||
fn new(
|
||
model: String,
|
||
enable_tools: bool,
|
||
allowed_tools: Option<AllowedToolSet>,
|
||
) -> 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,
|
||
allowed_tools.clone(),
|
||
)?;
|
||
let cli = Self {
|
||
model,
|
||
allowed_tools,
|
||
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>> {
|
||
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!();
|
||
self.persist_session()?;
|
||
Ok(())
|
||
}
|
||
Err(error) => {
|
||
spinner.fail(
|
||
"Claude request failed",
|
||
TerminalRenderer::new().color_theme(),
|
||
&mut stdout,
|
||
)?;
|
||
Err(Box::new(error))
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||
Ok(match command {
|
||
SlashCommand::Help => {
|
||
println!("{}", render_repl_help());
|
||
false
|
||
}
|
||
SlashCommand::Status => {
|
||
self.print_status();
|
||
false
|
||
}
|
||
SlashCommand::Compact => {
|
||
self.compact()?;
|
||
false
|
||
}
|
||
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();
|
||
false
|
||
}
|
||
SlashCommand::Resume { session_path } => self.resume_session(session_path)?,
|
||
SlashCommand::Config { section } => {
|
||
Self::print_config(section.as_deref())?;
|
||
false
|
||
}
|
||
SlashCommand::Memory => {
|
||
Self::print_memory()?;
|
||
false
|
||
}
|
||
SlashCommand::Init => {
|
||
Self::run_init()?;
|
||
false
|
||
}
|
||
SlashCommand::Diff => {
|
||
Self::print_diff()?;
|
||
false
|
||
}
|
||
SlashCommand::Version => {
|
||
Self::print_version();
|
||
false
|
||
}
|
||
SlashCommand::Export { path } => {
|
||
self.export_session(path.as_deref())?;
|
||
false
|
||
}
|
||
SlashCommand::Session { action, target } => {
|
||
self.handle_session_command(action.as_deref(), target.as_deref())?
|
||
}
|
||
SlashCommand::Unknown(name) => {
|
||
eprintln!("unknown slash command: /{name}");
|
||
false
|
||
}
|
||
})
|
||
}
|
||
|
||
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();
|
||
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(Some(&self.session.path)).expect("status context should load"),
|
||
)
|
||
);
|
||
}
|
||
|
||
fn set_model(&mut self, model: Option<String>) -> Result<bool, 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(false);
|
||
};
|
||
|
||
if model == self.model {
|
||
println!(
|
||
"{}",
|
||
format_model_report(
|
||
&self.model,
|
||
self.runtime.session().messages.len(),
|
||
self.runtime.usage().turns(),
|
||
)
|
||
);
|
||
return Ok(false);
|
||
}
|
||
|
||
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.allowed_tools.clone(),
|
||
)?;
|
||
self.model.clone_from(&model);
|
||
println!(
|
||
"{}",
|
||
format_model_switch_report(&previous, &model, message_count)
|
||
);
|
||
Ok(true)
|
||
}
|
||
|
||
fn set_permissions(
|
||
&mut self,
|
||
mode: Option<String>,
|
||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||
let Some(mode) = mode else {
|
||
println!("{}", format_permissions_report(permission_mode_label()));
|
||
return Ok(false);
|
||
};
|
||
|
||
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(false);
|
||
}
|
||
|
||
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,
|
||
self.allowed_tools.clone(),
|
||
normalized,
|
||
)?;
|
||
println!(
|
||
"{}",
|
||
format_permissions_switch_report(&previous, normalized)
|
||
);
|
||
Ok(true)
|
||
}
|
||
|
||
fn clear_session(&mut self, confirm: bool) -> Result<bool, Box<dyn std::error::Error>> {
|
||
if !confirm {
|
||
println!(
|
||
"clear: confirmation required; run /clear --confirm to start a fresh session."
|
||
);
|
||
return Ok(false);
|
||
}
|
||
|
||
self.session = create_managed_session_handle()?;
|
||
self.runtime = build_runtime_with_permission_mode(
|
||
Session::new(),
|
||
self.model.clone(),
|
||
self.system_prompt.clone(),
|
||
true,
|
||
self.allowed_tools.clone(),
|
||
permission_mode_label(),
|
||
)?;
|
||
println!(
|
||
"Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}",
|
||
self.model,
|
||
permission_mode_label(),
|
||
self.session.id,
|
||
);
|
||
Ok(true)
|
||
}
|
||
|
||
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<bool, Box<dyn std::error::Error>> {
|
||
let Some(session_ref) = session_path else {
|
||
println!("Usage: /resume <session-path>");
|
||
return Ok(false);
|
||
};
|
||
|
||
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,
|
||
self.model.clone(),
|
||
self.system_prompt.clone(),
|
||
true,
|
||
self.allowed_tools.clone(),
|
||
permission_mode_label(),
|
||
)?;
|
||
self.session = handle;
|
||
println!(
|
||
"{}",
|
||
format_resume_report(
|
||
&self.session.path.display().to_string(),
|
||
message_count,
|
||
self.runtime.usage().turns(),
|
||
)
|
||
);
|
||
Ok(true)
|
||
}
|
||
|
||
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 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<bool, Box<dyn std::error::Error>> {
|
||
match action {
|
||
None | Some("list") => {
|
||
println!("{}", render_session_list(&self.session.id)?);
|
||
Ok(false)
|
||
}
|
||
Some("switch") => {
|
||
let Some(target) = target else {
|
||
println!("Usage: /session switch <session-id>");
|
||
return Ok(false);
|
||
};
|
||
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,
|
||
self.allowed_tools.clone(),
|
||
permission_mode_label(),
|
||
)?;
|
||
self.session = handle;
|
||
println!(
|
||
"Session switched\n Active session {}\n File {}\n Messages {}",
|
||
self.session.id,
|
||
self.session.path.display(),
|
||
message_count,
|
||
);
|
||
Ok(true)
|
||
}
|
||
Some(other) => {
|
||
println!("Unknown /session action '{other}'. Use /session list or /session switch <session-id>.");
|
||
Ok(false)
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
self.allowed_tools.clone(),
|
||
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(),
|
||
" /exit Quit the REPL".to_string(),
|
||
" /quit Quit the REPL".to_string(),
|
||
" Up/Down Navigate prompt history".to_string(),
|
||
" Tab Complete slash commands".to_string(),
|
||
" Ctrl-C Clear input (or exit on empty prompt)".to_string(),
|
||
" Shift+Enter/Ctrl+J Insert a newline".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 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()?,
|
||
DEFAULT_DATE,
|
||
env::consts::OS,
|
||
"unknown",
|
||
)?)
|
||
}
|
||
|
||
fn build_runtime(
|
||
session: Session,
|
||
model: String,
|
||
system_prompt: Vec<String>,
|
||
enable_tools: bool,
|
||
allowed_tools: Option<AllowedToolSet>,
|
||
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
|
||
{
|
||
build_runtime_with_permission_mode(
|
||
session,
|
||
model,
|
||
system_prompt,
|
||
enable_tools,
|
||
allowed_tools,
|
||
permission_mode_label(),
|
||
)
|
||
}
|
||
|
||
fn build_runtime_with_permission_mode(
|
||
session: Session,
|
||
model: String,
|
||
system_prompt: Vec<String>,
|
||
enable_tools: bool,
|
||
allowed_tools: Option<AllowedToolSet>,
|
||
permission_mode: &str,
|
||
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
|
||
{
|
||
Ok(ConversationRuntime::new(
|
||
session,
|
||
AnthropicRuntimeClient::new(model, enable_tools, allowed_tools.clone())?,
|
||
CliToolExecutor::new(allowed_tools),
|
||
permission_policy(permission_mode),
|
||
system_prompt,
|
||
))
|
||
}
|
||
|
||
struct AnthropicRuntimeClient {
|
||
runtime: tokio::runtime::Runtime,
|
||
client: AnthropicClient,
|
||
model: String,
|
||
enable_tools: bool,
|
||
allowed_tools: Option<AllowedToolSet>,
|
||
}
|
||
|
||
impl AnthropicRuntimeClient {
|
||
fn new(
|
||
model: String,
|
||
enable_tools: bool,
|
||
allowed_tools: Option<AllowedToolSet>,
|
||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||
Ok(Self {
|
||
runtime: tokio::runtime::Runtime::new()?,
|
||
client: AnthropicClient::from_env()?,
|
||
model,
|
||
enable_tools,
|
||
allowed_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(|| {
|
||
filter_tool_specs(self.allowed_tools.as_ref())
|
||
.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 slash_command_completion_candidates() -> Vec<String> {
|
||
slash_command_specs()
|
||
.iter()
|
||
.map(|spec| format!("/{}", spec.name))
|
||
.collect()
|
||
}
|
||
|
||
fn format_tool_call_start(name: &str, input: &str) -> String {
|
||
format!(
|
||
"Tool call
|
||
Name {name}
|
||
Input {}",
|
||
summarize_tool_payload(input)
|
||
)
|
||
}
|
||
|
||
fn format_tool_result(name: &str, output: &str, is_error: bool) -> String {
|
||
let status = if is_error { "error" } else { "ok" };
|
||
format!(
|
||
"### Tool `{name}`
|
||
|
||
- Status: {status}
|
||
- Output:
|
||
|
||
```json
|
||
{}
|
||
```
|
||
",
|
||
prettify_tool_payload(output)
|
||
)
|
||
}
|
||
|
||
fn summarize_tool_payload(payload: &str) -> String {
|
||
let compact = match serde_json::from_str::<serde_json::Value>(payload) {
|
||
Ok(value) => value.to_string(),
|
||
Err(_) => payload.trim().to_string(),
|
||
};
|
||
truncate_for_summary(&compact, 96)
|
||
}
|
||
|
||
fn prettify_tool_payload(payload: &str) -> String {
|
||
match serde_json::from_str::<serde_json::Value>(payload) {
|
||
Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_else(|_| payload.to_string()),
|
||
Err(_) => payload.to_string(),
|
||
}
|
||
}
|
||
|
||
fn truncate_for_summary(value: &str, limit: usize) -> String {
|
||
let mut chars = value.chars();
|
||
let truncated = chars.by_ref().take(limit).collect::<String>();
|
||
if chars.next().is_some() {
|
||
format!("{truncated}…")
|
||
} else {
|
||
truncated
|
||
}
|
||
}
|
||
|
||
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 } => {
|
||
writeln!(
|
||
out,
|
||
"
|
||
{}",
|
||
format_tool_call_start(&name, &input.to_string())
|
||
)
|
||
.and_then(|()| out.flush())
|
||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||
*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,
|
||
allowed_tools: Option<AllowedToolSet>,
|
||
}
|
||
|
||
impl CliToolExecutor {
|
||
fn new(allowed_tools: Option<AllowedToolSet>) -> Self {
|
||
Self {
|
||
renderer: TerminalRenderer::new(),
|
||
allowed_tools,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl ToolExecutor for CliToolExecutor {
|
||
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
|
||
if self
|
||
.allowed_tools
|
||
.as_ref()
|
||
.is_some_and(|allowed| !allowed.contains(tool_name))
|
||
{
|
||
return Err(ToolError::new(format!(
|
||
"tool `{tool_name}` is not enabled by the current --allowedTools setting"
|
||
)));
|
||
}
|
||
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_result(tool_name, &output, false);
|
||
self.renderer
|
||
.stream_markdown(&markdown, &mut io::stdout())
|
||
.map_err(|error| ToolError::new(error.to_string()))?;
|
||
Ok(output)
|
||
}
|
||
Err(error) => {
|
||
let markdown = format_tool_result(tool_name, &error, true);
|
||
self.renderer
|
||
.stream_markdown(&markdown, &mut io::stdout())
|
||
.map_err(|stream_error| ToolError::new(stream_error.to_string()))?;
|
||
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() {
|
||
println!("rusty-claude-cli v{VERSION}");
|
||
println!();
|
||
println!("Usage:");
|
||
println!(" rusty-claude-cli [--model MODEL] [--allowedTools TOOL[,TOOL...]]");
|
||
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!(" --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)");
|
||
println!(" --version, -V Print version and build information locally");
|
||
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 {
|
||
Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
|
||
None => format!("/{}", spec.name),
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
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 --allowedTools read,glob \"summarize Cargo.toml\"");
|
||
println!(" rusty-claude-cli --resume session.json /status /diff /export notes.txt");
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{
|
||
filter_tool_specs, 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,
|
||
format_tool_call_start, format_tool_result, 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, CliOutputFormat, 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(),
|
||
allowed_tools: None,
|
||
}
|
||
);
|
||
}
|
||
|
||
#[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(),
|
||
output_format: CliOutputFormat::Text,
|
||
allowed_tools: None,
|
||
}
|
||
);
|
||
}
|
||
|
||
#[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,
|
||
allowed_tools: None,
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parses_version_flags_without_initializing_prompt_mode() {
|
||
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 parses_allowed_tools_flags_with_aliases_and_lists() {
|
||
let args = vec![
|
||
"--allowedTools".to_string(),
|
||
"read,glob".to_string(),
|
||
"--allowed-tools=write_file".to_string(),
|
||
];
|
||
assert_eq!(
|
||
parse_args(&args).expect("args should parse"),
|
||
CliAction::Repl {
|
||
model: DEFAULT_MODEL.to_string(),
|
||
allowed_tools: Some(
|
||
["glob_search", "read_file", "write_file"]
|
||
.into_iter()
|
||
.map(str::to_string)
|
||
.collect()
|
||
),
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_unknown_allowed_tools() {
|
||
let error = parse_args(&["--allowedTools".to_string(), "teleport".to_string()])
|
||
.expect_err("tool should be rejected");
|
||
assert!(error.contains("unsupported tool in --allowedTools: teleport"));
|
||
}
|
||
|
||
#[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 filtered_tool_specs_respect_allowlist() {
|
||
let allowed = ["read_file", "grep_search"]
|
||
.into_iter()
|
||
.map(str::to_string)
|
||
.collect();
|
||
let filtered = filter_tool_specs(Some(&allowed));
|
||
let names = filtered
|
||
.into_iter()
|
||
.map(|spec| spec.name)
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(names, vec!["read_file", "grep_search"]);
|
||
}
|
||
|
||
#[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 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("/diff"));
|
||
assert!(help.contains("/version"));
|
||
assert!(help.contains("/export [file]"));
|
||
assert!(help.contains("/session [list|switch <session-id>]"));
|
||
assert!(help.contains("/exit"));
|
||
}
|
||
|
||
#[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", "diff",
|
||
"version", "export",
|
||
]
|
||
);
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
#[test]
|
||
fn repl_help_mentions_history_completion_and_multiline() {
|
||
let help = render_repl_help();
|
||
assert!(help.contains("Up/Down"));
|
||
assert!(help.contains("Tab"));
|
||
assert!(help.contains("Shift+Enter/Ctrl+J"));
|
||
}
|
||
|
||
#[test]
|
||
fn tool_rendering_helpers_compact_output() {
|
||
let start = format_tool_call_start("read_file", r#"{"path":"src/main.rs"}"#);
|
||
assert!(start.contains("Tool call"));
|
||
assert!(start.contains("src/main.rs"));
|
||
|
||
let done = format_tool_result("read_file", r#"{"contents":"hello"}"#, false);
|
||
assert!(done.contains("Tool `read_file`"));
|
||
assert!(done.contains("contents"));
|
||
}
|
||
}
|