main.rs 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196
  1. mod input;
  2. mod render;
  3. use std::env;
  4. use std::fs;
  5. use std::io::{self, Write};
  6. use std::path::{Path, PathBuf};
  7. use std::time::{SystemTime, UNIX_EPOCH};
  8. use api::{
  9. AnthropicClient, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
  10. MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
  11. ToolResultContentBlock,
  12. };
  13. use commands::handle_slash_command;
  14. use compat_harness::{extract_manifest, UpstreamPaths};
  15. use render::{Spinner, TerminalRenderer};
  16. use runtime::{
  17. estimate_session_tokens, load_system_prompt, ApiClient, ApiRequest, AssistantEvent,
  18. CompactionConfig, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole,
  19. PermissionMode, PermissionPolicy, PermissionPromptDecision, PermissionPrompter,
  20. PermissionRequest, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
  21. };
  22. use tools::{execute_tool, mvp_tool_specs};
  23. const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
  24. const DEFAULT_MAX_TOKENS: u32 = 32;
  25. const DEFAULT_DATE: &str = "2026-03-31";
  26. const DEFAULT_SESSION_LIMIT: usize = 20;
  27. fn main() {
  28. if let Err(error) = run() {
  29. eprintln!("{error}");
  30. std::process::exit(1);
  31. }
  32. }
  33. fn run() -> Result<(), Box<dyn std::error::Error>> {
  34. let args: Vec<String> = env::args().skip(1).collect();
  35. match parse_args(&args)? {
  36. CliAction::DumpManifests => dump_manifests(),
  37. CliAction::BootstrapPlan => print_bootstrap_plan(),
  38. CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
  39. CliAction::ResumeSession {
  40. session_path,
  41. command,
  42. } => resume_session(&session_path, command),
  43. CliAction::ResumeNamed { target, command } => resume_named_session(&target, command),
  44. CliAction::ListSessions { query, limit } => list_sessions(query.as_deref(), limit),
  45. CliAction::Prompt { prompt, model } => LiveCli::new(model, false)?.run_turn(&prompt)?,
  46. CliAction::Repl { model } => run_repl(model)?,
  47. CliAction::Help => print_help(),
  48. }
  49. Ok(())
  50. }
  51. #[derive(Debug, Clone, PartialEq, Eq)]
  52. enum CliAction {
  53. DumpManifests,
  54. BootstrapPlan,
  55. PrintSystemPrompt {
  56. cwd: PathBuf,
  57. date: String,
  58. },
  59. ResumeSession {
  60. session_path: PathBuf,
  61. command: Option<String>,
  62. },
  63. ResumeNamed {
  64. target: String,
  65. command: Option<String>,
  66. },
  67. ListSessions {
  68. query: Option<String>,
  69. limit: usize,
  70. },
  71. Prompt {
  72. prompt: String,
  73. model: String,
  74. },
  75. Repl {
  76. model: String,
  77. },
  78. Help,
  79. }
  80. fn parse_args(args: &[String]) -> Result<CliAction, String> {
  81. let mut model = DEFAULT_MODEL.to_string();
  82. let mut rest = Vec::new();
  83. let mut index = 0;
  84. while index < args.len() {
  85. match args[index].as_str() {
  86. "--model" => {
  87. let value = args
  88. .get(index + 1)
  89. .ok_or_else(|| "missing value for --model".to_string())?;
  90. model.clone_from(value);
  91. index += 2;
  92. }
  93. flag if flag.starts_with("--model=") => {
  94. model = flag[8..].to_string();
  95. index += 1;
  96. }
  97. other => {
  98. rest.push(other.to_string());
  99. index += 1;
  100. }
  101. }
  102. }
  103. if rest.is_empty() {
  104. return Ok(CliAction::Repl { model });
  105. }
  106. if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
  107. return Ok(CliAction::Help);
  108. }
  109. if rest.first().map(String::as_str) == Some("--resume") {
  110. return parse_resume_args(&rest[1..]);
  111. }
  112. match rest[0].as_str() {
  113. "dump-manifests" => Ok(CliAction::DumpManifests),
  114. "bootstrap-plan" => Ok(CliAction::BootstrapPlan),
  115. "resume" => parse_named_resume_args(&rest[1..]),
  116. "sessions" => parse_sessions_args(&rest[1..]),
  117. "system-prompt" => parse_system_prompt_args(&rest[1..]),
  118. "prompt" => {
  119. let prompt = rest[1..].join(" ");
  120. if prompt.trim().is_empty() {
  121. return Err("prompt subcommand requires a prompt string".to_string());
  122. }
  123. Ok(CliAction::Prompt { prompt, model })
  124. }
  125. other => Err(format!("unknown subcommand: {other}")),
  126. }
  127. }
  128. fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
  129. let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
  130. let mut date = DEFAULT_DATE.to_string();
  131. let mut index = 0;
  132. while index < args.len() {
  133. match args[index].as_str() {
  134. "--cwd" => {
  135. let value = args
  136. .get(index + 1)
  137. .ok_or_else(|| "missing value for --cwd".to_string())?;
  138. cwd = PathBuf::from(value);
  139. index += 2;
  140. }
  141. "--date" => {
  142. let value = args
  143. .get(index + 1)
  144. .ok_or_else(|| "missing value for --date".to_string())?;
  145. date.clone_from(value);
  146. index += 2;
  147. }
  148. other => return Err(format!("unknown system-prompt option: {other}")),
  149. }
  150. }
  151. Ok(CliAction::PrintSystemPrompt { cwd, date })
  152. }
  153. fn parse_named_resume_args(args: &[String]) -> Result<CliAction, String> {
  154. let target = args
  155. .first()
  156. .ok_or_else(|| "missing session id, path, or 'latest' for resume".to_string())?
  157. .clone();
  158. let command = args.get(1).cloned();
  159. if args.len() > 2 {
  160. return Err("resume accepts at most one trailing slash command".to_string());
  161. }
  162. Ok(CliAction::ResumeNamed { target, command })
  163. }
  164. fn parse_sessions_args(args: &[String]) -> Result<CliAction, String> {
  165. let mut query = None;
  166. let mut limit = DEFAULT_SESSION_LIMIT;
  167. let mut index = 0;
  168. while index < args.len() {
  169. match args[index].as_str() {
  170. "--query" => {
  171. let value = args
  172. .get(index + 1)
  173. .ok_or_else(|| "missing value for --query".to_string())?;
  174. query = Some(value.clone());
  175. index += 2;
  176. }
  177. "--limit" => {
  178. let value = args
  179. .get(index + 1)
  180. .ok_or_else(|| "missing value for --limit".to_string())?;
  181. limit = value
  182. .parse::<usize>()
  183. .map_err(|error| format!("invalid --limit value: {error}"))?;
  184. index += 2;
  185. }
  186. other => return Err(format!("unknown sessions option: {other}")),
  187. }
  188. }
  189. Ok(CliAction::ListSessions { query, limit })
  190. }
  191. fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
  192. let session_path = args
  193. .first()
  194. .ok_or_else(|| "missing session path for --resume".to_string())
  195. .map(PathBuf::from)?;
  196. let command = args.get(1).cloned();
  197. if args.len() > 2 {
  198. return Err("--resume accepts at most one trailing slash command".to_string());
  199. }
  200. Ok(CliAction::ResumeSession {
  201. session_path,
  202. command,
  203. })
  204. }
  205. fn dump_manifests() {
  206. let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
  207. let paths = UpstreamPaths::from_workspace_dir(&workspace_dir);
  208. match extract_manifest(&paths) {
  209. Ok(manifest) => {
  210. println!("commands: {}", manifest.commands.entries().len());
  211. println!("tools: {}", manifest.tools.entries().len());
  212. println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
  213. }
  214. Err(error) => {
  215. eprintln!("failed to extract manifests: {error}");
  216. std::process::exit(1);
  217. }
  218. }
  219. }
  220. fn print_bootstrap_plan() {
  221. for phase in runtime::BootstrapPlan::claude_code_default().phases() {
  222. println!("- {phase:?}");
  223. }
  224. }
  225. fn print_system_prompt(cwd: PathBuf, date: String) {
  226. match load_system_prompt(cwd, date, env::consts::OS, "unknown") {
  227. Ok(sections) => println!("{}", sections.join("\n\n")),
  228. Err(error) => {
  229. eprintln!("failed to build system prompt: {error}");
  230. std::process::exit(1);
  231. }
  232. }
  233. }
  234. fn resume_session(session_path: &Path, command: Option<String>) {
  235. let session = match Session::load_from_path(session_path) {
  236. Ok(session) => session,
  237. Err(error) => {
  238. eprintln!("failed to restore session: {error}");
  239. std::process::exit(1);
  240. }
  241. };
  242. match command {
  243. Some(command) if command.starts_with('/') => {
  244. let Some(result) = handle_slash_command(
  245. &command,
  246. &session,
  247. CompactionConfig {
  248. max_estimated_tokens: 0,
  249. ..CompactionConfig::default()
  250. },
  251. ) else {
  252. eprintln!("unknown slash command: {command}");
  253. std::process::exit(2);
  254. };
  255. if let Err(error) = result.session.save_to_path(session_path) {
  256. eprintln!("failed to persist resumed session: {error}");
  257. std::process::exit(1);
  258. }
  259. println!("{}", result.message);
  260. }
  261. Some(other) => {
  262. eprintln!("unsupported resumed command: {other}");
  263. std::process::exit(2);
  264. }
  265. None => {
  266. println!(
  267. "Restored session from {} ({} messages).",
  268. session_path.display(),
  269. session.messages.len()
  270. );
  271. }
  272. }
  273. }
  274. fn resume_named_session(target: &str, command: Option<String>) {
  275. let session_path = match resolve_session_target(target) {
  276. Ok(path) => path,
  277. Err(error) => {
  278. eprintln!("{error}");
  279. std::process::exit(1);
  280. }
  281. };
  282. resume_session(&session_path, command);
  283. }
  284. fn list_sessions(query: Option<&str>, limit: usize) {
  285. match load_session_entries(query, limit) {
  286. Ok(entries) => {
  287. if entries.is_empty() {
  288. println!("No saved sessions found.");
  289. return;
  290. }
  291. println!("Saved sessions:");
  292. for entry in entries {
  293. println!(
  294. "- {} | updated={} | messages={} | tokens={} | {}",
  295. entry.id,
  296. entry.updated_unix,
  297. entry.message_count,
  298. entry.total_tokens,
  299. entry.preview
  300. );
  301. }
  302. }
  303. Err(error) => {
  304. eprintln!("failed to list sessions: {error}");
  305. std::process::exit(1);
  306. }
  307. }
  308. }
  309. fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> {
  310. let mut cli = LiveCli::new(model, true)?;
  311. let editor = input::LineEditor::new("› ");
  312. println!("Rusty Claude CLI interactive mode");
  313. println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
  314. while let Some(input) = editor.read_line()? {
  315. let trimmed = input.trim();
  316. if trimmed.is_empty() {
  317. continue;
  318. }
  319. match trimmed {
  320. "/exit" | "/quit" => break,
  321. "/help" => {
  322. println!("Available commands:");
  323. println!(" /help Show help");
  324. println!(" /status Show session status");
  325. println!(" /tools Show tool catalog and permission policy");
  326. println!(" /permissions Show permission mode details");
  327. println!(" /compact Compact session history");
  328. println!(" /exit Quit the REPL");
  329. }
  330. "/status" => cli.print_status(),
  331. "/tools" => cli.print_tools(),
  332. "/permissions" => cli.print_permissions(),
  333. "/compact" => cli.compact()?,
  334. _ => cli.run_turn(trimmed)?,
  335. }
  336. }
  337. Ok(())
  338. }
  339. struct LiveCli {
  340. model: String,
  341. system_prompt: Vec<String>,
  342. runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
  343. session_path: PathBuf,
  344. permission_policy: PermissionPolicy,
  345. }
  346. impl LiveCli {
  347. fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
  348. let system_prompt = build_system_prompt()?;
  349. let session_path = new_session_path()?;
  350. let permission_policy = permission_policy_from_env();
  351. let runtime = build_runtime(
  352. Session::new(),
  353. model.clone(),
  354. system_prompt.clone(),
  355. enable_tools,
  356. permission_policy.clone(),
  357. )?;
  358. Ok(Self {
  359. model,
  360. system_prompt,
  361. runtime,
  362. session_path,
  363. permission_policy,
  364. })
  365. }
  366. fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
  367. let mut spinner = Spinner::new();
  368. let mut stdout = io::stdout();
  369. spinner.tick(
  370. "Waiting for Claude",
  371. TerminalRenderer::new().color_theme(),
  372. &mut stdout,
  373. )?;
  374. let mut permission_prompter = CliPermissionPrompter::new();
  375. let result = self.runtime.run_turn(input, Some(&mut permission_prompter));
  376. match result {
  377. Ok(turn) => {
  378. spinner.finish(
  379. "Claude response complete",
  380. TerminalRenderer::new().color_theme(),
  381. &mut stdout,
  382. )?;
  383. println!();
  384. self.persist_session()?;
  385. self.print_turn_usage(turn.usage);
  386. Ok(())
  387. }
  388. Err(error) => {
  389. spinner.fail(
  390. "Claude request failed",
  391. TerminalRenderer::new().color_theme(),
  392. &mut stdout,
  393. )?;
  394. Err(Box::new(error))
  395. }
  396. }
  397. }
  398. fn print_status(&self) {
  399. let usage = self.runtime.usage().cumulative_usage();
  400. println!(
  401. "status: messages={} turns={} estimated_session_tokens={}",
  402. self.runtime.session().messages.len(),
  403. self.runtime.usage().turns(),
  404. self.runtime.estimated_tokens()
  405. );
  406. for line in usage.summary_lines("usage") {
  407. println!("{line}");
  408. }
  409. }
  410. fn print_turn_usage(&self, cumulative_usage: TokenUsage) {
  411. let latest = self.runtime.usage().current_turn_usage();
  412. println!("\nTurn usage:");
  413. for line in latest.summary_lines(" latest") {
  414. println!("{line}");
  415. }
  416. println!("Cumulative usage:");
  417. for line in cumulative_usage.summary_lines(" total") {
  418. println!("{line}");
  419. }
  420. }
  421. fn print_permissions(&self) {
  422. let mode = env::var("RUSTY_CLAUDE_PERMISSION_MODE")
  423. .unwrap_or_else(|_| "workspace-write".to_string());
  424. println!("Permission mode: {mode}");
  425. println!(
  426. "Default policy: {}",
  427. permission_mode_label(self.permission_policy.mode_for("bash"))
  428. );
  429. println!("Read-only safe tools stay auto-allowed when read-only mode is active.");
  430. println!("Interactive approvals appear when permission mode is set to prompt.");
  431. }
  432. fn print_tools(&self) {
  433. println!("Tool catalog:");
  434. for spec in mvp_tool_specs() {
  435. let mode = self.permission_policy.mode_for(spec.name);
  436. let summary = summarize_tool_schema(&spec.input_schema);
  437. println!(
  438. "- {} [{}] — {}{}",
  439. spec.name,
  440. permission_mode_label(mode),
  441. spec.description,
  442. if summary.is_empty() {
  443. String::new()
  444. } else {
  445. format!(" | args: {summary}")
  446. }
  447. );
  448. }
  449. }
  450. fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
  451. let estimated_before = self.runtime.estimated_tokens();
  452. let result = self.runtime.compact(CompactionConfig::default());
  453. let removed = result.removed_message_count;
  454. let estimated_after = estimate_session_tokens(&result.compacted_session);
  455. let formatted_summary = result.formatted_summary.clone();
  456. let compacted_session = result.compacted_session;
  457. self.runtime = build_runtime(
  458. compacted_session,
  459. self.model.clone(),
  460. self.system_prompt.clone(),
  461. true,
  462. self.permission_policy.clone(),
  463. )?;
  464. if removed == 0 {
  465. println!("Compaction skipped: session is below the compaction threshold.");
  466. } else {
  467. println!("Compacted {removed} messages into a resumable system summary.");
  468. if !formatted_summary.is_empty() {
  469. println!("\n{formatted_summary}");
  470. }
  471. let estimated_saved = estimated_before.saturating_sub(estimated_after);
  472. println!("Estimated tokens saved: {estimated_saved}");
  473. }
  474. self.persist_session()?;
  475. Ok(())
  476. }
  477. fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
  478. self.runtime.session().save_to_path(&self.session_path)?;
  479. Ok(())
  480. }
  481. }
  482. #[derive(Debug, Clone, PartialEq, Eq)]
  483. struct SessionListEntry {
  484. id: String,
  485. path: PathBuf,
  486. updated_unix: u64,
  487. message_count: usize,
  488. total_tokens: u32,
  489. preview: String,
  490. }
  491. fn new_session_path() -> io::Result<PathBuf> {
  492. let session_dir = default_session_dir()?;
  493. fs::create_dir_all(&session_dir)?;
  494. let timestamp = current_unix_timestamp();
  495. let process_id = std::process::id();
  496. Ok(session_dir.join(format!("session-{timestamp}-{process_id}.json")))
  497. }
  498. fn default_session_dir() -> io::Result<PathBuf> {
  499. Ok(env::current_dir()?.join(".rusty-claude").join("sessions"))
  500. }
  501. fn current_unix_timestamp() -> u64 {
  502. SystemTime::now()
  503. .duration_since(UNIX_EPOCH)
  504. .map_or(0, |duration| duration.as_secs())
  505. }
  506. fn resolve_session_target(target: &str) -> io::Result<PathBuf> {
  507. let direct_path = PathBuf::from(target);
  508. if direct_path.is_file() {
  509. return Ok(direct_path);
  510. }
  511. let entries = load_session_entries(None, usize::MAX)?;
  512. if target == "latest" {
  513. return entries
  514. .into_iter()
  515. .next()
  516. .map(|entry| entry.path)
  517. .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no saved sessions found"));
  518. }
  519. let mut matches = entries
  520. .into_iter()
  521. .filter(|entry| entry.id.contains(target) || entry.preview.contains(target))
  522. .collect::<Vec<_>>();
  523. if matches.is_empty() {
  524. return Err(io::Error::new(
  525. io::ErrorKind::NotFound,
  526. format!("no saved session matched '{target}'"),
  527. ));
  528. }
  529. matches.sort_by(|left, right| right.updated_unix.cmp(&left.updated_unix));
  530. Ok(matches.remove(0).path)
  531. }
  532. fn load_session_entries(query: Option<&str>, limit: usize) -> io::Result<Vec<SessionListEntry>> {
  533. let session_dir = default_session_dir()?;
  534. if !session_dir.exists() {
  535. return Ok(Vec::new());
  536. }
  537. let query = query.map(str::to_lowercase);
  538. let mut entries = Vec::new();
  539. for entry in fs::read_dir(session_dir)? {
  540. let entry = entry?;
  541. let path = entry.path();
  542. if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
  543. continue;
  544. }
  545. let Ok(session) = Session::load_from_path(&path) else {
  546. continue;
  547. };
  548. let preview = session_preview(&session);
  549. let id = path
  550. .file_stem()
  551. .map_or_else(String::new, |stem| stem.to_string_lossy().into_owned());
  552. let searchable = format!("{} {}", id.to_lowercase(), preview.to_lowercase());
  553. if let Some(query) = &query {
  554. if !searchable.contains(query) {
  555. continue;
  556. }
  557. }
  558. let updated_unix = entry
  559. .metadata()
  560. .and_then(|metadata| metadata.modified())
  561. .ok()
  562. .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
  563. .map_or(0, |duration| duration.as_secs());
  564. entries.push(SessionListEntry {
  565. id,
  566. path,
  567. updated_unix,
  568. message_count: session.messages.len(),
  569. total_tokens: runtime::UsageTracker::from_session(&session)
  570. .cumulative_usage()
  571. .total_tokens(),
  572. preview,
  573. });
  574. }
  575. entries.sort_by(|left, right| right.updated_unix.cmp(&left.updated_unix));
  576. if limit < entries.len() {
  577. entries.truncate(limit);
  578. }
  579. Ok(entries)
  580. }
  581. fn session_preview(session: &Session) -> String {
  582. for message in session.messages.iter().rev() {
  583. for block in &message.blocks {
  584. if let ContentBlock::Text { text } = block {
  585. let trimmed = text.trim();
  586. if !trimmed.is_empty() {
  587. return truncate_preview(trimmed, 80);
  588. }
  589. }
  590. }
  591. }
  592. "No text preview available".to_string()
  593. }
  594. fn truncate_preview(text: &str, max_chars: usize) -> String {
  595. if text.chars().count() <= max_chars {
  596. return text.to_string();
  597. }
  598. let mut output = text.chars().take(max_chars).collect::<String>();
  599. output.push('…');
  600. output
  601. }
  602. fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
  603. Ok(load_system_prompt(
  604. env::current_dir()?,
  605. DEFAULT_DATE,
  606. env::consts::OS,
  607. "unknown",
  608. )?)
  609. }
  610. fn build_runtime(
  611. session: Session,
  612. model: String,
  613. system_prompt: Vec<String>,
  614. enable_tools: bool,
  615. permission_policy: PermissionPolicy,
  616. ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
  617. {
  618. Ok(ConversationRuntime::new(
  619. session,
  620. AnthropicRuntimeClient::new(model, enable_tools)?,
  621. CliToolExecutor::new(),
  622. permission_policy,
  623. system_prompt,
  624. ))
  625. }
  626. struct AnthropicRuntimeClient {
  627. runtime: tokio::runtime::Runtime,
  628. client: AnthropicClient,
  629. model: String,
  630. enable_tools: bool,
  631. }
  632. impl AnthropicRuntimeClient {
  633. fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
  634. Ok(Self {
  635. runtime: tokio::runtime::Runtime::new()?,
  636. client: AnthropicClient::from_env()?,
  637. model,
  638. enable_tools,
  639. })
  640. }
  641. }
  642. impl ApiClient for AnthropicRuntimeClient {
  643. #[allow(clippy::too_many_lines)]
  644. fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
  645. let message_request = MessageRequest {
  646. model: self.model.clone(),
  647. max_tokens: DEFAULT_MAX_TOKENS,
  648. messages: convert_messages(&request.messages),
  649. system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
  650. tools: self.enable_tools.then(|| {
  651. mvp_tool_specs()
  652. .into_iter()
  653. .map(|spec| ToolDefinition {
  654. name: spec.name.to_string(),
  655. description: Some(spec.description.to_string()),
  656. input_schema: spec.input_schema,
  657. })
  658. .collect()
  659. }),
  660. tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
  661. stream: true,
  662. };
  663. self.runtime.block_on(async {
  664. let mut stream = self
  665. .client
  666. .stream_message(&message_request)
  667. .await
  668. .map_err(|error| RuntimeError::new(error.to_string()))?;
  669. let mut stdout = io::stdout();
  670. let mut events = Vec::new();
  671. let mut pending_tool: Option<(String, String, String)> = None;
  672. let mut saw_stop = false;
  673. while let Some(event) = stream
  674. .next_event()
  675. .await
  676. .map_err(|error| RuntimeError::new(error.to_string()))?
  677. {
  678. match event {
  679. ApiStreamEvent::MessageStart(start) => {
  680. for block in start.message.content {
  681. push_output_block(block, &mut stdout, &mut events, &mut pending_tool)?;
  682. }
  683. }
  684. ApiStreamEvent::ContentBlockStart(start) => {
  685. push_output_block(
  686. start.content_block,
  687. &mut stdout,
  688. &mut events,
  689. &mut pending_tool,
  690. )?;
  691. }
  692. ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
  693. ContentBlockDelta::TextDelta { text } => {
  694. if !text.is_empty() {
  695. write!(stdout, "{text}")
  696. .and_then(|()| stdout.flush())
  697. .map_err(|error| RuntimeError::new(error.to_string()))?;
  698. events.push(AssistantEvent::TextDelta(text));
  699. }
  700. }
  701. ContentBlockDelta::InputJsonDelta { partial_json } => {
  702. if let Some((_, _, input)) = &mut pending_tool {
  703. input.push_str(&partial_json);
  704. }
  705. }
  706. },
  707. ApiStreamEvent::ContentBlockStop(_) => {
  708. if let Some((id, name, input)) = pending_tool.take() {
  709. events.push(AssistantEvent::ToolUse { id, name, input });
  710. }
  711. }
  712. ApiStreamEvent::MessageDelta(delta) => {
  713. events.push(AssistantEvent::Usage(TokenUsage {
  714. input_tokens: delta.usage.input_tokens,
  715. output_tokens: delta.usage.output_tokens,
  716. cache_creation_input_tokens: 0,
  717. cache_read_input_tokens: 0,
  718. }));
  719. }
  720. ApiStreamEvent::MessageStop(_) => {
  721. saw_stop = true;
  722. events.push(AssistantEvent::MessageStop);
  723. }
  724. }
  725. }
  726. if !saw_stop
  727. && events.iter().any(|event| {
  728. matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
  729. || matches!(event, AssistantEvent::ToolUse { .. })
  730. })
  731. {
  732. events.push(AssistantEvent::MessageStop);
  733. }
  734. if events
  735. .iter()
  736. .any(|event| matches!(event, AssistantEvent::MessageStop))
  737. {
  738. return Ok(events);
  739. }
  740. let response = self
  741. .client
  742. .send_message(&MessageRequest {
  743. stream: false,
  744. ..message_request.clone()
  745. })
  746. .await
  747. .map_err(|error| RuntimeError::new(error.to_string()))?;
  748. response_to_events(response, &mut stdout)
  749. })
  750. }
  751. }
  752. fn push_output_block(
  753. block: OutputContentBlock,
  754. out: &mut impl Write,
  755. events: &mut Vec<AssistantEvent>,
  756. pending_tool: &mut Option<(String, String, String)>,
  757. ) -> Result<(), RuntimeError> {
  758. match block {
  759. OutputContentBlock::Text { text } => {
  760. if !text.is_empty() {
  761. write!(out, "{text}")
  762. .and_then(|()| out.flush())
  763. .map_err(|error| RuntimeError::new(error.to_string()))?;
  764. events.push(AssistantEvent::TextDelta(text));
  765. }
  766. }
  767. OutputContentBlock::ToolUse { id, name, input } => {
  768. *pending_tool = Some((id, name, input.to_string()));
  769. }
  770. }
  771. Ok(())
  772. }
  773. fn response_to_events(
  774. response: MessageResponse,
  775. out: &mut impl Write,
  776. ) -> Result<Vec<AssistantEvent>, RuntimeError> {
  777. let mut events = Vec::new();
  778. let mut pending_tool = None;
  779. for block in response.content {
  780. push_output_block(block, out, &mut events, &mut pending_tool)?;
  781. if let Some((id, name, input)) = pending_tool.take() {
  782. events.push(AssistantEvent::ToolUse { id, name, input });
  783. }
  784. }
  785. events.push(AssistantEvent::Usage(TokenUsage {
  786. input_tokens: response.usage.input_tokens,
  787. output_tokens: response.usage.output_tokens,
  788. cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
  789. cache_read_input_tokens: response.usage.cache_read_input_tokens,
  790. }));
  791. events.push(AssistantEvent::MessageStop);
  792. Ok(events)
  793. }
  794. fn permission_mode_label(mode: PermissionMode) -> &'static str {
  795. match mode {
  796. PermissionMode::Allow => "allow",
  797. PermissionMode::Deny => "deny",
  798. PermissionMode::Prompt => "prompt",
  799. }
  800. }
  801. fn summarize_tool_schema(schema: &serde_json::Value) -> String {
  802. let Some(properties) = schema
  803. .get("properties")
  804. .and_then(serde_json::Value::as_object)
  805. else {
  806. return String::new();
  807. };
  808. let mut keys = properties.keys().cloned().collect::<Vec<_>>();
  809. keys.sort();
  810. keys.join(", ")
  811. }
  812. fn summarize_tool_output(tool_name: &str, output: &str) -> String {
  813. let compact = output.replace('\n', " ");
  814. let preview = truncate_preview(compact.trim(), 120);
  815. if preview.is_empty() {
  816. format!("{tool_name} completed with no textual output")
  817. } else {
  818. format!("{tool_name} → {preview}")
  819. }
  820. }
  821. struct CliPermissionPrompter {
  822. prompt: String,
  823. }
  824. impl CliPermissionPrompter {
  825. fn new() -> Self {
  826. Self {
  827. prompt: "Allow tool? [y]es / [n]o / [a]lways deny this run: ".to_string(),
  828. }
  829. }
  830. }
  831. impl PermissionPrompter for CliPermissionPrompter {
  832. fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
  833. println!(
  834. "
  835. Tool permission request:"
  836. );
  837. println!("- tool: {}", request.tool_name);
  838. println!("- input: {}", truncate_preview(request.input.trim(), 200));
  839. print!("{}", self.prompt);
  840. let _ = io::stdout().flush();
  841. let mut response = String::new();
  842. match io::stdin().read_line(&mut response) {
  843. Ok(_) => match response.trim().to_ascii_lowercase().as_str() {
  844. "y" | "yes" => PermissionPromptDecision::Allow,
  845. "a" | "always" => PermissionPromptDecision::Deny {
  846. reason: "tool denied for this run by user".to_string(),
  847. },
  848. _ => PermissionPromptDecision::Deny {
  849. reason: "tool denied by user".to_string(),
  850. },
  851. },
  852. Err(error) => PermissionPromptDecision::Deny {
  853. reason: format!("tool approval failed: {error}"),
  854. },
  855. }
  856. }
  857. }
  858. struct CliToolExecutor {
  859. renderer: TerminalRenderer,
  860. }
  861. impl CliToolExecutor {
  862. fn new() -> Self {
  863. Self {
  864. renderer: TerminalRenderer::new(),
  865. }
  866. }
  867. }
  868. impl ToolExecutor for CliToolExecutor {
  869. fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
  870. let value = serde_json::from_str(input)
  871. .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
  872. match execute_tool(tool_name, &value) {
  873. Ok(output) => {
  874. let summary = summarize_tool_output(tool_name, &output);
  875. let markdown = format!(
  876. "### Tool `{tool_name}`\n\n- Summary: {summary}\n\n```json\n{output}\n```\n"
  877. );
  878. self.renderer
  879. .stream_markdown(&markdown, &mut io::stdout())
  880. .map_err(|error| ToolError::new(error.to_string()))?;
  881. Ok(output)
  882. }
  883. Err(error) => Err(ToolError::new(error)),
  884. }
  885. }
  886. }
  887. fn permission_policy_from_env() -> PermissionPolicy {
  888. let mode =
  889. env::var("RUSTY_CLAUDE_PERMISSION_MODE").unwrap_or_else(|_| "workspace-write".to_string());
  890. match mode.as_str() {
  891. "read-only" => PermissionPolicy::new(PermissionMode::Deny)
  892. .with_tool_mode("read_file", PermissionMode::Allow)
  893. .with_tool_mode("glob_search", PermissionMode::Allow)
  894. .with_tool_mode("grep_search", PermissionMode::Allow),
  895. "prompt" => PermissionPolicy::new(PermissionMode::Prompt)
  896. .with_tool_mode("read_file", PermissionMode::Allow)
  897. .with_tool_mode("glob_search", PermissionMode::Allow)
  898. .with_tool_mode("grep_search", PermissionMode::Allow),
  899. _ => PermissionPolicy::new(PermissionMode::Allow),
  900. }
  901. }
  902. fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
  903. messages
  904. .iter()
  905. .filter_map(|message| {
  906. let role = match message.role {
  907. MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
  908. MessageRole::Assistant => "assistant",
  909. };
  910. let content = message
  911. .blocks
  912. .iter()
  913. .map(|block| match block {
  914. ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
  915. ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
  916. id: id.clone(),
  917. name: name.clone(),
  918. input: serde_json::from_str(input)
  919. .unwrap_or_else(|_| serde_json::json!({ "raw": input })),
  920. },
  921. ContentBlock::ToolResult {
  922. tool_use_id,
  923. output,
  924. is_error,
  925. ..
  926. } => InputContentBlock::ToolResult {
  927. tool_use_id: tool_use_id.clone(),
  928. content: vec![ToolResultContentBlock::Text {
  929. text: output.clone(),
  930. }],
  931. is_error: *is_error,
  932. },
  933. })
  934. .collect::<Vec<_>>();
  935. (!content.is_empty()).then(|| InputMessage {
  936. role: role.to_string(),
  937. content,
  938. })
  939. })
  940. .collect()
  941. }
  942. fn print_help() {
  943. println!("rusty-claude-cli");
  944. println!();
  945. println!("Usage:");
  946. println!(" rusty-claude-cli [--model MODEL] Start interactive REPL");
  947. println!(
  948. " rusty-claude-cli [--model MODEL] prompt TEXT Send one prompt and stream the response"
  949. );
  950. println!(" rusty-claude-cli dump-manifests");
  951. println!(" rusty-claude-cli bootstrap-plan");
  952. println!(" rusty-claude-cli sessions [--query TEXT] [--limit N]");
  953. println!(" rusty-claude-cli resume <latest|SESSION|PATH> [/compact]");
  954. println!(" env RUSTY_CLAUDE_PERMISSION_MODE=prompt enables interactive tool approval");
  955. println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
  956. println!(" rusty-claude-cli --resume SESSION.json [/compact]");
  957. }
  958. #[cfg(test)]
  959. mod tests {
  960. use super::{parse_args, resolve_session_target, session_preview, CliAction, DEFAULT_MODEL};
  961. use runtime::{ContentBlock, ConversationMessage, MessageRole, Session};
  962. use std::fs;
  963. use std::path::PathBuf;
  964. use std::time::{SystemTime, UNIX_EPOCH};
  965. #[test]
  966. fn defaults_to_repl_when_no_args() {
  967. assert_eq!(
  968. parse_args(&[]).expect("args should parse"),
  969. CliAction::Repl {
  970. model: DEFAULT_MODEL.to_string(),
  971. }
  972. );
  973. }
  974. #[test]
  975. fn parses_prompt_subcommand() {
  976. let args = vec![
  977. "prompt".to_string(),
  978. "hello".to_string(),
  979. "world".to_string(),
  980. ];
  981. assert_eq!(
  982. parse_args(&args).expect("args should parse"),
  983. CliAction::Prompt {
  984. prompt: "hello world".to_string(),
  985. model: DEFAULT_MODEL.to_string(),
  986. }
  987. );
  988. }
  989. #[test]
  990. fn parses_system_prompt_options() {
  991. let args = vec![
  992. "system-prompt".to_string(),
  993. "--cwd".to_string(),
  994. "/tmp/project".to_string(),
  995. "--date".to_string(),
  996. "2026-04-01".to_string(),
  997. ];
  998. assert_eq!(
  999. parse_args(&args).expect("args should parse"),
  1000. CliAction::PrintSystemPrompt {
  1001. cwd: PathBuf::from("/tmp/project"),
  1002. date: "2026-04-01".to_string(),
  1003. }
  1004. );
  1005. }
  1006. #[test]
  1007. fn parses_resume_flag_with_slash_command() {
  1008. let args = vec![
  1009. "--resume".to_string(),
  1010. "session.json".to_string(),
  1011. "/compact".to_string(),
  1012. ];
  1013. assert_eq!(
  1014. parse_args(&args).expect("args should parse"),
  1015. CliAction::ResumeSession {
  1016. session_path: PathBuf::from("session.json"),
  1017. command: Some("/compact".to_string()),
  1018. }
  1019. );
  1020. }
  1021. #[test]
  1022. fn parses_sessions_subcommand() {
  1023. let args = vec![
  1024. "sessions".to_string(),
  1025. "--query".to_string(),
  1026. "compact".to_string(),
  1027. "--limit".to_string(),
  1028. "5".to_string(),
  1029. ];
  1030. assert_eq!(
  1031. parse_args(&args).expect("args should parse"),
  1032. CliAction::ListSessions {
  1033. query: Some("compact".to_string()),
  1034. limit: 5,
  1035. }
  1036. );
  1037. }
  1038. #[test]
  1039. fn parses_named_resume_subcommand() {
  1040. let args = vec![
  1041. "resume".to_string(),
  1042. "latest".to_string(),
  1043. "/compact".to_string(),
  1044. ];
  1045. assert_eq!(
  1046. parse_args(&args).expect("args should parse"),
  1047. CliAction::ResumeNamed {
  1048. target: "latest".to_string(),
  1049. command: Some("/compact".to_string()),
  1050. }
  1051. );
  1052. }
  1053. #[test]
  1054. fn converts_tool_roundtrip_messages() {
  1055. let messages = vec![
  1056. ConversationMessage::user_text("hello"),
  1057. ConversationMessage::assistant(vec![ContentBlock::ToolUse {
  1058. id: "tool-1".to_string(),
  1059. name: "bash".to_string(),
  1060. input: "{\"command\":\"pwd\"}".to_string(),
  1061. }]),
  1062. ConversationMessage {
  1063. role: MessageRole::Tool,
  1064. blocks: vec![ContentBlock::ToolResult {
  1065. tool_use_id: "tool-1".to_string(),
  1066. tool_name: "bash".to_string(),
  1067. output: "ok".to_string(),
  1068. is_error: false,
  1069. }],
  1070. usage: None,
  1071. },
  1072. ];
  1073. let converted = super::convert_messages(&messages);
  1074. assert_eq!(converted.len(), 3);
  1075. assert_eq!(converted[1].role, "assistant");
  1076. assert_eq!(converted[2].role, "user");
  1077. }
  1078. #[test]
  1079. fn builds_preview_from_latest_text_block() {
  1080. let session = Session {
  1081. version: 1,
  1082. messages: vec![
  1083. ConversationMessage::user_text("first"),
  1084. ConversationMessage::assistant(vec![ContentBlock::Text {
  1085. text: "latest preview".to_string(),
  1086. }]),
  1087. ],
  1088. };
  1089. assert_eq!(session_preview(&session), "latest preview");
  1090. }
  1091. #[test]
  1092. fn resolves_direct_session_path() {
  1093. let unique = SystemTime::now()
  1094. .duration_since(UNIX_EPOCH)
  1095. .map_or(0, |duration| duration.as_nanos());
  1096. let path = std::env::temp_dir().join(format!("rusty-claude-session-{unique}.json"));
  1097. fs::write(&path, "{\"version\":1,\"messages\":[]}").expect("temp session");
  1098. let resolved = resolve_session_target(path.to_string_lossy().as_ref()).expect("resolve");
  1099. assert_eq!(resolved, path);
  1100. fs::remove_file(resolved).expect("cleanup");
  1101. }
  1102. }