3220 lines
108 KiB
Rust
3220 lines
108 KiB
Rust
mod input;
|
||
mod render;
|
||
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
use std::env;
|
||
use std::fs;
|
||
use std::io::{self, Read, Write};
|
||
use std::net::TcpListener;
|
||
use std::path::{Path, PathBuf};
|
||
use std::process::Command;
|
||
use std::time::{SystemTime, UNIX_EPOCH};
|
||
|
||
use api::{
|
||
resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, ImageSource,
|
||
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::{
|
||
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
|
||
parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
|
||
AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
|
||
ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest,
|
||
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
|
||
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
||
};
|
||
use serde_json::json;
|
||
use tools::{execute_tool, mvp_tool_specs, ToolSpec};
|
||
|
||
const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
|
||
const DEFAULT_MAX_TOKENS: u32 = 32;
|
||
const DEFAULT_DATE: &str = "2026-03-31";
|
||
const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
|
||
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>;
|
||
const IMAGE_REF_PREFIX: &str = "@";
|
||
|
||
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,
|
||
permission_mode,
|
||
} => LiveCli::new(model, false, allowed_tools, permission_mode)?
|
||
.run_turn_with_output(&prompt, output_format)?,
|
||
CliAction::Login => run_login()?,
|
||
CliAction::Logout => run_logout()?,
|
||
CliAction::Repl {
|
||
model,
|
||
allowed_tools,
|
||
permission_mode,
|
||
} => run_repl(model, allowed_tools, permission_mode)?,
|
||
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>,
|
||
permission_mode: PermissionMode,
|
||
},
|
||
Login,
|
||
Logout,
|
||
Repl {
|
||
model: String,
|
||
allowed_tools: Option<AllowedToolSet>,
|
||
permission_mode: PermissionMode,
|
||
},
|
||
// 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)"
|
||
)),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[allow(clippy::too_many_lines)]
|
||
fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||
let mut model = DEFAULT_MODEL.to_string();
|
||
let mut output_format = CliOutputFormat::Text;
|
||
let mut permission_mode = default_permission_mode();
|
||
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;
|
||
}
|
||
"--permission-mode" => {
|
||
let value = args
|
||
.get(index + 1)
|
||
.ok_or_else(|| "missing value for --permission-mode".to_string())?;
|
||
permission_mode = parse_permission_mode_arg(value)?;
|
||
index += 2;
|
||
}
|
||
flag if flag.starts_with("--output-format=") => {
|
||
output_format = CliOutputFormat::parse(&flag[16..])?;
|
||
index += 1;
|
||
}
|
||
flag if flag.starts_with("--permission-mode=") => {
|
||
permission_mode = parse_permission_mode_arg(&flag[18..])?;
|
||
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,
|
||
permission_mode,
|
||
});
|
||
}
|
||
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..]),
|
||
"login" => Ok(CliAction::Login),
|
||
"logout" => Ok(CliAction::Logout),
|
||
"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,
|
||
permission_mode,
|
||
})
|
||
}
|
||
other if !other.starts_with('/') => Ok(CliAction::Prompt {
|
||
prompt: rest.join(" "),
|
||
model,
|
||
output_format,
|
||
allowed_tools,
|
||
permission_mode,
|
||
}),
|
||
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 parse_permission_mode_arg(value: &str) -> Result<PermissionMode, String> {
|
||
normalize_permission_mode(value)
|
||
.ok_or_else(|| {
|
||
format!(
|
||
"unsupported permission mode '{value}'. Use read-only, workspace-write, or danger-full-access."
|
||
)
|
||
})
|
||
.map(permission_mode_from_label)
|
||
}
|
||
|
||
fn permission_mode_from_label(mode: &str) -> PermissionMode {
|
||
match mode {
|
||
"read-only" => PermissionMode::ReadOnly,
|
||
"workspace-write" => PermissionMode::WorkspaceWrite,
|
||
"danger-full-access" => PermissionMode::DangerFullAccess,
|
||
other => panic!("unsupported permission mode label: {other}"),
|
||
}
|
||
}
|
||
|
||
fn default_permission_mode() -> PermissionMode {
|
||
env::var("RUSTY_CLAUDE_PERMISSION_MODE")
|
||
.ok()
|
||
.as_deref()
|
||
.and_then(normalize_permission_mode)
|
||
.map_or(PermissionMode::WorkspaceWrite, permission_mode_from_label)
|
||
}
|
||
|
||
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 run_login() -> Result<(), Box<dyn std::error::Error>> {
|
||
let cwd = env::current_dir()?;
|
||
let config = ConfigLoader::default_for(&cwd).load()?;
|
||
let oauth = config.oauth().ok_or_else(|| {
|
||
io::Error::new(
|
||
io::ErrorKind::NotFound,
|
||
"OAuth config is missing. Add settings.oauth.clientId/authorizeUrl/tokenUrl first.",
|
||
)
|
||
})?;
|
||
let callback_port = oauth.callback_port.unwrap_or(DEFAULT_OAUTH_CALLBACK_PORT);
|
||
let redirect_uri = runtime::loopback_redirect_uri(callback_port);
|
||
let pkce = generate_pkce_pair()?;
|
||
let state = generate_state()?;
|
||
let authorize_url =
|
||
OAuthAuthorizationRequest::from_config(oauth, redirect_uri.clone(), state.clone(), &pkce)
|
||
.build_url();
|
||
|
||
println!("Starting Claude OAuth login...");
|
||
println!("Listening for callback on {redirect_uri}");
|
||
if let Err(error) = open_browser(&authorize_url) {
|
||
eprintln!("warning: failed to open browser automatically: {error}");
|
||
println!("Open this URL manually:\n{authorize_url}");
|
||
}
|
||
|
||
let callback = wait_for_oauth_callback(callback_port)?;
|
||
if let Some(error) = callback.error {
|
||
let description = callback
|
||
.error_description
|
||
.unwrap_or_else(|| "authorization failed".to_string());
|
||
return Err(io::Error::other(format!("{error}: {description}")).into());
|
||
}
|
||
let code = callback.code.ok_or_else(|| {
|
||
io::Error::new(io::ErrorKind::InvalidData, "callback did not include code")
|
||
})?;
|
||
let returned_state = callback.state.ok_or_else(|| {
|
||
io::Error::new(io::ErrorKind::InvalidData, "callback did not include state")
|
||
})?;
|
||
if returned_state != state {
|
||
return Err(io::Error::new(io::ErrorKind::InvalidData, "oauth state mismatch").into());
|
||
}
|
||
|
||
let client = AnthropicClient::from_auth(AuthSource::None);
|
||
let exchange_request =
|
||
OAuthTokenExchangeRequest::from_config(oauth, code, state, pkce.verifier, redirect_uri);
|
||
let runtime = tokio::runtime::Runtime::new()?;
|
||
let token_set = runtime.block_on(client.exchange_oauth_code(oauth, &exchange_request))?;
|
||
save_oauth_credentials(&runtime::OAuthTokenSet {
|
||
access_token: token_set.access_token,
|
||
refresh_token: token_set.refresh_token,
|
||
expires_at: token_set.expires_at,
|
||
scopes: token_set.scopes,
|
||
})?;
|
||
println!("Claude OAuth login complete.");
|
||
Ok(())
|
||
}
|
||
|
||
fn run_logout() -> Result<(), Box<dyn std::error::Error>> {
|
||
clear_oauth_credentials()?;
|
||
println!("Claude OAuth credentials cleared.");
|
||
Ok(())
|
||
}
|
||
|
||
fn open_browser(url: &str) -> io::Result<()> {
|
||
let commands = if cfg!(target_os = "macos") {
|
||
vec![("open", vec![url])]
|
||
} else if cfg!(target_os = "windows") {
|
||
vec![("cmd", vec!["/C", "start", "", url])]
|
||
} else {
|
||
vec![("xdg-open", vec![url])]
|
||
};
|
||
for (program, args) in commands {
|
||
match Command::new(program).args(args).spawn() {
|
||
Ok(_) => return Ok(()),
|
||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||
Err(error) => return Err(error),
|
||
}
|
||
}
|
||
Err(io::Error::new(
|
||
io::ErrorKind::NotFound,
|
||
"no supported browser opener command found",
|
||
))
|
||
}
|
||
|
||
fn wait_for_oauth_callback(
|
||
port: u16,
|
||
) -> Result<runtime::OAuthCallbackParams, Box<dyn std::error::Error>> {
|
||
let listener = TcpListener::bind(("127.0.0.1", port))?;
|
||
let (mut stream, _) = listener.accept()?;
|
||
let mut buffer = [0_u8; 4096];
|
||
let bytes_read = stream.read(&mut buffer)?;
|
||
let request = String::from_utf8_lossy(&buffer[..bytes_read]);
|
||
let request_line = request.lines().next().ok_or_else(|| {
|
||
io::Error::new(io::ErrorKind::InvalidData, "missing callback request line")
|
||
})?;
|
||
let target = request_line.split_whitespace().nth(1).ok_or_else(|| {
|
||
io::Error::new(
|
||
io::ErrorKind::InvalidData,
|
||
"missing callback request target",
|
||
)
|
||
})?;
|
||
let callback = parse_oauth_callback_request_target(target)
|
||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
||
let body = if callback.error.is_some() {
|
||
"Claude OAuth login failed. You can close this window."
|
||
} else {
|
||
"Claude OAuth login succeeded. You can close this window."
|
||
};
|
||
let response = format!(
|
||
"HTTP/1.1 200 OK\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||
body.len(),
|
||
body
|
||
);
|
||
stream.write_all(response.as_bytes())?;
|
||
Ok(callback)
|
||
}
|
||
|
||
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,
|
||
},
|
||
default_permission_mode().as_str(),
|
||
&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>,
|
||
permission_mode: PermissionMode,
|
||
) -> Result<(), Box<dyn std::error::Error>> {
|
||
let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?;
|
||
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>,
|
||
permission_mode: PermissionMode,
|
||
system_prompt: Vec<String>,
|
||
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
|
||
session: SessionHandle,
|
||
}
|
||
|
||
impl LiveCli {
|
||
fn new(
|
||
model: String,
|
||
enable_tools: bool,
|
||
allowed_tools: Option<AllowedToolSet>,
|
||
permission_mode: PermissionMode,
|
||
) -> 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(),
|
||
permission_mode,
|
||
)?;
|
||
let cli = Self {
|
||
model,
|
||
allowed_tools,
|
||
permission_mode,
|
||
system_prompt,
|
||
runtime,
|
||
session,
|
||
};
|
||
cli.persist_session()?;
|
||
Ok(cli)
|
||
}
|
||
|
||
fn startup_banner(&self) -> String {
|
||
format!(
|
||
"Rusty Claude CLI\n Model {}\n Permission mode {}\n Working directory {}\n Session {}\n\nType /help for commands. Shift+Enter or Ctrl+J inserts a newline.",
|
||
self.model,
|
||
self.permission_mode.as_str(),
|
||
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 mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||
let result = self.runtime.run_turn(input, Some(&mut permission_prompter));
|
||
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_auth(resolve_cli_auth_source()?);
|
||
let request = MessageRequest {
|
||
model: self.model.clone(),
|
||
max_tokens: DEFAULT_MAX_TOKENS,
|
||
messages: vec![InputMessage {
|
||
role: "user".to_string(),
|
||
content: prompt_to_content_blocks(input, &env::current_dir()?)?,
|
||
}],
|
||
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(),
|
||
},
|
||
self.permission_mode.as_str(),
|
||
&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.permission_mode,
|
||
)?;
|
||
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(self.permission_mode.as_str())
|
||
);
|
||
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 == self.permission_mode.as_str() {
|
||
println!("{}", format_permissions_report(normalized));
|
||
return Ok(false);
|
||
}
|
||
|
||
let previous = self.permission_mode.as_str().to_string();
|
||
let session = self.runtime.session().clone();
|
||
self.permission_mode = permission_mode_from_label(normalized);
|
||
self.runtime = build_runtime(
|
||
session,
|
||
self.model.clone(),
|
||
self.system_prompt.clone(),
|
||
true,
|
||
self.allowed_tools.clone(),
|
||
self.permission_mode,
|
||
)?;
|
||
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(
|
||
Session::new(),
|
||
self.model.clone(),
|
||
self.system_prompt.clone(),
|
||
true,
|
||
self.allowed_tools.clone(),
|
||
self.permission_mode,
|
||
)?;
|
||
println!(
|
||
"Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}",
|
||
self.model,
|
||
self.permission_mode.as_str(),
|
||
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(
|
||
session,
|
||
self.model.clone(),
|
||
self.system_prompt.clone(),
|
||
true,
|
||
self.allowed_tools.clone(),
|
||
self.permission_mode,
|
||
)?;
|
||
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(
|
||
session,
|
||
self.model.clone(),
|
||
self.system_prompt.clone(),
|
||
true,
|
||
self.allowed_tools.clone(),
|
||
self.permission_mode,
|
||
)?;
|
||
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(
|
||
result.compacted_session,
|
||
self.model.clone(),
|
||
self.system_prompt.clone(),
|
||
true,
|
||
self.allowed_tools.clone(),
|
||
self.permission_mode,
|
||
)?;
|
||
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 discovered_config_files = discovered_config_files.max(runtime_config.loaded_entries().len());
|
||
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_context.memory_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 {}
|
||
Project memory files {}",
|
||
cwd.display(),
|
||
project_context.instruction_files.len(),
|
||
project_context.memory_files.len()
|
||
)];
|
||
append_memory_section(
|
||
&mut lines,
|
||
"Instruction files",
|
||
&project_context.instruction_files,
|
||
"No CLAUDE instruction files discovered in the current directory ancestry.",
|
||
);
|
||
append_memory_section(
|
||
&mut lines,
|
||
"Project memory files",
|
||
&project_context.memory_files,
|
||
"No persisted project memory files discovered in .claude/memory.",
|
||
);
|
||
Ok(lines.join(
|
||
"
|
||
",
|
||
))
|
||
}
|
||
|
||
fn append_memory_section(
|
||
lines: &mut Vec<String>,
|
||
title: &str,
|
||
files: &[runtime::ContextFile],
|
||
empty_message: &str,
|
||
) {
|
||
lines.push(title.to_string());
|
||
if files.is_empty() {
|
||
lines.push(format!(" {empty_message}"));
|
||
return;
|
||
}
|
||
|
||
for (index, file) in 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
|
||
));
|
||
}
|
||
}
|
||
|
||
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 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>,
|
||
permission_mode: PermissionMode,
|
||
) -> 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 CliPermissionPrompter {
|
||
current_mode: PermissionMode,
|
||
}
|
||
|
||
impl CliPermissionPrompter {
|
||
fn new(current_mode: PermissionMode) -> Self {
|
||
Self { current_mode }
|
||
}
|
||
}
|
||
|
||
impl runtime::PermissionPrompter for CliPermissionPrompter {
|
||
fn decide(
|
||
&mut self,
|
||
request: &runtime::PermissionRequest,
|
||
) -> runtime::PermissionPromptDecision {
|
||
println!();
|
||
println!("Permission approval required");
|
||
println!(" Tool {}", request.tool_name);
|
||
println!(" Current mode {}", self.current_mode.as_str());
|
||
println!(" Required mode {}", request.required_mode.as_str());
|
||
println!(" Input {}", request.input);
|
||
print!("Approve this tool call? [y/N]: ");
|
||
let _ = io::stdout().flush();
|
||
|
||
let mut response = String::new();
|
||
match io::stdin().read_line(&mut response) {
|
||
Ok(_) => {
|
||
let normalized = response.trim().to_ascii_lowercase();
|
||
if matches!(normalized.as_str(), "y" | "yes") {
|
||
runtime::PermissionPromptDecision::Allow
|
||
} else {
|
||
runtime::PermissionPromptDecision::Deny {
|
||
reason: format!(
|
||
"tool '{}' denied by user approval prompt",
|
||
request.tool_name
|
||
),
|
||
}
|
||
}
|
||
}
|
||
Err(error) => runtime::PermissionPromptDecision::Deny {
|
||
reason: format!("permission approval failed: {error}"),
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
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_auth(resolve_cli_auth_source()?),
|
||
model,
|
||
enable_tools,
|
||
allowed_tools,
|
||
})
|
||
}
|
||
}
|
||
|
||
fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
|
||
Ok(resolve_startup_auth_source(|| {
|
||
let cwd = env::current_dir().map_err(api::ApiError::from)?;
|
||
let config = ConfigLoader::default_for(&cwd).load().map_err(|error| {
|
||
api::ApiError::Auth(format!("failed to load runtime OAuth config: {error}"))
|
||
})?;
|
||
Ok(config.oauth().cloned())
|
||
})?)
|
||
}
|
||
|
||
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: PermissionMode) -> PermissionPolicy {
|
||
tool_permission_specs()
|
||
.into_iter()
|
||
.fold(PermissionPolicy::new(mode), |policy, spec| {
|
||
policy.with_tool_requirement(spec.name, spec.required_permission)
|
||
})
|
||
}
|
||
|
||
fn tool_permission_specs() -> Vec<ToolSpec> {
|
||
mvp_tool_specs()
|
||
}
|
||
|
||
fn convert_messages(messages: &[ConversationMessage]) -> Result<Vec<InputMessage>, RuntimeError> {
|
||
let cwd = env::current_dir().map_err(|error| {
|
||
RuntimeError::new(format!("failed to resolve current directory: {error}"))
|
||
})?;
|
||
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()
|
||
.try_fold(Vec::new(), |mut acc, block| {
|
||
match block {
|
||
ContentBlock::Text { text } => {
|
||
if message.role == MessageRole::User {
|
||
acc.extend(
|
||
prompt_to_content_blocks(text, &cwd)
|
||
.map_err(RuntimeError::new)?,
|
||
);
|
||
} else {
|
||
acc.push(InputContentBlock::Text { text: text.clone() });
|
||
}
|
||
}
|
||
ContentBlock::ToolUse { id, name, input } => {
|
||
acc.push(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,
|
||
..
|
||
} => acc.push(InputContentBlock::ToolResult {
|
||
tool_use_id: tool_use_id.clone(),
|
||
content: vec![ToolResultContentBlock::Text {
|
||
text: output.clone(),
|
||
}],
|
||
is_error: *is_error,
|
||
}),
|
||
}
|
||
Ok::<_, RuntimeError>(acc)
|
||
});
|
||
match content {
|
||
Ok(content) if !content.is_empty() => Some(Ok(InputMessage {
|
||
role: role.to_string(),
|
||
content,
|
||
})),
|
||
Ok(_) => None,
|
||
Err(error) => Some(Err(error)),
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn prompt_to_content_blocks(input: &str, cwd: &Path) -> Result<Vec<InputContentBlock>, String> {
|
||
let mut blocks = Vec::new();
|
||
let mut text_buffer = String::new();
|
||
let mut chars = input.char_indices().peekable();
|
||
|
||
while let Some((index, ch)) = chars.next() {
|
||
if ch == '!' && input[index..].starts_with("![") {
|
||
if let Some((alt_end, path_start, path_end)) = parse_markdown_image_ref(input, index) {
|
||
let _ = alt_end;
|
||
flush_text_block(&mut blocks, &mut text_buffer);
|
||
let path = &input[path_start..path_end];
|
||
blocks.push(load_image_block(path, cwd)?);
|
||
while let Some((next_index, _)) = chars.peek() {
|
||
if *next_index < path_end + 1 {
|
||
let _ = chars.next();
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if ch == '@' && is_ref_boundary(input[..index].chars().next_back()) {
|
||
let path_end = find_path_end(input, index + 1);
|
||
if path_end > index + 1 {
|
||
let candidate = &input[index + 1..path_end];
|
||
if looks_like_image_ref(candidate, cwd) {
|
||
flush_text_block(&mut blocks, &mut text_buffer);
|
||
blocks.push(load_image_block(candidate, cwd)?);
|
||
while let Some((next_index, _)) = chars.peek() {
|
||
if *next_index < path_end {
|
||
let _ = chars.next();
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
text_buffer.push(ch);
|
||
}
|
||
|
||
flush_text_block(&mut blocks, &mut text_buffer);
|
||
if blocks.is_empty() {
|
||
blocks.push(InputContentBlock::Text {
|
||
text: input.to_string(),
|
||
});
|
||
}
|
||
Ok(blocks)
|
||
}
|
||
|
||
fn parse_markdown_image_ref(input: &str, start: usize) -> Option<(usize, usize, usize)> {
|
||
let after_bang = input.get(start + 2..)?;
|
||
let alt_end_offset = after_bang.find("](")?;
|
||
let path_start = start + 2 + alt_end_offset + 2;
|
||
let remainder = input.get(path_start..)?;
|
||
let path_end_offset = remainder.find(')')?;
|
||
let path_end = path_start + path_end_offset;
|
||
Some((start + 2 + alt_end_offset, path_start, path_end))
|
||
}
|
||
|
||
fn is_ref_boundary(ch: Option<char>) -> bool {
|
||
ch.is_none_or(char::is_whitespace)
|
||
}
|
||
|
||
fn find_path_end(input: &str, start: usize) -> usize {
|
||
input[start..]
|
||
.char_indices()
|
||
.find_map(|(offset, ch)| (ch.is_whitespace()).then_some(start + offset))
|
||
.unwrap_or(input.len())
|
||
}
|
||
|
||
fn looks_like_image_ref(candidate: &str, cwd: &Path) -> bool {
|
||
let resolved = resolve_prompt_path(candidate, cwd);
|
||
media_type_for_path(Path::new(candidate)).is_some()
|
||
|| resolved.is_file()
|
||
|| candidate.contains(std::path::MAIN_SEPARATOR)
|
||
|| candidate.starts_with("./")
|
||
|| candidate.starts_with("../")
|
||
}
|
||
|
||
fn flush_text_block(blocks: &mut Vec<InputContentBlock>, text_buffer: &mut String) {
|
||
if text_buffer.is_empty() {
|
||
return;
|
||
}
|
||
blocks.push(InputContentBlock::Text {
|
||
text: std::mem::take(text_buffer),
|
||
});
|
||
}
|
||
|
||
fn load_image_block(path_ref: &str, cwd: &Path) -> Result<InputContentBlock, String> {
|
||
let resolved = resolve_prompt_path(path_ref, cwd);
|
||
let media_type = media_type_for_path(&resolved).ok_or_else(|| {
|
||
format!(
|
||
"unsupported image format for reference {IMAGE_REF_PREFIX}{path_ref}; supported: png, jpg, jpeg, gif, webp"
|
||
)
|
||
})?;
|
||
let bytes = fs::read(&resolved).map_err(|error| {
|
||
format!(
|
||
"failed to read image reference {}: {error}",
|
||
resolved.display()
|
||
)
|
||
})?;
|
||
Ok(InputContentBlock::Image {
|
||
source: ImageSource {
|
||
kind: "base64".to_string(),
|
||
media_type: media_type.to_string(),
|
||
data: encode_base64(&bytes),
|
||
},
|
||
})
|
||
}
|
||
|
||
fn resolve_prompt_path(path_ref: &str, cwd: &Path) -> PathBuf {
|
||
let path = Path::new(path_ref);
|
||
if path.is_absolute() {
|
||
path.to_path_buf()
|
||
} else {
|
||
cwd.join(path)
|
||
}
|
||
}
|
||
|
||
fn media_type_for_path(path: &Path) -> Option<&'static str> {
|
||
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
|
||
match extension.as_str() {
|
||
"png" => Some("image/png"),
|
||
"jpg" | "jpeg" => Some("image/jpeg"),
|
||
"gif" => Some("image/gif"),
|
||
"webp" => Some("image/webp"),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn encode_base64(bytes: &[u8]) -> String {
|
||
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||
let mut output = String::new();
|
||
let mut index = 0;
|
||
while index + 3 <= bytes.len() {
|
||
let block = (u32::from(bytes[index]) << 16)
|
||
| (u32::from(bytes[index + 1]) << 8)
|
||
| u32::from(bytes[index + 2]);
|
||
output.push(TABLE[((block >> 18) & 0x3F) as usize] as char);
|
||
output.push(TABLE[((block >> 12) & 0x3F) as usize] as char);
|
||
output.push(TABLE[((block >> 6) & 0x3F) as usize] as char);
|
||
output.push(TABLE[(block & 0x3F) as usize] as char);
|
||
index += 3;
|
||
}
|
||
|
||
match bytes.len().saturating_sub(index) {
|
||
1 => {
|
||
let block = u32::from(bytes[index]) << 16;
|
||
output.push(TABLE[((block >> 18) & 0x3F) as usize] as char);
|
||
output.push(TABLE[((block >> 12) & 0x3F) as usize] as char);
|
||
output.push('=');
|
||
output.push('=');
|
||
}
|
||
2 => {
|
||
let block = (u32::from(bytes[index]) << 16) | (u32::from(bytes[index + 1]) << 8);
|
||
output.push(TABLE[((block >> 18) & 0x3F) as usize] as char);
|
||
output.push(TABLE[((block >> 12) & 0x3F) as usize] as char);
|
||
output.push(TABLE[((block >> 6) & 0x3F) as usize] as char);
|
||
output.push('=');
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
output
|
||
}
|
||
|
||
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!(" rusty-claude-cli login");
|
||
println!(" rusty-claude-cli logout");
|
||
println!();
|
||
println!("Flags:");
|
||
println!(" --model MODEL Override the active model");
|
||
println!(" --output-format FORMAT Non-interactive output format: text or json");
|
||
println!(" --permission-mode MODE Set read-only, workspace-write, or danger-full-access");
|
||
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");
|
||
println!(" rusty-claude-cli login");
|
||
}
|
||
|
||
#[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 api::InputContentBlock;
|
||
use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode};
|
||
use std::path::{Path, PathBuf};
|
||
use std::time::{SystemTime, UNIX_EPOCH};
|
||
|
||
#[test]
|
||
fn defaults_to_repl_when_no_args() {
|
||
assert_eq!(
|
||
parse_args(&[]).expect("args should parse"),
|
||
CliAction::Repl {
|
||
model: DEFAULT_MODEL.to_string(),
|
||
allowed_tools: None,
|
||
permission_mode: PermissionMode::WorkspaceWrite,
|
||
}
|
||
);
|
||
}
|
||
|
||
#[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,
|
||
permission_mode: PermissionMode::WorkspaceWrite,
|
||
}
|
||
);
|
||
}
|
||
|
||
#[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,
|
||
permission_mode: PermissionMode::WorkspaceWrite,
|
||
}
|
||
);
|
||
}
|
||
|
||
#[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_permission_mode_flag() {
|
||
let args = vec!["--permission-mode=read-only".to_string()];
|
||
assert_eq!(
|
||
parse_args(&args).expect("args should parse"),
|
||
CliAction::Repl {
|
||
model: DEFAULT_MODEL.to_string(),
|
||
allowed_tools: None,
|
||
permission_mode: PermissionMode::ReadOnly,
|
||
}
|
||
);
|
||
}
|
||
|
||
#[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()
|
||
),
|
||
permission_mode: PermissionMode::WorkspaceWrite,
|
||
}
|
||
);
|
||
}
|
||
|
||
#[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_login_and_logout_subcommands() {
|
||
assert_eq!(
|
||
parse_args(&["login".to_string()]).expect("login should parse"),
|
||
CliAction::Login
|
||
);
|
||
assert_eq!(
|
||
parse_args(&["logout".to_string()]).expect("logout should parse"),
|
||
CliAction::Logout
|
||
);
|
||
}
|
||
|
||
#[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("Project memory 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!(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).expect("messages should convert");
|
||
assert_eq!(converted.len(), 3);
|
||
assert_eq!(converted[1].role, "assistant");
|
||
assert_eq!(converted[2].role, "user");
|
||
}
|
||
|
||
#[test]
|
||
fn prompt_to_content_blocks_keeps_text_only_prompt() {
|
||
let blocks = super::prompt_to_content_blocks("hello world", Path::new("."))
|
||
.expect("text prompt should parse");
|
||
assert_eq!(
|
||
blocks,
|
||
vec![InputContentBlock::Text {
|
||
text: "hello world".to_string()
|
||
}]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn prompt_to_content_blocks_embeds_at_image_refs() {
|
||
let temp = temp_fixture_dir("at-image-ref");
|
||
let image_path = temp.join("sample.png");
|
||
std::fs::write(&image_path, [1_u8, 2, 3]).expect("fixture write");
|
||
let prompt = format!("describe @{} please", image_path.display());
|
||
|
||
let blocks = super::prompt_to_content_blocks(&prompt, Path::new("."))
|
||
.expect("image ref should parse");
|
||
|
||
assert!(matches!(
|
||
&blocks[0],
|
||
InputContentBlock::Text { text } if text == "describe "
|
||
));
|
||
assert!(matches!(
|
||
&blocks[1],
|
||
InputContentBlock::Image { source }
|
||
if source.kind == "base64"
|
||
&& source.media_type == "image/png"
|
||
&& source.data == "AQID"
|
||
));
|
||
assert!(matches!(
|
||
&blocks[2],
|
||
InputContentBlock::Text { text } if text == " please"
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn prompt_to_content_blocks_embeds_markdown_image_refs() {
|
||
let temp = temp_fixture_dir("markdown-image-ref");
|
||
let image_path = temp.join("sample.webp");
|
||
std::fs::write(&image_path, [255_u8]).expect("fixture write");
|
||
let prompt = format!("see  now", image_path.display());
|
||
|
||
let blocks = super::prompt_to_content_blocks(&prompt, Path::new("."))
|
||
.expect("markdown image ref should parse");
|
||
|
||
assert!(matches!(
|
||
&blocks[1],
|
||
InputContentBlock::Image { source }
|
||
if source.media_type == "image/webp" && source.data == "/w=="
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn prompt_to_content_blocks_rejects_unsupported_formats() {
|
||
let temp = temp_fixture_dir("unsupported-image-ref");
|
||
let image_path = temp.join("sample.bmp");
|
||
std::fs::write(&image_path, [1_u8]).expect("fixture write");
|
||
let prompt = format!("describe @{}", image_path.display());
|
||
|
||
let error = super::prompt_to_content_blocks(&prompt, Path::new("."))
|
||
.expect_err("unsupported image ref should fail");
|
||
|
||
assert!(error.contains("unsupported image format"));
|
||
}
|
||
|
||
#[test]
|
||
fn convert_messages_expands_user_text_image_refs() {
|
||
let temp = temp_fixture_dir("convert-message-image-ref");
|
||
let image_path = temp.join("sample.gif");
|
||
std::fs::write(&image_path, [71_u8, 73, 70]).expect("fixture write");
|
||
let messages = vec![ConversationMessage::user_text(format!(
|
||
"inspect @{}",
|
||
image_path.display()
|
||
))];
|
||
|
||
let converted = super::convert_messages(&messages).expect("messages should convert");
|
||
|
||
assert_eq!(converted.len(), 1);
|
||
assert!(matches!(
|
||
&converted[0].content[1],
|
||
InputContentBlock::Image { source }
|
||
if source.media_type == "image/gif" && source.data == "R0lG"
|
||
));
|
||
}
|
||
|
||
fn temp_fixture_dir(label: &str) -> PathBuf {
|
||
let unique = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.expect("clock should advance")
|
||
.as_nanos();
|
||
let path = std::env::temp_dir().join(format!("rusty-claude-cli-{label}-{unique}"));
|
||
std::fs::create_dir_all(&path).expect("temp dir should exist");
|
||
path
|
||
}
|
||
#[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"));
|
||
}
|
||
}
|