| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416 |
- mod input;
- mod render;
- use std::env;
- use std::fs;
- use std::io::{self, Write};
- use std::path::{Path, PathBuf};
- use api::{
- AnthropicClient, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
- MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
- ToolResultContentBlock,
- };
- use commands::{
- handle_slash_command, render_slash_command_help, resume_supported_slash_commands, SlashCommand,
- };
- use compat_harness::{extract_manifest, UpstreamPaths};
- use render::{Spinner, TerminalRenderer};
- use runtime::{
- load_system_prompt, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader,
- ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole,
- PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, Session, TokenUsage, ToolError,
- ToolExecutor, UsageTracker,
- };
- use tools::{execute_tool, mvp_tool_specs};
- const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
- const DEFAULT_MAX_TOKENS: u32 = 32;
- const DEFAULT_DATE: &str = "2026-03-31";
- fn main() {
- if let Err(error) = run() {
- eprintln!("{error}");
- std::process::exit(1);
- }
- }
- fn run() -> Result<(), Box<dyn std::error::Error>> {
- let args: Vec<String> = env::args().skip(1).collect();
- match parse_args(&args)? {
- CliAction::DumpManifests => dump_manifests(),
- CliAction::BootstrapPlan => print_bootstrap_plan(),
- CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
- CliAction::ResumeSession {
- session_path,
- commands,
- } => resume_session(&session_path, &commands),
- CliAction::Prompt { prompt, model } => LiveCli::new(model, false)?.run_turn(&prompt)?,
- CliAction::Repl { model } => run_repl(model)?,
- CliAction::Help => print_help(),
- }
- Ok(())
- }
- #[derive(Debug, Clone, PartialEq, Eq)]
- enum CliAction {
- DumpManifests,
- BootstrapPlan,
- PrintSystemPrompt {
- cwd: PathBuf,
- date: String,
- },
- ResumeSession {
- session_path: PathBuf,
- commands: Vec<String>,
- },
- Prompt {
- prompt: String,
- model: String,
- },
- Repl {
- model: String,
- },
- Help,
- }
- fn parse_args(args: &[String]) -> Result<CliAction, String> {
- let mut model = DEFAULT_MODEL.to_string();
- let mut rest = Vec::new();
- let mut index = 0;
- while index < args.len() {
- match args[index].as_str() {
- "--model" => {
- let value = args
- .get(index + 1)
- .ok_or_else(|| "missing value for --model".to_string())?;
- model.clone_from(value);
- index += 2;
- }
- flag if flag.starts_with("--model=") => {
- model = flag[8..].to_string();
- index += 1;
- }
- other => {
- rest.push(other.to_string());
- index += 1;
- }
- }
- }
- if rest.is_empty() {
- return Ok(CliAction::Repl { model });
- }
- if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
- return Ok(CliAction::Help);
- }
- if rest.first().map(String::as_str) == Some("--resume") {
- return parse_resume_args(&rest[1..]);
- }
- match rest[0].as_str() {
- "dump-manifests" => Ok(CliAction::DumpManifests),
- "bootstrap-plan" => Ok(CliAction::BootstrapPlan),
- "system-prompt" => parse_system_prompt_args(&rest[1..]),
- "prompt" => {
- let prompt = rest[1..].join(" ");
- if prompt.trim().is_empty() {
- return Err("prompt subcommand requires a prompt string".to_string());
- }
- Ok(CliAction::Prompt { prompt, model })
- }
- other => Err(format!("unknown subcommand: {other}")),
- }
- }
- fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
- let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
- let mut date = DEFAULT_DATE.to_string();
- let mut index = 0;
- while index < args.len() {
- match args[index].as_str() {
- "--cwd" => {
- let value = args
- .get(index + 1)
- .ok_or_else(|| "missing value for --cwd".to_string())?;
- cwd = PathBuf::from(value);
- index += 2;
- }
- "--date" => {
- let value = args
- .get(index + 1)
- .ok_or_else(|| "missing value for --date".to_string())?;
- date.clone_from(value);
- index += 2;
- }
- other => return Err(format!("unknown system-prompt option: {other}")),
- }
- }
- Ok(CliAction::PrintSystemPrompt { cwd, date })
- }
- fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
- let session_path = args
- .first()
- .ok_or_else(|| "missing session path for --resume".to_string())
- .map(PathBuf::from)?;
- let commands = args[1..].to_vec();
- if commands
- .iter()
- .any(|command| !command.trim_start().starts_with('/'))
- {
- return Err("--resume trailing arguments must be slash commands".to_string());
- }
- Ok(CliAction::ResumeSession {
- session_path,
- commands,
- })
- }
- fn dump_manifests() {
- let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
- let paths = UpstreamPaths::from_workspace_dir(&workspace_dir);
- match extract_manifest(&paths) {
- Ok(manifest) => {
- println!("commands: {}", manifest.commands.entries().len());
- println!("tools: {}", manifest.tools.entries().len());
- println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
- }
- Err(error) => {
- eprintln!("failed to extract manifests: {error}");
- std::process::exit(1);
- }
- }
- }
- fn print_bootstrap_plan() {
- for phase in runtime::BootstrapPlan::claude_code_default().phases() {
- println!("- {phase:?}");
- }
- }
- fn print_system_prompt(cwd: PathBuf, date: String) {
- match load_system_prompt(cwd, date, env::consts::OS, "unknown") {
- Ok(sections) => println!("{}", sections.join("\n\n")),
- Err(error) => {
- eprintln!("failed to build system prompt: {error}");
- std::process::exit(1);
- }
- }
- }
- fn resume_session(session_path: &Path, commands: &[String]) {
- let session = match Session::load_from_path(session_path) {
- Ok(session) => session,
- Err(error) => {
- eprintln!("failed to restore session: {error}");
- std::process::exit(1);
- }
- };
- if commands.is_empty() {
- println!(
- "Restored session from {} ({} messages).",
- session_path.display(),
- session.messages.len()
- );
- return;
- }
- let mut session = session;
- for raw_command in commands {
- let Some(command) = SlashCommand::parse(raw_command) else {
- eprintln!("unsupported resumed command: {raw_command}");
- std::process::exit(2);
- };
- match run_resume_command(session_path, &session, &command) {
- Ok(ResumeCommandOutcome {
- session: next_session,
- message,
- }) => {
- session = next_session;
- if let Some(message) = message {
- println!("{message}");
- }
- }
- Err(error) => {
- eprintln!("{error}");
- std::process::exit(2);
- }
- }
- }
- }
- #[derive(Debug, Clone)]
- struct ResumeCommandOutcome {
- session: Session,
- message: Option<String>,
- }
- #[derive(Debug, Clone)]
- struct StatusContext {
- cwd: PathBuf,
- session_path: Option<PathBuf>,
- loaded_config_files: usize,
- discovered_config_files: usize,
- memory_file_count: usize,
- }
- #[derive(Debug, Clone, Copy)]
- struct StatusUsage {
- message_count: usize,
- turns: u32,
- latest: TokenUsage,
- cumulative: TokenUsage,
- estimated_tokens: usize,
- }
- 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 Some(result) = handle_slash_command(
- "/compact",
- session,
- CompactionConfig {
- max_estimated_tokens: 0,
- ..CompactionConfig::default()
- },
- ) else {
- return Ok(ResumeCommandOutcome {
- session: session.clone(),
- message: None,
- });
- };
- result.session.save_to_path(session_path)?;
- Ok(ResumeCommandOutcome {
- session: result.session,
- message: Some(result.message),
- })
- }
- SlashCommand::Clear => {
- let cleared = Session::new();
- cleared.save_to_path(session_path)?;
- Ok(ResumeCommandOutcome {
- session: cleared,
- message: Some(format!(
- "Cleared resumed session file {}.",
- session_path.display()
- )),
- })
- }
- SlashCommand::Status => {
- let tracker = UsageTracker::from_session(session);
- let usage = tracker.cumulative_usage();
- Ok(ResumeCommandOutcome {
- session: session.clone(),
- message: Some(format_status_report(
- "restored-session",
- StatusUsage {
- message_count: session.messages.len(),
- turns: tracker.turns(),
- latest: tracker.current_turn_usage(),
- cumulative: usage,
- estimated_tokens: 0,
- },
- permission_mode_label(),
- &status_context(Some(session_path))?,
- )),
- })
- }
- SlashCommand::Cost => {
- let usage = UsageTracker::from_session(session).cumulative_usage();
- Ok(ResumeCommandOutcome {
- session: session.clone(),
- message: Some(format!(
- "cost: input_tokens={} output_tokens={} cache_creation_tokens={} cache_read_tokens={} total_tokens={}",
- usage.input_tokens,
- usage.output_tokens,
- usage.cache_creation_input_tokens,
- usage.cache_read_input_tokens,
- usage.total_tokens(),
- )),
- })
- }
- SlashCommand::Config => Ok(ResumeCommandOutcome {
- session: session.clone(),
- message: Some(render_config_report()?),
- }),
- SlashCommand::Memory => Ok(ResumeCommandOutcome {
- session: session.clone(),
- message: Some(render_memory_report()?),
- }),
- SlashCommand::Init => Ok(ResumeCommandOutcome {
- session: session.clone(),
- message: Some(init_claude_md()?),
- }),
- SlashCommand::Resume { .. }
- | SlashCommand::Model { .. }
- | SlashCommand::Permissions { .. }
- | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
- }
- }
- fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> {
- let mut cli = LiveCli::new(model, true)?;
- let editor = input::LineEditor::new("› ");
- println!("Rusty Claude CLI interactive mode");
- println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
- while let Some(input) = editor.read_line()? {
- let trimmed = input.trim();
- if trimmed.is_empty() {
- continue;
- }
- if matches!(trimmed, "/exit" | "/quit") {
- break;
- }
- if let Some(command) = SlashCommand::parse(trimmed) {
- cli.handle_repl_command(command)?;
- continue;
- }
- cli.run_turn(trimmed)?;
- }
- Ok(())
- }
- struct LiveCli {
- model: String,
- system_prompt: Vec<String>,
- runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
- }
- impl LiveCli {
- fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
- let system_prompt = build_system_prompt()?;
- let runtime = build_runtime(
- Session::new(),
- model.clone(),
- system_prompt.clone(),
- enable_tools,
- )?;
- Ok(Self {
- model,
- system_prompt,
- runtime,
- })
- }
- fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
- let mut spinner = Spinner::new();
- let mut stdout = io::stdout();
- spinner.tick(
- "Waiting for Claude",
- TerminalRenderer::new().color_theme(),
- &mut stdout,
- )?;
- let result = self.runtime.run_turn(input, None);
- match result {
- Ok(_) => {
- spinner.finish(
- "Claude response complete",
- TerminalRenderer::new().color_theme(),
- &mut stdout,
- )?;
- println!();
- Ok(())
- }
- Err(error) => {
- spinner.fail(
- "Claude request failed",
- TerminalRenderer::new().color_theme(),
- &mut stdout,
- )?;
- Err(Box::new(error))
- }
- }
- }
- fn handle_repl_command(
- &mut self,
- command: SlashCommand,
- ) -> Result<(), Box<dyn std::error::Error>> {
- match command {
- SlashCommand::Help => println!("{}", render_repl_help()),
- SlashCommand::Status => self.print_status(),
- SlashCommand::Compact => self.compact()?,
- SlashCommand::Model { model } => self.set_model(model)?,
- SlashCommand::Permissions { mode } => self.set_permissions(mode)?,
- SlashCommand::Clear => self.clear_session()?,
- SlashCommand::Cost => self.print_cost(),
- SlashCommand::Resume { session_path } => self.resume_session(session_path)?,
- SlashCommand::Config => Self::print_config()?,
- SlashCommand::Memory => Self::print_memory()?,
- SlashCommand::Init => Self::run_init()?,
- SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
- }
- Ok(())
- }
- fn print_status(&self) {
- let cumulative = self.runtime.usage().cumulative_usage();
- let latest = self.runtime.usage().current_turn_usage();
- println!(
- "{}",
- format_status_report(
- &self.model,
- StatusUsage {
- message_count: self.runtime.session().messages.len(),
- turns: self.runtime.usage().turns(),
- latest,
- cumulative,
- estimated_tokens: self.runtime.estimated_tokens(),
- },
- permission_mode_label(),
- &status_context(None).expect("status context should load"),
- )
- );
- }
- fn set_model(&mut self, model: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
- let Some(model) = model else {
- println!("Current model: {}", self.model);
- return Ok(());
- };
- if model == self.model {
- println!("Model already set to {model}.");
- return Ok(());
- }
- let session = self.runtime.session().clone();
- self.runtime = build_runtime(session, model.clone(), self.system_prompt.clone(), true)?;
- self.model.clone_from(&model);
- println!("Switched model to {model}.");
- Ok(())
- }
- fn set_permissions(&mut self, mode: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
- let Some(mode) = mode else {
- println!("Current permission mode: {}", permission_mode_label());
- return Ok(());
- };
- let normalized = normalize_permission_mode(&mode).ok_or_else(|| {
- format!(
- "Unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access."
- )
- })?;
- if normalized == permission_mode_label() {
- println!("Permission mode already set to {normalized}.");
- return Ok(());
- }
- let session = self.runtime.session().clone();
- self.runtime = build_runtime_with_permission_mode(
- session,
- self.model.clone(),
- self.system_prompt.clone(),
- true,
- normalized,
- )?;
- println!("Switched permission mode to {normalized}.");
- Ok(())
- }
- fn clear_session(&mut self) -> Result<(), Box<dyn std::error::Error>> {
- self.runtime = build_runtime_with_permission_mode(
- Session::new(),
- self.model.clone(),
- self.system_prompt.clone(),
- true,
- permission_mode_label(),
- )?;
- println!("Cleared local session history.");
- Ok(())
- }
- fn print_cost(&self) {
- let cumulative = self.runtime.usage().cumulative_usage();
- println!(
- "cost: input_tokens={} output_tokens={} cache_creation_tokens={} cache_read_tokens={} total_tokens={}",
- cumulative.input_tokens,
- cumulative.output_tokens,
- cumulative.cache_creation_input_tokens,
- cumulative.cache_read_input_tokens,
- cumulative.total_tokens(),
- );
- }
- fn resume_session(
- &mut self,
- session_path: Option<String>,
- ) -> Result<(), Box<dyn std::error::Error>> {
- let Some(session_path) = session_path else {
- println!("Usage: /resume <session-path>");
- return Ok(());
- };
- let session = Session::load_from_path(&session_path)?;
- let message_count = session.messages.len();
- self.runtime = build_runtime_with_permission_mode(
- session,
- self.model.clone(),
- self.system_prompt.clone(),
- true,
- permission_mode_label(),
- )?;
- println!("Resumed session from {session_path} ({message_count} messages).");
- Ok(())
- }
- fn print_config() -> Result<(), Box<dyn std::error::Error>> {
- println!("{}", render_config_report()?);
- Ok(())
- }
- fn print_memory() -> Result<(), Box<dyn std::error::Error>> {
- println!("{}", render_memory_report()?);
- Ok(())
- }
- fn run_init() -> Result<(), Box<dyn std::error::Error>> {
- println!("{}", init_claude_md()?);
- Ok(())
- }
- fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
- let result = self.runtime.compact(CompactionConfig::default());
- let removed = result.removed_message_count;
- self.runtime = build_runtime_with_permission_mode(
- result.compacted_session,
- self.model.clone(),
- self.system_prompt.clone(),
- true,
- permission_mode_label(),
- )?;
- println!("Compacted {removed} messages.");
- Ok(())
- }
- }
- fn render_repl_help() -> String {
- format!(
- "{}
- /exit Quit the REPL",
- render_slash_command_help()
- )
- }
- fn status_context(
- session_path: Option<&Path>,
- ) -> Result<StatusContext, Box<dyn std::error::Error>> {
- let cwd = env::current_dir()?;
- let loader = ConfigLoader::default_for(&cwd);
- let discovered_config_files = loader.discover().len();
- let runtime_config = loader.load()?;
- let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
- 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(),
- })
- }
- 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 {}
- Session {}
- Config files loaded {}/{}
- Memory files {}",
- context.cwd.display(),
- 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() -> 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()
- ));
- }
- 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 project_context = ProjectContext::discover(env::current_dir()?, DEFAULT_DATE)?;
- let mut lines = vec![format!(
- "memory: files={}",
- project_context.instruction_files.len()
- )];
- if project_context.instruction_files.is_empty() {
- lines.push(
- " No CLAUDE instruction files discovered in the current directory ancestry."
- .to_string(),
- );
- } else {
- for file in project_context.instruction_files {
- let preview = file.content.lines().next().unwrap_or("").trim();
- let preview = if preview.is_empty() {
- "<empty>"
- } else {
- preview
- };
- lines.push(format!(
- " {} ({}) {}",
- file.path.display(),
- file.content.lines().count(),
- preview
- ));
- }
- }
- Ok(lines.join(
- "
- ",
- ))
- }
- fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
- let cwd = env::current_dir()?;
- let claude_md = cwd.join("CLAUDE.md");
- if claude_md.exists() {
- return Ok(format!(
- "init: skipped because {} already exists",
- claude_md.display()
- ));
- }
- let content = render_init_claude_md(&cwd);
- fs::write(&claude_md, content)?;
- Ok(format!("init: created {}", claude_md.display()))
- }
- fn render_init_claude_md(cwd: &Path) -> String {
- let mut lines = vec![
- "# CLAUDE.md".to_string(),
- String::new(),
- "This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.".to_string(),
- String::new(),
- ];
- let mut command_lines = Vec::new();
- if cwd.join("rust").join("Cargo.toml").is_file() {
- command_lines.push("- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
- } else if cwd.join("Cargo.toml").is_file() {
- command_lines.push("- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
- }
- if cwd.join("tests").is_dir() && cwd.join("src").is_dir() {
- command_lines.push("- `src/` and `tests/` are also present; check those surfaces before removing or renaming Python-era compatibility assets.".to_string());
- }
- if !command_lines.is_empty() {
- lines.push("## Verification".to_string());
- lines.extend(command_lines);
- lines.push(String::new());
- }
- let mut structure_lines = Vec::new();
- if cwd.join("rust").is_dir() {
- structure_lines.push(
- "- `rust/` contains the Rust workspace and the active CLI/runtime implementation."
- .to_string(),
- );
- }
- if cwd.join("src").is_dir() {
- structure_lines.push("- `src/` contains the older Python-first workspace artifacts referenced by the repo history and tests.".to_string());
- }
- if cwd.join("tests").is_dir() {
- structure_lines.push("- `tests/` exercises compatibility and porting behavior across the repository surfaces.".to_string());
- }
- if !structure_lines.is_empty() {
- lines.push("## Repository shape".to_string());
- lines.extend(structure_lines);
- lines.push(String::new());
- }
- lines.push("## Working agreement".to_string());
- lines.push("- Prefer small, reviewable Rust changes and keep slash-command behavior aligned between the shared command registry and the CLI entrypoints.".to_string());
- lines.push("- Do not overwrite existing CLAUDE.md content automatically; update it intentionally when repo workflows change.".to_string());
- lines.push(String::new());
- lines.join(
- "
- ",
- )
- }
- fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
- match mode.trim() {
- "read-only" => Some("read-only"),
- "workspace-write" => Some("workspace-write"),
- "danger-full-access" => Some("danger-full-access"),
- _ => None,
- }
- }
- fn permission_mode_label() -> &'static str {
- match env::var("RUSTY_CLAUDE_PERMISSION_MODE") {
- Ok(value) if value == "read-only" => "read-only",
- Ok(value) if value == "danger-full-access" => "danger-full-access",
- _ => "workspace-write",
- }
- }
- fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
- Ok(load_system_prompt(
- env::current_dir()?,
- DEFAULT_DATE,
- env::consts::OS,
- "unknown",
- )?)
- }
- fn build_runtime(
- session: Session,
- model: String,
- system_prompt: Vec<String>,
- enable_tools: bool,
- ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
- {
- build_runtime_with_permission_mode(
- session,
- model,
- system_prompt,
- enable_tools,
- permission_mode_label(),
- )
- }
- fn build_runtime_with_permission_mode(
- session: Session,
- model: String,
- system_prompt: Vec<String>,
- enable_tools: bool,
- permission_mode: &str,
- ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
- {
- Ok(ConversationRuntime::new(
- session,
- AnthropicRuntimeClient::new(model, enable_tools)?,
- CliToolExecutor::new(),
- permission_policy(permission_mode),
- system_prompt,
- ))
- }
- struct AnthropicRuntimeClient {
- runtime: tokio::runtime::Runtime,
- client: AnthropicClient,
- model: String,
- enable_tools: bool,
- }
- impl AnthropicRuntimeClient {
- fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
- Ok(Self {
- runtime: tokio::runtime::Runtime::new()?,
- client: AnthropicClient::from_env()?,
- model,
- enable_tools,
- })
- }
- }
- impl ApiClient for AnthropicRuntimeClient {
- #[allow(clippy::too_many_lines)]
- fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
- let message_request = MessageRequest {
- model: self.model.clone(),
- max_tokens: DEFAULT_MAX_TOKENS,
- messages: convert_messages(&request.messages),
- system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
- tools: self.enable_tools.then(|| {
- mvp_tool_specs()
- .into_iter()
- .map(|spec| ToolDefinition {
- name: spec.name.to_string(),
- description: Some(spec.description.to_string()),
- input_schema: spec.input_schema,
- })
- .collect()
- }),
- tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
- stream: true,
- };
- self.runtime.block_on(async {
- let mut stream = self
- .client
- .stream_message(&message_request)
- .await
- .map_err(|error| RuntimeError::new(error.to_string()))?;
- let mut stdout = io::stdout();
- let mut events = Vec::new();
- let mut pending_tool: Option<(String, String, String)> = None;
- let mut saw_stop = false;
- while let Some(event) = stream
- .next_event()
- .await
- .map_err(|error| RuntimeError::new(error.to_string()))?
- {
- match event {
- ApiStreamEvent::MessageStart(start) => {
- for block in start.message.content {
- push_output_block(block, &mut stdout, &mut events, &mut pending_tool)?;
- }
- }
- ApiStreamEvent::ContentBlockStart(start) => {
- push_output_block(
- start.content_block,
- &mut stdout,
- &mut events,
- &mut pending_tool,
- )?;
- }
- ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
- ContentBlockDelta::TextDelta { text } => {
- if !text.is_empty() {
- write!(stdout, "{text}")
- .and_then(|()| stdout.flush())
- .map_err(|error| RuntimeError::new(error.to_string()))?;
- events.push(AssistantEvent::TextDelta(text));
- }
- }
- ContentBlockDelta::InputJsonDelta { partial_json } => {
- if let Some((_, _, input)) = &mut pending_tool {
- input.push_str(&partial_json);
- }
- }
- },
- ApiStreamEvent::ContentBlockStop(_) => {
- if let Some((id, name, input)) = pending_tool.take() {
- events.push(AssistantEvent::ToolUse { id, name, input });
- }
- }
- ApiStreamEvent::MessageDelta(delta) => {
- events.push(AssistantEvent::Usage(TokenUsage {
- input_tokens: delta.usage.input_tokens,
- output_tokens: delta.usage.output_tokens,
- cache_creation_input_tokens: 0,
- cache_read_input_tokens: 0,
- }));
- }
- ApiStreamEvent::MessageStop(_) => {
- saw_stop = true;
- events.push(AssistantEvent::MessageStop);
- }
- }
- }
- if !saw_stop
- && events.iter().any(|event| {
- matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
- || matches!(event, AssistantEvent::ToolUse { .. })
- })
- {
- events.push(AssistantEvent::MessageStop);
- }
- if events
- .iter()
- .any(|event| matches!(event, AssistantEvent::MessageStop))
- {
- return Ok(events);
- }
- let response = self
- .client
- .send_message(&MessageRequest {
- stream: false,
- ..message_request.clone()
- })
- .await
- .map_err(|error| RuntimeError::new(error.to_string()))?;
- response_to_events(response, &mut stdout)
- })
- }
- }
- fn push_output_block(
- block: OutputContentBlock,
- out: &mut impl Write,
- events: &mut Vec<AssistantEvent>,
- pending_tool: &mut Option<(String, String, String)>,
- ) -> Result<(), RuntimeError> {
- match block {
- OutputContentBlock::Text { text } => {
- if !text.is_empty() {
- write!(out, "{text}")
- .and_then(|()| out.flush())
- .map_err(|error| RuntimeError::new(error.to_string()))?;
- events.push(AssistantEvent::TextDelta(text));
- }
- }
- OutputContentBlock::ToolUse { id, name, input } => {
- *pending_tool = Some((id, name, input.to_string()));
- }
- }
- Ok(())
- }
- fn response_to_events(
- response: MessageResponse,
- out: &mut impl Write,
- ) -> Result<Vec<AssistantEvent>, RuntimeError> {
- let mut events = Vec::new();
- let mut pending_tool = None;
- for block in response.content {
- push_output_block(block, out, &mut events, &mut pending_tool)?;
- if let Some((id, name, input)) = pending_tool.take() {
- events.push(AssistantEvent::ToolUse { id, name, input });
- }
- }
- events.push(AssistantEvent::Usage(TokenUsage {
- input_tokens: response.usage.input_tokens,
- output_tokens: response.usage.output_tokens,
- cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
- cache_read_input_tokens: response.usage.cache_read_input_tokens,
- }));
- events.push(AssistantEvent::MessageStop);
- Ok(events)
- }
- struct CliToolExecutor {
- renderer: TerminalRenderer,
- }
- impl CliToolExecutor {
- fn new() -> Self {
- Self {
- renderer: TerminalRenderer::new(),
- }
- }
- }
- impl ToolExecutor for CliToolExecutor {
- fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
- let value = serde_json::from_str(input)
- .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
- match execute_tool(tool_name, &value) {
- Ok(output) => {
- let markdown = format!("### Tool `{tool_name}`\n\n```json\n{output}\n```\n");
- self.renderer
- .stream_markdown(&markdown, &mut io::stdout())
- .map_err(|error| ToolError::new(error.to_string()))?;
- Ok(output)
- }
- Err(error) => Err(ToolError::new(error)),
- }
- }
- }
- fn permission_policy(mode: &str) -> PermissionPolicy {
- if normalize_permission_mode(mode) == Some("read-only") {
- PermissionPolicy::new(PermissionMode::Deny)
- .with_tool_mode("read_file", PermissionMode::Allow)
- .with_tool_mode("glob_search", PermissionMode::Allow)
- .with_tool_mode("grep_search", PermissionMode::Allow)
- } else {
- PermissionPolicy::new(PermissionMode::Allow)
- }
- }
- fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
- messages
- .iter()
- .filter_map(|message| {
- let role = match message.role {
- MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
- MessageRole::Assistant => "assistant",
- };
- let content = message
- .blocks
- .iter()
- .map(|block| match block {
- ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
- ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
- id: id.clone(),
- name: name.clone(),
- input: serde_json::from_str(input)
- .unwrap_or_else(|_| serde_json::json!({ "raw": input })),
- },
- ContentBlock::ToolResult {
- tool_use_id,
- output,
- is_error,
- ..
- } => InputContentBlock::ToolResult {
- tool_use_id: tool_use_id.clone(),
- content: vec![ToolResultContentBlock::Text {
- text: output.clone(),
- }],
- is_error: *is_error,
- },
- })
- .collect::<Vec<_>>();
- (!content.is_empty()).then(|| InputMessage {
- role: role.to_string(),
- content,
- })
- })
- .collect()
- }
- fn print_help() {
- println!("rusty-claude-cli");
- println!();
- println!("Usage:");
- println!(" rusty-claude-cli [--model MODEL]");
- println!(" Start interactive REPL");
- println!(" rusty-claude-cli [--model MODEL] prompt TEXT");
- println!(" Send one prompt and stream the response");
- println!(" rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]");
- println!(" Inspect or maintain a saved session without entering the REPL");
- println!(" rusty-claude-cli dump-manifests");
- println!(" rusty-claude-cli bootstrap-plan");
- println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
- println!();
- println!("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 --resume session.json /status /compact /cost");
- println!(" rusty-claude-cli --resume session.json /memory /config");
- }
- #[cfg(test)]
- mod tests {
- use super::{
- format_status_report, normalize_permission_mode, parse_args, render_init_claude_md,
- render_repl_help, resume_supported_slash_commands, status_context, CliAction, SlashCommand,
- StatusUsage, DEFAULT_MODEL,
- };
- use runtime::{ContentBlock, ConversationMessage, MessageRole};
- use std::path::{Path, PathBuf};
- #[test]
- fn defaults_to_repl_when_no_args() {
- assert_eq!(
- parse_args(&[]).expect("args should parse"),
- CliAction::Repl {
- model: DEFAULT_MODEL.to_string(),
- }
- );
- }
- #[test]
- fn parses_prompt_subcommand() {
- let args = vec![
- "prompt".to_string(),
- "hello".to_string(),
- "world".to_string(),
- ];
- assert_eq!(
- parse_args(&args).expect("args should parse"),
- CliAction::Prompt {
- prompt: "hello world".to_string(),
- model: DEFAULT_MODEL.to_string(),
- }
- );
- }
- #[test]
- fn parses_system_prompt_options() {
- let args = vec![
- "system-prompt".to_string(),
- "--cwd".to_string(),
- "/tmp/project".to_string(),
- "--date".to_string(),
- "2026-04-01".to_string(),
- ];
- assert_eq!(
- parse_args(&args).expect("args should parse"),
- CliAction::PrintSystemPrompt {
- cwd: PathBuf::from("/tmp/project"),
- date: "2026-04-01".to_string(),
- }
- );
- }
- #[test]
- fn parses_resume_flag_with_slash_command() {
- let args = vec![
- "--resume".to_string(),
- "session.json".to_string(),
- "/compact".to_string(),
- ];
- assert_eq!(
- parse_args(&args).expect("args should parse"),
- CliAction::ResumeSession {
- session_path: PathBuf::from("session.json"),
- commands: vec!["/compact".to_string()],
- }
- );
- }
- #[test]
- fn parses_resume_flag_with_multiple_slash_commands() {
- let args = vec![
- "--resume".to_string(),
- "session.json".to_string(),
- "/status".to_string(),
- "/compact".to_string(),
- "/cost".to_string(),
- ];
- assert_eq!(
- parse_args(&args).expect("args should parse"),
- CliAction::ResumeSession {
- session_path: PathBuf::from("session.json"),
- commands: vec![
- "/status".to_string(),
- "/compact".to_string(),
- "/cost".to_string(),
- ],
- }
- );
- }
- #[test]
- fn repl_help_includes_shared_commands_and_exit() {
- let help = render_repl_help();
- 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"));
- assert!(help.contains("/cost"));
- assert!(help.contains("/resume <session-path>"));
- assert!(help.contains("/config"));
- assert!(help.contains("/memory"));
- assert!(help.contains("/init"));
- 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",]
- );
- }
- #[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,
- },
- );
- 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("Session session.json"));
- assert!(status.contains("Config files loaded 2/3"));
- assert!(status.contains("Memory files 4"));
- }
- #[test]
- fn config_report_uses_sectioned_layout() {
- let report = super::render_config_report().expect("config report should render");
- assert!(report.contains("Config"));
- assert!(report.contains("Discovered files"));
- assert!(report.contains("Merged JSON"));
- }
- #[test]
- fn status_context_reads_real_workspace_metadata() {
- let context = status_context(None).expect("status context should load");
- assert!(context.cwd.is_absolute());
- assert_eq!(context.discovered_config_files, 3);
- assert!(context.loaded_config_files <= context.discovered_config_files);
- }
- #[test]
- fn normalizes_supported_permission_modes() {
- assert_eq!(normalize_permission_mode("read-only"), Some("read-only"));
- assert_eq!(
- normalize_permission_mode("workspace-write"),
- Some("workspace-write")
- );
- assert_eq!(
- normalize_permission_mode("danger-full-access"),
- Some("danger-full-access")
- );
- assert_eq!(normalize_permission_mode("unknown"), None);
- }
- #[test]
- fn 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("/config"), Some(SlashCommand::Config));
- assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
- assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
- }
- #[test]
- fn init_template_mentions_detected_rust_workspace() {
- let rendered = render_init_claude_md(Path::new("."));
- assert!(rendered.contains("# CLAUDE.md"));
- assert!(rendered.contains("cargo clippy --workspace --all-targets -- -D warnings"));
- }
- #[test]
- fn converts_tool_roundtrip_messages() {
- let messages = vec![
- ConversationMessage::user_text("hello"),
- ConversationMessage::assistant(vec![ContentBlock::ToolUse {
- id: "tool-1".to_string(),
- name: "bash".to_string(),
- input: "{\"command\":\"pwd\"}".to_string(),
- }]),
- ConversationMessage {
- role: MessageRole::Tool,
- blocks: vec![ContentBlock::ToolResult {
- tool_use_id: "tool-1".to_string(),
- tool_name: "bash".to_string(),
- output: "ok".to_string(),
- is_error: false,
- }],
- usage: None,
- },
- ];
- let converted = super::convert_messages(&messages);
- assert_eq!(converted.len(), 3);
- assert_eq!(converted[1].role, "assistant");
- assert_eq!(converted[2].role, "user");
- }
- }
|