main.rs 47 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416
  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 api::{
  8. AnthropicClient, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
  9. MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
  10. ToolResultContentBlock,
  11. };
  12. use commands::{
  13. handle_slash_command, render_slash_command_help, resume_supported_slash_commands, SlashCommand,
  14. };
  15. use compat_harness::{extract_manifest, UpstreamPaths};
  16. use render::{Spinner, TerminalRenderer};
  17. use runtime::{
  18. load_system_prompt, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader,
  19. ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole,
  20. PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, Session, TokenUsage, ToolError,
  21. ToolExecutor, UsageTracker,
  22. };
  23. use tools::{execute_tool, mvp_tool_specs};
  24. const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
  25. const DEFAULT_MAX_TOKENS: u32 = 32;
  26. const DEFAULT_DATE: &str = "2026-03-31";
  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. commands,
  42. } => resume_session(&session_path, &commands),
  43. CliAction::Prompt { prompt, model } => LiveCli::new(model, false)?.run_turn(&prompt)?,
  44. CliAction::Repl { model } => run_repl(model)?,
  45. CliAction::Help => print_help(),
  46. }
  47. Ok(())
  48. }
  49. #[derive(Debug, Clone, PartialEq, Eq)]
  50. enum CliAction {
  51. DumpManifests,
  52. BootstrapPlan,
  53. PrintSystemPrompt {
  54. cwd: PathBuf,
  55. date: String,
  56. },
  57. ResumeSession {
  58. session_path: PathBuf,
  59. commands: Vec<String>,
  60. },
  61. Prompt {
  62. prompt: String,
  63. model: String,
  64. },
  65. Repl {
  66. model: String,
  67. },
  68. Help,
  69. }
  70. fn parse_args(args: &[String]) -> Result<CliAction, String> {
  71. let mut model = DEFAULT_MODEL.to_string();
  72. let mut rest = Vec::new();
  73. let mut index = 0;
  74. while index < args.len() {
  75. match args[index].as_str() {
  76. "--model" => {
  77. let value = args
  78. .get(index + 1)
  79. .ok_or_else(|| "missing value for --model".to_string())?;
  80. model.clone_from(value);
  81. index += 2;
  82. }
  83. flag if flag.starts_with("--model=") => {
  84. model = flag[8..].to_string();
  85. index += 1;
  86. }
  87. other => {
  88. rest.push(other.to_string());
  89. index += 1;
  90. }
  91. }
  92. }
  93. if rest.is_empty() {
  94. return Ok(CliAction::Repl { model });
  95. }
  96. if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
  97. return Ok(CliAction::Help);
  98. }
  99. if rest.first().map(String::as_str) == Some("--resume") {
  100. return parse_resume_args(&rest[1..]);
  101. }
  102. match rest[0].as_str() {
  103. "dump-manifests" => Ok(CliAction::DumpManifests),
  104. "bootstrap-plan" => Ok(CliAction::BootstrapPlan),
  105. "system-prompt" => parse_system_prompt_args(&rest[1..]),
  106. "prompt" => {
  107. let prompt = rest[1..].join(" ");
  108. if prompt.trim().is_empty() {
  109. return Err("prompt subcommand requires a prompt string".to_string());
  110. }
  111. Ok(CliAction::Prompt { prompt, model })
  112. }
  113. other => Err(format!("unknown subcommand: {other}")),
  114. }
  115. }
  116. fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
  117. let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
  118. let mut date = DEFAULT_DATE.to_string();
  119. let mut index = 0;
  120. while index < args.len() {
  121. match args[index].as_str() {
  122. "--cwd" => {
  123. let value = args
  124. .get(index + 1)
  125. .ok_or_else(|| "missing value for --cwd".to_string())?;
  126. cwd = PathBuf::from(value);
  127. index += 2;
  128. }
  129. "--date" => {
  130. let value = args
  131. .get(index + 1)
  132. .ok_or_else(|| "missing value for --date".to_string())?;
  133. date.clone_from(value);
  134. index += 2;
  135. }
  136. other => return Err(format!("unknown system-prompt option: {other}")),
  137. }
  138. }
  139. Ok(CliAction::PrintSystemPrompt { cwd, date })
  140. }
  141. fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
  142. let session_path = args
  143. .first()
  144. .ok_or_else(|| "missing session path for --resume".to_string())
  145. .map(PathBuf::from)?;
  146. let commands = args[1..].to_vec();
  147. if commands
  148. .iter()
  149. .any(|command| !command.trim_start().starts_with('/'))
  150. {
  151. return Err("--resume trailing arguments must be slash commands".to_string());
  152. }
  153. Ok(CliAction::ResumeSession {
  154. session_path,
  155. commands,
  156. })
  157. }
  158. fn dump_manifests() {
  159. let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
  160. let paths = UpstreamPaths::from_workspace_dir(&workspace_dir);
  161. match extract_manifest(&paths) {
  162. Ok(manifest) => {
  163. println!("commands: {}", manifest.commands.entries().len());
  164. println!("tools: {}", manifest.tools.entries().len());
  165. println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
  166. }
  167. Err(error) => {
  168. eprintln!("failed to extract manifests: {error}");
  169. std::process::exit(1);
  170. }
  171. }
  172. }
  173. fn print_bootstrap_plan() {
  174. for phase in runtime::BootstrapPlan::claude_code_default().phases() {
  175. println!("- {phase:?}");
  176. }
  177. }
  178. fn print_system_prompt(cwd: PathBuf, date: String) {
  179. match load_system_prompt(cwd, date, env::consts::OS, "unknown") {
  180. Ok(sections) => println!("{}", sections.join("\n\n")),
  181. Err(error) => {
  182. eprintln!("failed to build system prompt: {error}");
  183. std::process::exit(1);
  184. }
  185. }
  186. }
  187. fn resume_session(session_path: &Path, commands: &[String]) {
  188. let session = match Session::load_from_path(session_path) {
  189. Ok(session) => session,
  190. Err(error) => {
  191. eprintln!("failed to restore session: {error}");
  192. std::process::exit(1);
  193. }
  194. };
  195. if commands.is_empty() {
  196. println!(
  197. "Restored session from {} ({} messages).",
  198. session_path.display(),
  199. session.messages.len()
  200. );
  201. return;
  202. }
  203. let mut session = session;
  204. for raw_command in commands {
  205. let Some(command) = SlashCommand::parse(raw_command) else {
  206. eprintln!("unsupported resumed command: {raw_command}");
  207. std::process::exit(2);
  208. };
  209. match run_resume_command(session_path, &session, &command) {
  210. Ok(ResumeCommandOutcome {
  211. session: next_session,
  212. message,
  213. }) => {
  214. session = next_session;
  215. if let Some(message) = message {
  216. println!("{message}");
  217. }
  218. }
  219. Err(error) => {
  220. eprintln!("{error}");
  221. std::process::exit(2);
  222. }
  223. }
  224. }
  225. }
  226. #[derive(Debug, Clone)]
  227. struct ResumeCommandOutcome {
  228. session: Session,
  229. message: Option<String>,
  230. }
  231. #[derive(Debug, Clone)]
  232. struct StatusContext {
  233. cwd: PathBuf,
  234. session_path: Option<PathBuf>,
  235. loaded_config_files: usize,
  236. discovered_config_files: usize,
  237. memory_file_count: usize,
  238. }
  239. #[derive(Debug, Clone, Copy)]
  240. struct StatusUsage {
  241. message_count: usize,
  242. turns: u32,
  243. latest: TokenUsage,
  244. cumulative: TokenUsage,
  245. estimated_tokens: usize,
  246. }
  247. fn run_resume_command(
  248. session_path: &Path,
  249. session: &Session,
  250. command: &SlashCommand,
  251. ) -> Result<ResumeCommandOutcome, Box<dyn std::error::Error>> {
  252. match command {
  253. SlashCommand::Help => Ok(ResumeCommandOutcome {
  254. session: session.clone(),
  255. message: Some(render_repl_help()),
  256. }),
  257. SlashCommand::Compact => {
  258. let Some(result) = handle_slash_command(
  259. "/compact",
  260. session,
  261. CompactionConfig {
  262. max_estimated_tokens: 0,
  263. ..CompactionConfig::default()
  264. },
  265. ) else {
  266. return Ok(ResumeCommandOutcome {
  267. session: session.clone(),
  268. message: None,
  269. });
  270. };
  271. result.session.save_to_path(session_path)?;
  272. Ok(ResumeCommandOutcome {
  273. session: result.session,
  274. message: Some(result.message),
  275. })
  276. }
  277. SlashCommand::Clear => {
  278. let cleared = Session::new();
  279. cleared.save_to_path(session_path)?;
  280. Ok(ResumeCommandOutcome {
  281. session: cleared,
  282. message: Some(format!(
  283. "Cleared resumed session file {}.",
  284. session_path.display()
  285. )),
  286. })
  287. }
  288. SlashCommand::Status => {
  289. let tracker = UsageTracker::from_session(session);
  290. let usage = tracker.cumulative_usage();
  291. Ok(ResumeCommandOutcome {
  292. session: session.clone(),
  293. message: Some(format_status_report(
  294. "restored-session",
  295. StatusUsage {
  296. message_count: session.messages.len(),
  297. turns: tracker.turns(),
  298. latest: tracker.current_turn_usage(),
  299. cumulative: usage,
  300. estimated_tokens: 0,
  301. },
  302. permission_mode_label(),
  303. &status_context(Some(session_path))?,
  304. )),
  305. })
  306. }
  307. SlashCommand::Cost => {
  308. let usage = UsageTracker::from_session(session).cumulative_usage();
  309. Ok(ResumeCommandOutcome {
  310. session: session.clone(),
  311. message: Some(format!(
  312. "cost: input_tokens={} output_tokens={} cache_creation_tokens={} cache_read_tokens={} total_tokens={}",
  313. usage.input_tokens,
  314. usage.output_tokens,
  315. usage.cache_creation_input_tokens,
  316. usage.cache_read_input_tokens,
  317. usage.total_tokens(),
  318. )),
  319. })
  320. }
  321. SlashCommand::Config => Ok(ResumeCommandOutcome {
  322. session: session.clone(),
  323. message: Some(render_config_report()?),
  324. }),
  325. SlashCommand::Memory => Ok(ResumeCommandOutcome {
  326. session: session.clone(),
  327. message: Some(render_memory_report()?),
  328. }),
  329. SlashCommand::Init => Ok(ResumeCommandOutcome {
  330. session: session.clone(),
  331. message: Some(init_claude_md()?),
  332. }),
  333. SlashCommand::Resume { .. }
  334. | SlashCommand::Model { .. }
  335. | SlashCommand::Permissions { .. }
  336. | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
  337. }
  338. }
  339. fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> {
  340. let mut cli = LiveCli::new(model, true)?;
  341. let editor = input::LineEditor::new("› ");
  342. println!("Rusty Claude CLI interactive mode");
  343. println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
  344. while let Some(input) = editor.read_line()? {
  345. let trimmed = input.trim();
  346. if trimmed.is_empty() {
  347. continue;
  348. }
  349. if matches!(trimmed, "/exit" | "/quit") {
  350. break;
  351. }
  352. if let Some(command) = SlashCommand::parse(trimmed) {
  353. cli.handle_repl_command(command)?;
  354. continue;
  355. }
  356. cli.run_turn(trimmed)?;
  357. }
  358. Ok(())
  359. }
  360. struct LiveCli {
  361. model: String,
  362. system_prompt: Vec<String>,
  363. runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
  364. }
  365. impl LiveCli {
  366. fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
  367. let system_prompt = build_system_prompt()?;
  368. let runtime = build_runtime(
  369. Session::new(),
  370. model.clone(),
  371. system_prompt.clone(),
  372. enable_tools,
  373. )?;
  374. Ok(Self {
  375. model,
  376. system_prompt,
  377. runtime,
  378. })
  379. }
  380. fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
  381. let mut spinner = Spinner::new();
  382. let mut stdout = io::stdout();
  383. spinner.tick(
  384. "Waiting for Claude",
  385. TerminalRenderer::new().color_theme(),
  386. &mut stdout,
  387. )?;
  388. let result = self.runtime.run_turn(input, None);
  389. match result {
  390. Ok(_) => {
  391. spinner.finish(
  392. "Claude response complete",
  393. TerminalRenderer::new().color_theme(),
  394. &mut stdout,
  395. )?;
  396. println!();
  397. Ok(())
  398. }
  399. Err(error) => {
  400. spinner.fail(
  401. "Claude request failed",
  402. TerminalRenderer::new().color_theme(),
  403. &mut stdout,
  404. )?;
  405. Err(Box::new(error))
  406. }
  407. }
  408. }
  409. fn handle_repl_command(
  410. &mut self,
  411. command: SlashCommand,
  412. ) -> Result<(), Box<dyn std::error::Error>> {
  413. match command {
  414. SlashCommand::Help => println!("{}", render_repl_help()),
  415. SlashCommand::Status => self.print_status(),
  416. SlashCommand::Compact => self.compact()?,
  417. SlashCommand::Model { model } => self.set_model(model)?,
  418. SlashCommand::Permissions { mode } => self.set_permissions(mode)?,
  419. SlashCommand::Clear => self.clear_session()?,
  420. SlashCommand::Cost => self.print_cost(),
  421. SlashCommand::Resume { session_path } => self.resume_session(session_path)?,
  422. SlashCommand::Config => Self::print_config()?,
  423. SlashCommand::Memory => Self::print_memory()?,
  424. SlashCommand::Init => Self::run_init()?,
  425. SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
  426. }
  427. Ok(())
  428. }
  429. fn print_status(&self) {
  430. let cumulative = self.runtime.usage().cumulative_usage();
  431. let latest = self.runtime.usage().current_turn_usage();
  432. println!(
  433. "{}",
  434. format_status_report(
  435. &self.model,
  436. StatusUsage {
  437. message_count: self.runtime.session().messages.len(),
  438. turns: self.runtime.usage().turns(),
  439. latest,
  440. cumulative,
  441. estimated_tokens: self.runtime.estimated_tokens(),
  442. },
  443. permission_mode_label(),
  444. &status_context(None).expect("status context should load"),
  445. )
  446. );
  447. }
  448. fn set_model(&mut self, model: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
  449. let Some(model) = model else {
  450. println!("Current model: {}", self.model);
  451. return Ok(());
  452. };
  453. if model == self.model {
  454. println!("Model already set to {model}.");
  455. return Ok(());
  456. }
  457. let session = self.runtime.session().clone();
  458. self.runtime = build_runtime(session, model.clone(), self.system_prompt.clone(), true)?;
  459. self.model.clone_from(&model);
  460. println!("Switched model to {model}.");
  461. Ok(())
  462. }
  463. fn set_permissions(&mut self, mode: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
  464. let Some(mode) = mode else {
  465. println!("Current permission mode: {}", permission_mode_label());
  466. return Ok(());
  467. };
  468. let normalized = normalize_permission_mode(&mode).ok_or_else(|| {
  469. format!(
  470. "Unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access."
  471. )
  472. })?;
  473. if normalized == permission_mode_label() {
  474. println!("Permission mode already set to {normalized}.");
  475. return Ok(());
  476. }
  477. let session = self.runtime.session().clone();
  478. self.runtime = build_runtime_with_permission_mode(
  479. session,
  480. self.model.clone(),
  481. self.system_prompt.clone(),
  482. true,
  483. normalized,
  484. )?;
  485. println!("Switched permission mode to {normalized}.");
  486. Ok(())
  487. }
  488. fn clear_session(&mut self) -> Result<(), Box<dyn std::error::Error>> {
  489. self.runtime = build_runtime_with_permission_mode(
  490. Session::new(),
  491. self.model.clone(),
  492. self.system_prompt.clone(),
  493. true,
  494. permission_mode_label(),
  495. )?;
  496. println!("Cleared local session history.");
  497. Ok(())
  498. }
  499. fn print_cost(&self) {
  500. let cumulative = self.runtime.usage().cumulative_usage();
  501. println!(
  502. "cost: input_tokens={} output_tokens={} cache_creation_tokens={} cache_read_tokens={} total_tokens={}",
  503. cumulative.input_tokens,
  504. cumulative.output_tokens,
  505. cumulative.cache_creation_input_tokens,
  506. cumulative.cache_read_input_tokens,
  507. cumulative.total_tokens(),
  508. );
  509. }
  510. fn resume_session(
  511. &mut self,
  512. session_path: Option<String>,
  513. ) -> Result<(), Box<dyn std::error::Error>> {
  514. let Some(session_path) = session_path else {
  515. println!("Usage: /resume <session-path>");
  516. return Ok(());
  517. };
  518. let session = Session::load_from_path(&session_path)?;
  519. let message_count = session.messages.len();
  520. self.runtime = build_runtime_with_permission_mode(
  521. session,
  522. self.model.clone(),
  523. self.system_prompt.clone(),
  524. true,
  525. permission_mode_label(),
  526. )?;
  527. println!("Resumed session from {session_path} ({message_count} messages).");
  528. Ok(())
  529. }
  530. fn print_config() -> Result<(), Box<dyn std::error::Error>> {
  531. println!("{}", render_config_report()?);
  532. Ok(())
  533. }
  534. fn print_memory() -> Result<(), Box<dyn std::error::Error>> {
  535. println!("{}", render_memory_report()?);
  536. Ok(())
  537. }
  538. fn run_init() -> Result<(), Box<dyn std::error::Error>> {
  539. println!("{}", init_claude_md()?);
  540. Ok(())
  541. }
  542. fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
  543. let result = self.runtime.compact(CompactionConfig::default());
  544. let removed = result.removed_message_count;
  545. self.runtime = build_runtime_with_permission_mode(
  546. result.compacted_session,
  547. self.model.clone(),
  548. self.system_prompt.clone(),
  549. true,
  550. permission_mode_label(),
  551. )?;
  552. println!("Compacted {removed} messages.");
  553. Ok(())
  554. }
  555. }
  556. fn render_repl_help() -> String {
  557. format!(
  558. "{}
  559. /exit Quit the REPL",
  560. render_slash_command_help()
  561. )
  562. }
  563. fn status_context(
  564. session_path: Option<&Path>,
  565. ) -> Result<StatusContext, Box<dyn std::error::Error>> {
  566. let cwd = env::current_dir()?;
  567. let loader = ConfigLoader::default_for(&cwd);
  568. let discovered_config_files = loader.discover().len();
  569. let runtime_config = loader.load()?;
  570. let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
  571. Ok(StatusContext {
  572. cwd,
  573. session_path: session_path.map(Path::to_path_buf),
  574. loaded_config_files: runtime_config.loaded_entries().len(),
  575. discovered_config_files,
  576. memory_file_count: project_context.instruction_files.len(),
  577. })
  578. }
  579. fn format_status_report(
  580. model: &str,
  581. usage: StatusUsage,
  582. permission_mode: &str,
  583. context: &StatusContext,
  584. ) -> String {
  585. [
  586. format!(
  587. "Status
  588. Model {model}
  589. Permission mode {permission_mode}
  590. Messages {}
  591. Turns {}
  592. Estimated tokens {}",
  593. usage.message_count, usage.turns, usage.estimated_tokens,
  594. ),
  595. format!(
  596. "Usage
  597. Latest total {}
  598. Cumulative input {}
  599. Cumulative output {}
  600. Cumulative total {}",
  601. usage.latest.total_tokens(),
  602. usage.cumulative.input_tokens,
  603. usage.cumulative.output_tokens,
  604. usage.cumulative.total_tokens(),
  605. ),
  606. format!(
  607. "Workspace
  608. Cwd {}
  609. Session {}
  610. Config files loaded {}/{}
  611. Memory files {}",
  612. context.cwd.display(),
  613. context.session_path.as_ref().map_or_else(
  614. || "live-repl".to_string(),
  615. |path| path.display().to_string()
  616. ),
  617. context.loaded_config_files,
  618. context.discovered_config_files,
  619. context.memory_file_count,
  620. ),
  621. ]
  622. .join(
  623. "
  624. ",
  625. )
  626. }
  627. fn render_config_report() -> Result<String, Box<dyn std::error::Error>> {
  628. let cwd = env::current_dir()?;
  629. let loader = ConfigLoader::default_for(&cwd);
  630. let discovered = loader.discover();
  631. let runtime_config = loader.load()?;
  632. let mut lines = vec![
  633. format!(
  634. "Config
  635. Working directory {}
  636. Loaded files {}
  637. Merged keys {}",
  638. cwd.display(),
  639. runtime_config.loaded_entries().len(),
  640. runtime_config.merged().len()
  641. ),
  642. "Discovered files".to_string(),
  643. ];
  644. for entry in discovered {
  645. let source = match entry.source {
  646. ConfigSource::User => "user",
  647. ConfigSource::Project => "project",
  648. ConfigSource::Local => "local",
  649. };
  650. let status = if runtime_config
  651. .loaded_entries()
  652. .iter()
  653. .any(|loaded_entry| loaded_entry.path == entry.path)
  654. {
  655. "loaded"
  656. } else {
  657. "missing"
  658. };
  659. lines.push(format!(
  660. " {source:<7} {status:<7} {}",
  661. entry.path.display()
  662. ));
  663. }
  664. lines.push("Merged JSON".to_string());
  665. lines.push(format!(" {}", runtime_config.as_json().render()));
  666. Ok(lines.join(
  667. "
  668. ",
  669. ))
  670. }
  671. fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
  672. let project_context = ProjectContext::discover(env::current_dir()?, DEFAULT_DATE)?;
  673. let mut lines = vec![format!(
  674. "memory: files={}",
  675. project_context.instruction_files.len()
  676. )];
  677. if project_context.instruction_files.is_empty() {
  678. lines.push(
  679. " No CLAUDE instruction files discovered in the current directory ancestry."
  680. .to_string(),
  681. );
  682. } else {
  683. for file in project_context.instruction_files {
  684. let preview = file.content.lines().next().unwrap_or("").trim();
  685. let preview = if preview.is_empty() {
  686. "<empty>"
  687. } else {
  688. preview
  689. };
  690. lines.push(format!(
  691. " {} ({}) {}",
  692. file.path.display(),
  693. file.content.lines().count(),
  694. preview
  695. ));
  696. }
  697. }
  698. Ok(lines.join(
  699. "
  700. ",
  701. ))
  702. }
  703. fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
  704. let cwd = env::current_dir()?;
  705. let claude_md = cwd.join("CLAUDE.md");
  706. if claude_md.exists() {
  707. return Ok(format!(
  708. "init: skipped because {} already exists",
  709. claude_md.display()
  710. ));
  711. }
  712. let content = render_init_claude_md(&cwd);
  713. fs::write(&claude_md, content)?;
  714. Ok(format!("init: created {}", claude_md.display()))
  715. }
  716. fn render_init_claude_md(cwd: &Path) -> String {
  717. let mut lines = vec![
  718. "# CLAUDE.md".to_string(),
  719. String::new(),
  720. "This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.".to_string(),
  721. String::new(),
  722. ];
  723. let mut command_lines = Vec::new();
  724. if cwd.join("rust").join("Cargo.toml").is_file() {
  725. command_lines.push("- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
  726. } else if cwd.join("Cargo.toml").is_file() {
  727. command_lines.push("- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
  728. }
  729. if cwd.join("tests").is_dir() && cwd.join("src").is_dir() {
  730. command_lines.push("- `src/` and `tests/` are also present; check those surfaces before removing or renaming Python-era compatibility assets.".to_string());
  731. }
  732. if !command_lines.is_empty() {
  733. lines.push("## Verification".to_string());
  734. lines.extend(command_lines);
  735. lines.push(String::new());
  736. }
  737. let mut structure_lines = Vec::new();
  738. if cwd.join("rust").is_dir() {
  739. structure_lines.push(
  740. "- `rust/` contains the Rust workspace and the active CLI/runtime implementation."
  741. .to_string(),
  742. );
  743. }
  744. if cwd.join("src").is_dir() {
  745. structure_lines.push("- `src/` contains the older Python-first workspace artifacts referenced by the repo history and tests.".to_string());
  746. }
  747. if cwd.join("tests").is_dir() {
  748. structure_lines.push("- `tests/` exercises compatibility and porting behavior across the repository surfaces.".to_string());
  749. }
  750. if !structure_lines.is_empty() {
  751. lines.push("## Repository shape".to_string());
  752. lines.extend(structure_lines);
  753. lines.push(String::new());
  754. }
  755. lines.push("## Working agreement".to_string());
  756. lines.push("- Prefer small, reviewable Rust changes and keep slash-command behavior aligned between the shared command registry and the CLI entrypoints.".to_string());
  757. lines.push("- Do not overwrite existing CLAUDE.md content automatically; update it intentionally when repo workflows change.".to_string());
  758. lines.push(String::new());
  759. lines.join(
  760. "
  761. ",
  762. )
  763. }
  764. fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
  765. match mode.trim() {
  766. "read-only" => Some("read-only"),
  767. "workspace-write" => Some("workspace-write"),
  768. "danger-full-access" => Some("danger-full-access"),
  769. _ => None,
  770. }
  771. }
  772. fn permission_mode_label() -> &'static str {
  773. match env::var("RUSTY_CLAUDE_PERMISSION_MODE") {
  774. Ok(value) if value == "read-only" => "read-only",
  775. Ok(value) if value == "danger-full-access" => "danger-full-access",
  776. _ => "workspace-write",
  777. }
  778. }
  779. fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
  780. Ok(load_system_prompt(
  781. env::current_dir()?,
  782. DEFAULT_DATE,
  783. env::consts::OS,
  784. "unknown",
  785. )?)
  786. }
  787. fn build_runtime(
  788. session: Session,
  789. model: String,
  790. system_prompt: Vec<String>,
  791. enable_tools: bool,
  792. ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
  793. {
  794. build_runtime_with_permission_mode(
  795. session,
  796. model,
  797. system_prompt,
  798. enable_tools,
  799. permission_mode_label(),
  800. )
  801. }
  802. fn build_runtime_with_permission_mode(
  803. session: Session,
  804. model: String,
  805. system_prompt: Vec<String>,
  806. enable_tools: bool,
  807. permission_mode: &str,
  808. ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
  809. {
  810. Ok(ConversationRuntime::new(
  811. session,
  812. AnthropicRuntimeClient::new(model, enable_tools)?,
  813. CliToolExecutor::new(),
  814. permission_policy(permission_mode),
  815. system_prompt,
  816. ))
  817. }
  818. struct AnthropicRuntimeClient {
  819. runtime: tokio::runtime::Runtime,
  820. client: AnthropicClient,
  821. model: String,
  822. enable_tools: bool,
  823. }
  824. impl AnthropicRuntimeClient {
  825. fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
  826. Ok(Self {
  827. runtime: tokio::runtime::Runtime::new()?,
  828. client: AnthropicClient::from_env()?,
  829. model,
  830. enable_tools,
  831. })
  832. }
  833. }
  834. impl ApiClient for AnthropicRuntimeClient {
  835. #[allow(clippy::too_many_lines)]
  836. fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
  837. let message_request = MessageRequest {
  838. model: self.model.clone(),
  839. max_tokens: DEFAULT_MAX_TOKENS,
  840. messages: convert_messages(&request.messages),
  841. system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
  842. tools: self.enable_tools.then(|| {
  843. mvp_tool_specs()
  844. .into_iter()
  845. .map(|spec| ToolDefinition {
  846. name: spec.name.to_string(),
  847. description: Some(spec.description.to_string()),
  848. input_schema: spec.input_schema,
  849. })
  850. .collect()
  851. }),
  852. tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
  853. stream: true,
  854. };
  855. self.runtime.block_on(async {
  856. let mut stream = self
  857. .client
  858. .stream_message(&message_request)
  859. .await
  860. .map_err(|error| RuntimeError::new(error.to_string()))?;
  861. let mut stdout = io::stdout();
  862. let mut events = Vec::new();
  863. let mut pending_tool: Option<(String, String, String)> = None;
  864. let mut saw_stop = false;
  865. while let Some(event) = stream
  866. .next_event()
  867. .await
  868. .map_err(|error| RuntimeError::new(error.to_string()))?
  869. {
  870. match event {
  871. ApiStreamEvent::MessageStart(start) => {
  872. for block in start.message.content {
  873. push_output_block(block, &mut stdout, &mut events, &mut pending_tool)?;
  874. }
  875. }
  876. ApiStreamEvent::ContentBlockStart(start) => {
  877. push_output_block(
  878. start.content_block,
  879. &mut stdout,
  880. &mut events,
  881. &mut pending_tool,
  882. )?;
  883. }
  884. ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
  885. ContentBlockDelta::TextDelta { text } => {
  886. if !text.is_empty() {
  887. write!(stdout, "{text}")
  888. .and_then(|()| stdout.flush())
  889. .map_err(|error| RuntimeError::new(error.to_string()))?;
  890. events.push(AssistantEvent::TextDelta(text));
  891. }
  892. }
  893. ContentBlockDelta::InputJsonDelta { partial_json } => {
  894. if let Some((_, _, input)) = &mut pending_tool {
  895. input.push_str(&partial_json);
  896. }
  897. }
  898. },
  899. ApiStreamEvent::ContentBlockStop(_) => {
  900. if let Some((id, name, input)) = pending_tool.take() {
  901. events.push(AssistantEvent::ToolUse { id, name, input });
  902. }
  903. }
  904. ApiStreamEvent::MessageDelta(delta) => {
  905. events.push(AssistantEvent::Usage(TokenUsage {
  906. input_tokens: delta.usage.input_tokens,
  907. output_tokens: delta.usage.output_tokens,
  908. cache_creation_input_tokens: 0,
  909. cache_read_input_tokens: 0,
  910. }));
  911. }
  912. ApiStreamEvent::MessageStop(_) => {
  913. saw_stop = true;
  914. events.push(AssistantEvent::MessageStop);
  915. }
  916. }
  917. }
  918. if !saw_stop
  919. && events.iter().any(|event| {
  920. matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
  921. || matches!(event, AssistantEvent::ToolUse { .. })
  922. })
  923. {
  924. events.push(AssistantEvent::MessageStop);
  925. }
  926. if events
  927. .iter()
  928. .any(|event| matches!(event, AssistantEvent::MessageStop))
  929. {
  930. return Ok(events);
  931. }
  932. let response = self
  933. .client
  934. .send_message(&MessageRequest {
  935. stream: false,
  936. ..message_request.clone()
  937. })
  938. .await
  939. .map_err(|error| RuntimeError::new(error.to_string()))?;
  940. response_to_events(response, &mut stdout)
  941. })
  942. }
  943. }
  944. fn push_output_block(
  945. block: OutputContentBlock,
  946. out: &mut impl Write,
  947. events: &mut Vec<AssistantEvent>,
  948. pending_tool: &mut Option<(String, String, String)>,
  949. ) -> Result<(), RuntimeError> {
  950. match block {
  951. OutputContentBlock::Text { text } => {
  952. if !text.is_empty() {
  953. write!(out, "{text}")
  954. .and_then(|()| out.flush())
  955. .map_err(|error| RuntimeError::new(error.to_string()))?;
  956. events.push(AssistantEvent::TextDelta(text));
  957. }
  958. }
  959. OutputContentBlock::ToolUse { id, name, input } => {
  960. *pending_tool = Some((id, name, input.to_string()));
  961. }
  962. }
  963. Ok(())
  964. }
  965. fn response_to_events(
  966. response: MessageResponse,
  967. out: &mut impl Write,
  968. ) -> Result<Vec<AssistantEvent>, RuntimeError> {
  969. let mut events = Vec::new();
  970. let mut pending_tool = None;
  971. for block in response.content {
  972. push_output_block(block, out, &mut events, &mut pending_tool)?;
  973. if let Some((id, name, input)) = pending_tool.take() {
  974. events.push(AssistantEvent::ToolUse { id, name, input });
  975. }
  976. }
  977. events.push(AssistantEvent::Usage(TokenUsage {
  978. input_tokens: response.usage.input_tokens,
  979. output_tokens: response.usage.output_tokens,
  980. cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
  981. cache_read_input_tokens: response.usage.cache_read_input_tokens,
  982. }));
  983. events.push(AssistantEvent::MessageStop);
  984. Ok(events)
  985. }
  986. struct CliToolExecutor {
  987. renderer: TerminalRenderer,
  988. }
  989. impl CliToolExecutor {
  990. fn new() -> Self {
  991. Self {
  992. renderer: TerminalRenderer::new(),
  993. }
  994. }
  995. }
  996. impl ToolExecutor for CliToolExecutor {
  997. fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
  998. let value = serde_json::from_str(input)
  999. .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
  1000. match execute_tool(tool_name, &value) {
  1001. Ok(output) => {
  1002. let markdown = format!("### Tool `{tool_name}`\n\n```json\n{output}\n```\n");
  1003. self.renderer
  1004. .stream_markdown(&markdown, &mut io::stdout())
  1005. .map_err(|error| ToolError::new(error.to_string()))?;
  1006. Ok(output)
  1007. }
  1008. Err(error) => Err(ToolError::new(error)),
  1009. }
  1010. }
  1011. }
  1012. fn permission_policy(mode: &str) -> PermissionPolicy {
  1013. if normalize_permission_mode(mode) == Some("read-only") {
  1014. PermissionPolicy::new(PermissionMode::Deny)
  1015. .with_tool_mode("read_file", PermissionMode::Allow)
  1016. .with_tool_mode("glob_search", PermissionMode::Allow)
  1017. .with_tool_mode("grep_search", PermissionMode::Allow)
  1018. } else {
  1019. PermissionPolicy::new(PermissionMode::Allow)
  1020. }
  1021. }
  1022. fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
  1023. messages
  1024. .iter()
  1025. .filter_map(|message| {
  1026. let role = match message.role {
  1027. MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
  1028. MessageRole::Assistant => "assistant",
  1029. };
  1030. let content = message
  1031. .blocks
  1032. .iter()
  1033. .map(|block| match block {
  1034. ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
  1035. ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
  1036. id: id.clone(),
  1037. name: name.clone(),
  1038. input: serde_json::from_str(input)
  1039. .unwrap_or_else(|_| serde_json::json!({ "raw": input })),
  1040. },
  1041. ContentBlock::ToolResult {
  1042. tool_use_id,
  1043. output,
  1044. is_error,
  1045. ..
  1046. } => InputContentBlock::ToolResult {
  1047. tool_use_id: tool_use_id.clone(),
  1048. content: vec![ToolResultContentBlock::Text {
  1049. text: output.clone(),
  1050. }],
  1051. is_error: *is_error,
  1052. },
  1053. })
  1054. .collect::<Vec<_>>();
  1055. (!content.is_empty()).then(|| InputMessage {
  1056. role: role.to_string(),
  1057. content,
  1058. })
  1059. })
  1060. .collect()
  1061. }
  1062. fn print_help() {
  1063. println!("rusty-claude-cli");
  1064. println!();
  1065. println!("Usage:");
  1066. println!(" rusty-claude-cli [--model MODEL]");
  1067. println!(" Start interactive REPL");
  1068. println!(" rusty-claude-cli [--model MODEL] prompt TEXT");
  1069. println!(" Send one prompt and stream the response");
  1070. println!(" rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]");
  1071. println!(" Inspect or maintain a saved session without entering the REPL");
  1072. println!(" rusty-claude-cli dump-manifests");
  1073. println!(" rusty-claude-cli bootstrap-plan");
  1074. println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
  1075. println!();
  1076. println!("Interactive slash commands:");
  1077. println!("{}", render_slash_command_help());
  1078. println!();
  1079. let resume_commands = resume_supported_slash_commands()
  1080. .into_iter()
  1081. .map(|spec| match spec.argument_hint {
  1082. Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
  1083. None => format!("/{}", spec.name),
  1084. })
  1085. .collect::<Vec<_>>()
  1086. .join(", ");
  1087. println!("Resume-safe commands: {resume_commands}");
  1088. println!("Examples:");
  1089. println!(" rusty-claude-cli --resume session.json /status /compact /cost");
  1090. println!(" rusty-claude-cli --resume session.json /memory /config");
  1091. }
  1092. #[cfg(test)]
  1093. mod tests {
  1094. use super::{
  1095. format_status_report, normalize_permission_mode, parse_args, render_init_claude_md,
  1096. render_repl_help, resume_supported_slash_commands, status_context, CliAction, SlashCommand,
  1097. StatusUsage, DEFAULT_MODEL,
  1098. };
  1099. use runtime::{ContentBlock, ConversationMessage, MessageRole};
  1100. use std::path::{Path, PathBuf};
  1101. #[test]
  1102. fn defaults_to_repl_when_no_args() {
  1103. assert_eq!(
  1104. parse_args(&[]).expect("args should parse"),
  1105. CliAction::Repl {
  1106. model: DEFAULT_MODEL.to_string(),
  1107. }
  1108. );
  1109. }
  1110. #[test]
  1111. fn parses_prompt_subcommand() {
  1112. let args = vec![
  1113. "prompt".to_string(),
  1114. "hello".to_string(),
  1115. "world".to_string(),
  1116. ];
  1117. assert_eq!(
  1118. parse_args(&args).expect("args should parse"),
  1119. CliAction::Prompt {
  1120. prompt: "hello world".to_string(),
  1121. model: DEFAULT_MODEL.to_string(),
  1122. }
  1123. );
  1124. }
  1125. #[test]
  1126. fn parses_system_prompt_options() {
  1127. let args = vec![
  1128. "system-prompt".to_string(),
  1129. "--cwd".to_string(),
  1130. "/tmp/project".to_string(),
  1131. "--date".to_string(),
  1132. "2026-04-01".to_string(),
  1133. ];
  1134. assert_eq!(
  1135. parse_args(&args).expect("args should parse"),
  1136. CliAction::PrintSystemPrompt {
  1137. cwd: PathBuf::from("/tmp/project"),
  1138. date: "2026-04-01".to_string(),
  1139. }
  1140. );
  1141. }
  1142. #[test]
  1143. fn parses_resume_flag_with_slash_command() {
  1144. let args = vec![
  1145. "--resume".to_string(),
  1146. "session.json".to_string(),
  1147. "/compact".to_string(),
  1148. ];
  1149. assert_eq!(
  1150. parse_args(&args).expect("args should parse"),
  1151. CliAction::ResumeSession {
  1152. session_path: PathBuf::from("session.json"),
  1153. commands: vec!["/compact".to_string()],
  1154. }
  1155. );
  1156. }
  1157. #[test]
  1158. fn parses_resume_flag_with_multiple_slash_commands() {
  1159. let args = vec![
  1160. "--resume".to_string(),
  1161. "session.json".to_string(),
  1162. "/status".to_string(),
  1163. "/compact".to_string(),
  1164. "/cost".to_string(),
  1165. ];
  1166. assert_eq!(
  1167. parse_args(&args).expect("args should parse"),
  1168. CliAction::ResumeSession {
  1169. session_path: PathBuf::from("session.json"),
  1170. commands: vec![
  1171. "/status".to_string(),
  1172. "/compact".to_string(),
  1173. "/cost".to_string(),
  1174. ],
  1175. }
  1176. );
  1177. }
  1178. #[test]
  1179. fn repl_help_includes_shared_commands_and_exit() {
  1180. let help = render_repl_help();
  1181. assert!(help.contains("/help"));
  1182. assert!(help.contains("/status"));
  1183. assert!(help.contains("/model [model]"));
  1184. assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
  1185. assert!(help.contains("/clear"));
  1186. assert!(help.contains("/cost"));
  1187. assert!(help.contains("/resume <session-path>"));
  1188. assert!(help.contains("/config"));
  1189. assert!(help.contains("/memory"));
  1190. assert!(help.contains("/init"));
  1191. assert!(help.contains("/exit"));
  1192. }
  1193. #[test]
  1194. fn resume_supported_command_list_matches_expected_surface() {
  1195. let names = resume_supported_slash_commands()
  1196. .into_iter()
  1197. .map(|spec| spec.name)
  1198. .collect::<Vec<_>>();
  1199. assert_eq!(
  1200. names,
  1201. vec!["help", "status", "compact", "clear", "cost", "config", "memory", "init",]
  1202. );
  1203. }
  1204. #[test]
  1205. fn status_line_reports_model_and_token_totals() {
  1206. let status = format_status_report(
  1207. "claude-sonnet",
  1208. StatusUsage {
  1209. message_count: 7,
  1210. turns: 3,
  1211. latest: runtime::TokenUsage {
  1212. input_tokens: 5,
  1213. output_tokens: 4,
  1214. cache_creation_input_tokens: 1,
  1215. cache_read_input_tokens: 0,
  1216. },
  1217. cumulative: runtime::TokenUsage {
  1218. input_tokens: 20,
  1219. output_tokens: 8,
  1220. cache_creation_input_tokens: 2,
  1221. cache_read_input_tokens: 1,
  1222. },
  1223. estimated_tokens: 128,
  1224. },
  1225. "workspace-write",
  1226. &super::StatusContext {
  1227. cwd: PathBuf::from("/tmp/project"),
  1228. session_path: Some(PathBuf::from("session.json")),
  1229. loaded_config_files: 2,
  1230. discovered_config_files: 3,
  1231. memory_file_count: 4,
  1232. },
  1233. );
  1234. assert!(status.contains("Status"));
  1235. assert!(status.contains("Model claude-sonnet"));
  1236. assert!(status.contains("Permission mode workspace-write"));
  1237. assert!(status.contains("Messages 7"));
  1238. assert!(status.contains("Latest total 10"));
  1239. assert!(status.contains("Cumulative total 31"));
  1240. assert!(status.contains("Cwd /tmp/project"));
  1241. assert!(status.contains("Session session.json"));
  1242. assert!(status.contains("Config files loaded 2/3"));
  1243. assert!(status.contains("Memory files 4"));
  1244. }
  1245. #[test]
  1246. fn config_report_uses_sectioned_layout() {
  1247. let report = super::render_config_report().expect("config report should render");
  1248. assert!(report.contains("Config"));
  1249. assert!(report.contains("Discovered files"));
  1250. assert!(report.contains("Merged JSON"));
  1251. }
  1252. #[test]
  1253. fn status_context_reads_real_workspace_metadata() {
  1254. let context = status_context(None).expect("status context should load");
  1255. assert!(context.cwd.is_absolute());
  1256. assert_eq!(context.discovered_config_files, 3);
  1257. assert!(context.loaded_config_files <= context.discovered_config_files);
  1258. }
  1259. #[test]
  1260. fn normalizes_supported_permission_modes() {
  1261. assert_eq!(normalize_permission_mode("read-only"), Some("read-only"));
  1262. assert_eq!(
  1263. normalize_permission_mode("workspace-write"),
  1264. Some("workspace-write")
  1265. );
  1266. assert_eq!(
  1267. normalize_permission_mode("danger-full-access"),
  1268. Some("danger-full-access")
  1269. );
  1270. assert_eq!(normalize_permission_mode("unknown"), None);
  1271. }
  1272. #[test]
  1273. fn parses_resume_and_config_slash_commands() {
  1274. assert_eq!(
  1275. SlashCommand::parse("/resume saved-session.json"),
  1276. Some(SlashCommand::Resume {
  1277. session_path: Some("saved-session.json".to_string())
  1278. })
  1279. );
  1280. assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config));
  1281. assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
  1282. assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
  1283. }
  1284. #[test]
  1285. fn init_template_mentions_detected_rust_workspace() {
  1286. let rendered = render_init_claude_md(Path::new("."));
  1287. assert!(rendered.contains("# CLAUDE.md"));
  1288. assert!(rendered.contains("cargo clippy --workspace --all-targets -- -D warnings"));
  1289. }
  1290. #[test]
  1291. fn converts_tool_roundtrip_messages() {
  1292. let messages = vec![
  1293. ConversationMessage::user_text("hello"),
  1294. ConversationMessage::assistant(vec![ContentBlock::ToolUse {
  1295. id: "tool-1".to_string(),
  1296. name: "bash".to_string(),
  1297. input: "{\"command\":\"pwd\"}".to_string(),
  1298. }]),
  1299. ConversationMessage {
  1300. role: MessageRole::Tool,
  1301. blocks: vec![ContentBlock::ToolResult {
  1302. tool_use_id: "tool-1".to_string(),
  1303. tool_name: "bash".to_string(),
  1304. output: "ok".to_string(),
  1305. is_error: false,
  1306. }],
  1307. usage: None,
  1308. },
  1309. ];
  1310. let converted = super::convert_messages(&messages);
  1311. assert_eq!(converted.len(), 3);
  1312. assert_eq!(converted[1].role, "assistant");
  1313. assert_eq!(converted[2].role, "user");
  1314. }
  1315. }
备用站点 当前处于降级运行的备用站点,仅供应急访问,数据和功能可能不是最新。