main.rs 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770
  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;
  12. use compat_harness::{extract_manifest, UpstreamPaths};
  13. use render::{Spinner, TerminalRenderer};
  14. use runtime::{
  15. estimate_session_tokens, load_system_prompt, ApiClient, ApiRequest, AssistantEvent,
  16. CompactionConfig, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole,
  17. PermissionMode, PermissionPolicy, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
  18. };
  19. use tools::{execute_tool, mvp_tool_specs};
  20. const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
  21. const DEFAULT_MAX_TOKENS: u32 = 32;
  22. const DEFAULT_DATE: &str = "2026-03-31";
  23. fn main() {
  24. if let Err(error) = run() {
  25. eprintln!("{error}");
  26. std::process::exit(1);
  27. }
  28. }
  29. fn run() -> Result<(), Box<dyn std::error::Error>> {
  30. let args: Vec<String> = env::args().skip(1).collect();
  31. match parse_args(&args)? {
  32. CliAction::DumpManifests => dump_manifests(),
  33. CliAction::BootstrapPlan => print_bootstrap_plan(),
  34. CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
  35. CliAction::ResumeSession {
  36. session_path,
  37. command,
  38. } => resume_session(&session_path, command),
  39. CliAction::Prompt { prompt, model } => LiveCli::new(model, false)?.run_turn(&prompt)?,
  40. CliAction::Repl { model } => run_repl(model)?,
  41. CliAction::Help => print_help(),
  42. }
  43. Ok(())
  44. }
  45. #[derive(Debug, Clone, PartialEq, Eq)]
  46. enum CliAction {
  47. DumpManifests,
  48. BootstrapPlan,
  49. PrintSystemPrompt {
  50. cwd: PathBuf,
  51. date: String,
  52. },
  53. ResumeSession {
  54. session_path: PathBuf,
  55. command: Option<String>,
  56. },
  57. Prompt {
  58. prompt: String,
  59. model: String,
  60. },
  61. Repl {
  62. model: String,
  63. },
  64. Help,
  65. }
  66. fn parse_args(args: &[String]) -> Result<CliAction, String> {
  67. let mut model = DEFAULT_MODEL.to_string();
  68. let mut rest = Vec::new();
  69. let mut index = 0;
  70. while index < args.len() {
  71. match args[index].as_str() {
  72. "--model" => {
  73. let value = args
  74. .get(index + 1)
  75. .ok_or_else(|| "missing value for --model".to_string())?;
  76. model.clone_from(value);
  77. index += 2;
  78. }
  79. flag if flag.starts_with("--model=") => {
  80. model = flag[8..].to_string();
  81. index += 1;
  82. }
  83. other => {
  84. rest.push(other.to_string());
  85. index += 1;
  86. }
  87. }
  88. }
  89. if rest.is_empty() {
  90. return Ok(CliAction::Repl { model });
  91. }
  92. if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
  93. return Ok(CliAction::Help);
  94. }
  95. if rest.first().map(String::as_str) == Some("--resume") {
  96. return parse_resume_args(&rest[1..]);
  97. }
  98. match rest[0].as_str() {
  99. "dump-manifests" => Ok(CliAction::DumpManifests),
  100. "bootstrap-plan" => Ok(CliAction::BootstrapPlan),
  101. "system-prompt" => parse_system_prompt_args(&rest[1..]),
  102. "prompt" => {
  103. let prompt = rest[1..].join(" ");
  104. if prompt.trim().is_empty() {
  105. return Err("prompt subcommand requires a prompt string".to_string());
  106. }
  107. Ok(CliAction::Prompt { prompt, model })
  108. }
  109. other => Err(format!("unknown subcommand: {other}")),
  110. }
  111. }
  112. fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
  113. let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
  114. let mut date = DEFAULT_DATE.to_string();
  115. let mut index = 0;
  116. while index < args.len() {
  117. match args[index].as_str() {
  118. "--cwd" => {
  119. let value = args
  120. .get(index + 1)
  121. .ok_or_else(|| "missing value for --cwd".to_string())?;
  122. cwd = PathBuf::from(value);
  123. index += 2;
  124. }
  125. "--date" => {
  126. let value = args
  127. .get(index + 1)
  128. .ok_or_else(|| "missing value for --date".to_string())?;
  129. date.clone_from(value);
  130. index += 2;
  131. }
  132. other => return Err(format!("unknown system-prompt option: {other}")),
  133. }
  134. }
  135. Ok(CliAction::PrintSystemPrompt { cwd, date })
  136. }
  137. fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
  138. let session_path = args
  139. .first()
  140. .ok_or_else(|| "missing session path for --resume".to_string())
  141. .map(PathBuf::from)?;
  142. let command = args.get(1).cloned();
  143. if args.len() > 2 {
  144. return Err("--resume accepts at most one trailing slash command".to_string());
  145. }
  146. Ok(CliAction::ResumeSession {
  147. session_path,
  148. command,
  149. })
  150. }
  151. fn dump_manifests() {
  152. let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
  153. let paths = UpstreamPaths::from_workspace_dir(&workspace_dir);
  154. match extract_manifest(&paths) {
  155. Ok(manifest) => {
  156. println!("commands: {}", manifest.commands.entries().len());
  157. println!("tools: {}", manifest.tools.entries().len());
  158. println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
  159. }
  160. Err(error) => {
  161. eprintln!("failed to extract manifests: {error}");
  162. std::process::exit(1);
  163. }
  164. }
  165. }
  166. fn print_bootstrap_plan() {
  167. for phase in runtime::BootstrapPlan::claude_code_default().phases() {
  168. println!("- {phase:?}");
  169. }
  170. }
  171. fn print_system_prompt(cwd: PathBuf, date: String) {
  172. match load_system_prompt(cwd, date, env::consts::OS, "unknown") {
  173. Ok(sections) => println!("{}", sections.join("\n\n")),
  174. Err(error) => {
  175. eprintln!("failed to build system prompt: {error}");
  176. std::process::exit(1);
  177. }
  178. }
  179. }
  180. fn resume_session(session_path: &Path, command: Option<String>) {
  181. let session = match Session::load_from_path(session_path) {
  182. Ok(session) => session,
  183. Err(error) => {
  184. eprintln!("failed to restore session: {error}");
  185. std::process::exit(1);
  186. }
  187. };
  188. match command {
  189. Some(command) if command.starts_with('/') => {
  190. let Some(result) = handle_slash_command(
  191. &command,
  192. &session,
  193. CompactionConfig {
  194. max_estimated_tokens: 0,
  195. ..CompactionConfig::default()
  196. },
  197. ) else {
  198. eprintln!("unknown slash command: {command}");
  199. std::process::exit(2);
  200. };
  201. if let Err(error) = result.session.save_to_path(session_path) {
  202. eprintln!("failed to persist resumed session: {error}");
  203. std::process::exit(1);
  204. }
  205. println!("{}", result.message);
  206. }
  207. Some(other) => {
  208. eprintln!("unsupported resumed command: {other}");
  209. std::process::exit(2);
  210. }
  211. None => {
  212. println!(
  213. "Restored session from {} ({} messages).",
  214. session_path.display(),
  215. session.messages.len()
  216. );
  217. }
  218. }
  219. }
  220. fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> {
  221. let mut cli = LiveCli::new(model, true)?;
  222. let editor = input::LineEditor::new("› ");
  223. println!("Rusty Claude CLI interactive mode");
  224. println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
  225. while let Some(input) = editor.read_line()? {
  226. let trimmed = input.trim();
  227. if trimmed.is_empty() {
  228. continue;
  229. }
  230. match trimmed {
  231. "/exit" | "/quit" => break,
  232. "/help" => {
  233. println!("Available commands:");
  234. println!(" /help Show help");
  235. println!(" /status Show session status");
  236. println!(" /compact Compact session history");
  237. println!(" /exit Quit the REPL");
  238. }
  239. "/status" => cli.print_status(),
  240. "/compact" => cli.compact()?,
  241. _ => cli.run_turn(trimmed)?,
  242. }
  243. }
  244. Ok(())
  245. }
  246. struct LiveCli {
  247. model: String,
  248. system_prompt: Vec<String>,
  249. runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
  250. }
  251. impl LiveCli {
  252. fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
  253. let system_prompt = build_system_prompt()?;
  254. let runtime = build_runtime(
  255. Session::new(),
  256. model.clone(),
  257. system_prompt.clone(),
  258. enable_tools,
  259. )?;
  260. Ok(Self {
  261. model,
  262. system_prompt,
  263. runtime,
  264. })
  265. }
  266. fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
  267. let mut spinner = Spinner::new();
  268. let mut stdout = io::stdout();
  269. spinner.tick(
  270. "Waiting for Claude",
  271. TerminalRenderer::new().color_theme(),
  272. &mut stdout,
  273. )?;
  274. let result = self.runtime.run_turn(input, None);
  275. match result {
  276. Ok(turn) => {
  277. spinner.finish(
  278. "Claude response complete",
  279. TerminalRenderer::new().color_theme(),
  280. &mut stdout,
  281. )?;
  282. println!();
  283. self.print_turn_usage(turn.usage);
  284. Ok(())
  285. }
  286. Err(error) => {
  287. spinner.fail(
  288. "Claude request failed",
  289. TerminalRenderer::new().color_theme(),
  290. &mut stdout,
  291. )?;
  292. Err(Box::new(error))
  293. }
  294. }
  295. }
  296. fn print_status(&self) {
  297. let usage = self.runtime.usage().cumulative_usage();
  298. println!(
  299. "status: messages={} turns={} estimated_session_tokens={}",
  300. self.runtime.session().messages.len(),
  301. self.runtime.usage().turns(),
  302. self.runtime.estimated_tokens()
  303. );
  304. for line in usage.summary_lines("usage") {
  305. println!("{line}");
  306. }
  307. }
  308. fn print_turn_usage(&self, cumulative_usage: TokenUsage) {
  309. let latest = self.runtime.usage().current_turn_usage();
  310. println!("\nTurn usage:");
  311. for line in latest.summary_lines(" latest") {
  312. println!("{line}");
  313. }
  314. println!("Cumulative usage:");
  315. for line in cumulative_usage.summary_lines(" total") {
  316. println!("{line}");
  317. }
  318. }
  319. fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
  320. let estimated_before = self.runtime.estimated_tokens();
  321. let result = self.runtime.compact(CompactionConfig::default());
  322. let removed = result.removed_message_count;
  323. let estimated_after = estimate_session_tokens(&result.compacted_session);
  324. let formatted_summary = result.formatted_summary.clone();
  325. let compacted_session = result.compacted_session;
  326. self.runtime = build_runtime(
  327. compacted_session,
  328. self.model.clone(),
  329. self.system_prompt.clone(),
  330. true,
  331. )?;
  332. if removed == 0 {
  333. println!("Compaction skipped: session is below the compaction threshold.");
  334. } else {
  335. println!("Compacted {removed} messages into a resumable system summary.");
  336. if !formatted_summary.is_empty() {
  337. println!("\n{formatted_summary}");
  338. }
  339. let estimated_saved = estimated_before.saturating_sub(estimated_after);
  340. println!("Estimated tokens saved: {estimated_saved}");
  341. }
  342. Ok(())
  343. }
  344. }
  345. fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
  346. Ok(load_system_prompt(
  347. env::current_dir()?,
  348. DEFAULT_DATE,
  349. env::consts::OS,
  350. "unknown",
  351. )?)
  352. }
  353. fn build_runtime(
  354. session: Session,
  355. model: String,
  356. system_prompt: Vec<String>,
  357. enable_tools: bool,
  358. ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
  359. {
  360. Ok(ConversationRuntime::new(
  361. session,
  362. AnthropicRuntimeClient::new(model, enable_tools)?,
  363. CliToolExecutor::new(),
  364. permission_policy_from_env(),
  365. system_prompt,
  366. ))
  367. }
  368. struct AnthropicRuntimeClient {
  369. runtime: tokio::runtime::Runtime,
  370. client: AnthropicClient,
  371. model: String,
  372. enable_tools: bool,
  373. }
  374. impl AnthropicRuntimeClient {
  375. fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
  376. Ok(Self {
  377. runtime: tokio::runtime::Runtime::new()?,
  378. client: AnthropicClient::from_env()?,
  379. model,
  380. enable_tools,
  381. })
  382. }
  383. }
  384. impl ApiClient for AnthropicRuntimeClient {
  385. #[allow(clippy::too_many_lines)]
  386. fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
  387. let message_request = MessageRequest {
  388. model: self.model.clone(),
  389. max_tokens: DEFAULT_MAX_TOKENS,
  390. messages: convert_messages(&request.messages),
  391. system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
  392. tools: self.enable_tools.then(|| {
  393. mvp_tool_specs()
  394. .into_iter()
  395. .map(|spec| ToolDefinition {
  396. name: spec.name.to_string(),
  397. description: Some(spec.description.to_string()),
  398. input_schema: spec.input_schema,
  399. })
  400. .collect()
  401. }),
  402. tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
  403. stream: true,
  404. };
  405. self.runtime.block_on(async {
  406. let mut stream = self
  407. .client
  408. .stream_message(&message_request)
  409. .await
  410. .map_err(|error| RuntimeError::new(error.to_string()))?;
  411. let mut stdout = io::stdout();
  412. let mut events = Vec::new();
  413. let mut pending_tool: Option<(String, String, String)> = None;
  414. let mut saw_stop = false;
  415. while let Some(event) = stream
  416. .next_event()
  417. .await
  418. .map_err(|error| RuntimeError::new(error.to_string()))?
  419. {
  420. match event {
  421. ApiStreamEvent::MessageStart(start) => {
  422. for block in start.message.content {
  423. push_output_block(block, &mut stdout, &mut events, &mut pending_tool)?;
  424. }
  425. }
  426. ApiStreamEvent::ContentBlockStart(start) => {
  427. push_output_block(
  428. start.content_block,
  429. &mut stdout,
  430. &mut events,
  431. &mut pending_tool,
  432. )?;
  433. }
  434. ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
  435. ContentBlockDelta::TextDelta { text } => {
  436. if !text.is_empty() {
  437. write!(stdout, "{text}")
  438. .and_then(|()| stdout.flush())
  439. .map_err(|error| RuntimeError::new(error.to_string()))?;
  440. events.push(AssistantEvent::TextDelta(text));
  441. }
  442. }
  443. ContentBlockDelta::InputJsonDelta { partial_json } => {
  444. if let Some((_, _, input)) = &mut pending_tool {
  445. input.push_str(&partial_json);
  446. }
  447. }
  448. },
  449. ApiStreamEvent::ContentBlockStop(_) => {
  450. if let Some((id, name, input)) = pending_tool.take() {
  451. events.push(AssistantEvent::ToolUse { id, name, input });
  452. }
  453. }
  454. ApiStreamEvent::MessageDelta(delta) => {
  455. events.push(AssistantEvent::Usage(TokenUsage {
  456. input_tokens: delta.usage.input_tokens,
  457. output_tokens: delta.usage.output_tokens,
  458. cache_creation_input_tokens: 0,
  459. cache_read_input_tokens: 0,
  460. }));
  461. }
  462. ApiStreamEvent::MessageStop(_) => {
  463. saw_stop = true;
  464. events.push(AssistantEvent::MessageStop);
  465. }
  466. }
  467. }
  468. if !saw_stop
  469. && events.iter().any(|event| {
  470. matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
  471. || matches!(event, AssistantEvent::ToolUse { .. })
  472. })
  473. {
  474. events.push(AssistantEvent::MessageStop);
  475. }
  476. if events
  477. .iter()
  478. .any(|event| matches!(event, AssistantEvent::MessageStop))
  479. {
  480. return Ok(events);
  481. }
  482. let response = self
  483. .client
  484. .send_message(&MessageRequest {
  485. stream: false,
  486. ..message_request.clone()
  487. })
  488. .await
  489. .map_err(|error| RuntimeError::new(error.to_string()))?;
  490. response_to_events(response, &mut stdout)
  491. })
  492. }
  493. }
  494. fn push_output_block(
  495. block: OutputContentBlock,
  496. out: &mut impl Write,
  497. events: &mut Vec<AssistantEvent>,
  498. pending_tool: &mut Option<(String, String, String)>,
  499. ) -> Result<(), RuntimeError> {
  500. match block {
  501. OutputContentBlock::Text { text } => {
  502. if !text.is_empty() {
  503. write!(out, "{text}")
  504. .and_then(|()| out.flush())
  505. .map_err(|error| RuntimeError::new(error.to_string()))?;
  506. events.push(AssistantEvent::TextDelta(text));
  507. }
  508. }
  509. OutputContentBlock::ToolUse { id, name, input } => {
  510. *pending_tool = Some((id, name, input.to_string()));
  511. }
  512. }
  513. Ok(())
  514. }
  515. fn response_to_events(
  516. response: MessageResponse,
  517. out: &mut impl Write,
  518. ) -> Result<Vec<AssistantEvent>, RuntimeError> {
  519. let mut events = Vec::new();
  520. let mut pending_tool = None;
  521. for block in response.content {
  522. push_output_block(block, out, &mut events, &mut pending_tool)?;
  523. if let Some((id, name, input)) = pending_tool.take() {
  524. events.push(AssistantEvent::ToolUse { id, name, input });
  525. }
  526. }
  527. events.push(AssistantEvent::Usage(TokenUsage {
  528. input_tokens: response.usage.input_tokens,
  529. output_tokens: response.usage.output_tokens,
  530. cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
  531. cache_read_input_tokens: response.usage.cache_read_input_tokens,
  532. }));
  533. events.push(AssistantEvent::MessageStop);
  534. Ok(events)
  535. }
  536. struct CliToolExecutor {
  537. renderer: TerminalRenderer,
  538. }
  539. impl CliToolExecutor {
  540. fn new() -> Self {
  541. Self {
  542. renderer: TerminalRenderer::new(),
  543. }
  544. }
  545. }
  546. impl ToolExecutor for CliToolExecutor {
  547. fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
  548. let value = serde_json::from_str(input)
  549. .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
  550. match execute_tool(tool_name, &value) {
  551. Ok(output) => {
  552. let markdown = format!("### Tool `{tool_name}`\n\n```json\n{output}\n```\n");
  553. self.renderer
  554. .stream_markdown(&markdown, &mut io::stdout())
  555. .map_err(|error| ToolError::new(error.to_string()))?;
  556. Ok(output)
  557. }
  558. Err(error) => Err(ToolError::new(error)),
  559. }
  560. }
  561. }
  562. fn permission_policy_from_env() -> PermissionPolicy {
  563. let mode =
  564. env::var("RUSTY_CLAUDE_PERMISSION_MODE").unwrap_or_else(|_| "workspace-write".to_string());
  565. match mode.as_str() {
  566. "read-only" => PermissionPolicy::new(PermissionMode::Deny)
  567. .with_tool_mode("read_file", PermissionMode::Allow)
  568. .with_tool_mode("glob_search", PermissionMode::Allow)
  569. .with_tool_mode("grep_search", PermissionMode::Allow),
  570. _ => PermissionPolicy::new(PermissionMode::Allow),
  571. }
  572. }
  573. fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
  574. messages
  575. .iter()
  576. .filter_map(|message| {
  577. let role = match message.role {
  578. MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
  579. MessageRole::Assistant => "assistant",
  580. };
  581. let content = message
  582. .blocks
  583. .iter()
  584. .map(|block| match block {
  585. ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
  586. ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
  587. id: id.clone(),
  588. name: name.clone(),
  589. input: serde_json::from_str(input)
  590. .unwrap_or_else(|_| serde_json::json!({ "raw": input })),
  591. },
  592. ContentBlock::ToolResult {
  593. tool_use_id,
  594. output,
  595. is_error,
  596. ..
  597. } => InputContentBlock::ToolResult {
  598. tool_use_id: tool_use_id.clone(),
  599. content: vec![ToolResultContentBlock::Text {
  600. text: output.clone(),
  601. }],
  602. is_error: *is_error,
  603. },
  604. })
  605. .collect::<Vec<_>>();
  606. (!content.is_empty()).then(|| InputMessage {
  607. role: role.to_string(),
  608. content,
  609. })
  610. })
  611. .collect()
  612. }
  613. fn print_help() {
  614. println!("rusty-claude-cli");
  615. println!();
  616. println!("Usage:");
  617. println!(" rusty-claude-cli [--model MODEL] Start interactive REPL");
  618. println!(
  619. " rusty-claude-cli [--model MODEL] prompt TEXT Send one prompt and stream the response"
  620. );
  621. println!(" rusty-claude-cli dump-manifests");
  622. println!(" rusty-claude-cli bootstrap-plan");
  623. println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
  624. println!(" rusty-claude-cli --resume SESSION.json [/compact]");
  625. }
  626. #[cfg(test)]
  627. mod tests {
  628. use super::{parse_args, CliAction, DEFAULT_MODEL};
  629. use runtime::{ContentBlock, ConversationMessage, MessageRole};
  630. use std::path::PathBuf;
  631. #[test]
  632. fn defaults_to_repl_when_no_args() {
  633. assert_eq!(
  634. parse_args(&[]).expect("args should parse"),
  635. CliAction::Repl {
  636. model: DEFAULT_MODEL.to_string(),
  637. }
  638. );
  639. }
  640. #[test]
  641. fn parses_prompt_subcommand() {
  642. let args = vec![
  643. "prompt".to_string(),
  644. "hello".to_string(),
  645. "world".to_string(),
  646. ];
  647. assert_eq!(
  648. parse_args(&args).expect("args should parse"),
  649. CliAction::Prompt {
  650. prompt: "hello world".to_string(),
  651. model: DEFAULT_MODEL.to_string(),
  652. }
  653. );
  654. }
  655. #[test]
  656. fn parses_system_prompt_options() {
  657. let args = vec![
  658. "system-prompt".to_string(),
  659. "--cwd".to_string(),
  660. "/tmp/project".to_string(),
  661. "--date".to_string(),
  662. "2026-04-01".to_string(),
  663. ];
  664. assert_eq!(
  665. parse_args(&args).expect("args should parse"),
  666. CliAction::PrintSystemPrompt {
  667. cwd: PathBuf::from("/tmp/project"),
  668. date: "2026-04-01".to_string(),
  669. }
  670. );
  671. }
  672. #[test]
  673. fn parses_resume_flag_with_slash_command() {
  674. let args = vec![
  675. "--resume".to_string(),
  676. "session.json".to_string(),
  677. "/compact".to_string(),
  678. ];
  679. assert_eq!(
  680. parse_args(&args).expect("args should parse"),
  681. CliAction::ResumeSession {
  682. session_path: PathBuf::from("session.json"),
  683. command: Some("/compact".to_string()),
  684. }
  685. );
  686. }
  687. #[test]
  688. fn converts_tool_roundtrip_messages() {
  689. let messages = vec![
  690. ConversationMessage::user_text("hello"),
  691. ConversationMessage::assistant(vec![ContentBlock::ToolUse {
  692. id: "tool-1".to_string(),
  693. name: "bash".to_string(),
  694. input: "{\"command\":\"pwd\"}".to_string(),
  695. }]),
  696. ConversationMessage {
  697. role: MessageRole::Tool,
  698. blocks: vec![ContentBlock::ToolResult {
  699. tool_use_id: "tool-1".to_string(),
  700. tool_name: "bash".to_string(),
  701. output: "ok".to_string(),
  702. is_error: false,
  703. }],
  704. usage: None,
  705. },
  706. ];
  707. let converted = super::convert_messages(&messages);
  708. assert_eq!(converted.len(), 3);
  709. assert_eq!(converted[1].role, "assistant");
  710. assert_eq!(converted[2].role, "user");
  711. }
  712. }
备用站点 当前处于降级运行的备用站点,仅供应急访问,数据和功能可能不是最新。