main.rs 37 KB

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