main.rs 43 KB

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