main.rs 60 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819
  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::{render_slash_command_help, resume_supported_slash_commands, SlashCommand};
  13. use compat_harness::{extract_manifest, UpstreamPaths};
  14. use render::{Spinner, TerminalRenderer};
  15. use runtime::{
  16. load_system_prompt, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader,
  17. ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole,
  18. PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, Session, TokenUsage, ToolError,
  19. ToolExecutor, UsageTracker,
  20. };
  21. use tools::{execute_tool, mvp_tool_specs};
  22. const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
  23. const DEFAULT_MAX_TOKENS: u32 = 32;
  24. const DEFAULT_DATE: &str = "2026-03-31";
  25. fn main() {
  26. if let Err(error) = run() {
  27. eprintln!("{error}");
  28. std::process::exit(1);
  29. }
  30. }
  31. fn run() -> Result<(), Box<dyn std::error::Error>> {
  32. let args: Vec<String> = env::args().skip(1).collect();
  33. match parse_args(&args)? {
  34. CliAction::DumpManifests => dump_manifests(),
  35. CliAction::BootstrapPlan => print_bootstrap_plan(),
  36. CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
  37. CliAction::ResumeSession {
  38. session_path,
  39. commands,
  40. } => resume_session(&session_path, &commands),
  41. CliAction::Prompt { prompt, model } => LiveCli::new(model, false)?.run_turn(&prompt)?,
  42. CliAction::Repl { model } => run_repl(model)?,
  43. CliAction::Help => print_help(),
  44. }
  45. Ok(())
  46. }
  47. #[derive(Debug, Clone, PartialEq, Eq)]
  48. enum CliAction {
  49. DumpManifests,
  50. BootstrapPlan,
  51. PrintSystemPrompt {
  52. cwd: PathBuf,
  53. date: String,
  54. },
  55. ResumeSession {
  56. session_path: PathBuf,
  57. commands: Vec<String>,
  58. },
  59. Prompt {
  60. prompt: String,
  61. model: String,
  62. },
  63. Repl {
  64. model: String,
  65. },
  66. Help,
  67. }
  68. fn parse_args(args: &[String]) -> Result<CliAction, String> {
  69. let mut model = DEFAULT_MODEL.to_string();
  70. let mut rest = Vec::new();
  71. let mut index = 0;
  72. while index < args.len() {
  73. match args[index].as_str() {
  74. "--model" => {
  75. let value = args
  76. .get(index + 1)
  77. .ok_or_else(|| "missing value for --model".to_string())?;
  78. model.clone_from(value);
  79. index += 2;
  80. }
  81. flag if flag.starts_with("--model=") => {
  82. model = flag[8..].to_string();
  83. index += 1;
  84. }
  85. other => {
  86. rest.push(other.to_string());
  87. index += 1;
  88. }
  89. }
  90. }
  91. if rest.is_empty() {
  92. return Ok(CliAction::Repl { model });
  93. }
  94. if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
  95. return Ok(CliAction::Help);
  96. }
  97. if rest.first().map(String::as_str) == Some("--resume") {
  98. return parse_resume_args(&rest[1..]);
  99. }
  100. match rest[0].as_str() {
  101. "dump-manifests" => Ok(CliAction::DumpManifests),
  102. "bootstrap-plan" => Ok(CliAction::BootstrapPlan),
  103. "system-prompt" => parse_system_prompt_args(&rest[1..]),
  104. "prompt" => {
  105. let prompt = rest[1..].join(" ");
  106. if prompt.trim().is_empty() {
  107. return Err("prompt subcommand requires a prompt string".to_string());
  108. }
  109. Ok(CliAction::Prompt { prompt, model })
  110. }
  111. other => Err(format!("unknown subcommand: {other}")),
  112. }
  113. }
  114. fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
  115. let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
  116. let mut date = DEFAULT_DATE.to_string();
  117. let mut index = 0;
  118. while index < args.len() {
  119. match args[index].as_str() {
  120. "--cwd" => {
  121. let value = args
  122. .get(index + 1)
  123. .ok_or_else(|| "missing value for --cwd".to_string())?;
  124. cwd = PathBuf::from(value);
  125. index += 2;
  126. }
  127. "--date" => {
  128. let value = args
  129. .get(index + 1)
  130. .ok_or_else(|| "missing value for --date".to_string())?;
  131. date.clone_from(value);
  132. index += 2;
  133. }
  134. other => return Err(format!("unknown system-prompt option: {other}")),
  135. }
  136. }
  137. Ok(CliAction::PrintSystemPrompt { cwd, date })
  138. }
  139. fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
  140. let session_path = args
  141. .first()
  142. .ok_or_else(|| "missing session path for --resume".to_string())
  143. .map(PathBuf::from)?;
  144. let commands = args[1..].to_vec();
  145. if commands
  146. .iter()
  147. .any(|command| !command.trim_start().starts_with('/'))
  148. {
  149. return Err("--resume trailing arguments must be slash commands".to_string());
  150. }
  151. Ok(CliAction::ResumeSession {
  152. session_path,
  153. commands,
  154. })
  155. }
  156. fn dump_manifests() {
  157. let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
  158. let paths = UpstreamPaths::from_workspace_dir(&workspace_dir);
  159. match extract_manifest(&paths) {
  160. Ok(manifest) => {
  161. println!("commands: {}", manifest.commands.entries().len());
  162. println!("tools: {}", manifest.tools.entries().len());
  163. println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
  164. }
  165. Err(error) => {
  166. eprintln!("failed to extract manifests: {error}");
  167. std::process::exit(1);
  168. }
  169. }
  170. }
  171. fn print_bootstrap_plan() {
  172. for phase in runtime::BootstrapPlan::claude_code_default().phases() {
  173. println!("- {phase:?}");
  174. }
  175. }
  176. fn print_system_prompt(cwd: PathBuf, date: String) {
  177. match load_system_prompt(cwd, date, env::consts::OS, "unknown") {
  178. Ok(sections) => println!("{}", sections.join("\n\n")),
  179. Err(error) => {
  180. eprintln!("failed to build system prompt: {error}");
  181. std::process::exit(1);
  182. }
  183. }
  184. }
  185. fn resume_session(session_path: &Path, commands: &[String]) {
  186. let session = match Session::load_from_path(session_path) {
  187. Ok(session) => session,
  188. Err(error) => {
  189. eprintln!("failed to restore session: {error}");
  190. std::process::exit(1);
  191. }
  192. };
  193. if commands.is_empty() {
  194. println!(
  195. "Restored session from {} ({} messages).",
  196. session_path.display(),
  197. session.messages.len()
  198. );
  199. return;
  200. }
  201. let mut session = session;
  202. for raw_command in commands {
  203. let Some(command) = SlashCommand::parse(raw_command) else {
  204. eprintln!("unsupported resumed command: {raw_command}");
  205. std::process::exit(2);
  206. };
  207. match run_resume_command(session_path, &session, &command) {
  208. Ok(ResumeCommandOutcome {
  209. session: next_session,
  210. message,
  211. }) => {
  212. session = next_session;
  213. if let Some(message) = message {
  214. println!("{message}");
  215. }
  216. }
  217. Err(error) => {
  218. eprintln!("{error}");
  219. std::process::exit(2);
  220. }
  221. }
  222. }
  223. }
  224. #[derive(Debug, Clone)]
  225. struct ResumeCommandOutcome {
  226. session: Session,
  227. message: Option<String>,
  228. }
  229. #[derive(Debug, Clone)]
  230. struct StatusContext {
  231. cwd: PathBuf,
  232. session_path: Option<PathBuf>,
  233. loaded_config_files: usize,
  234. discovered_config_files: usize,
  235. memory_file_count: usize,
  236. project_root: Option<PathBuf>,
  237. git_branch: Option<String>,
  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 format_model_report(model: &str, message_count: usize, turns: u32) -> String {
  248. format!(
  249. "Model
  250. Current model {model}
  251. Session messages {message_count}
  252. Session turns {turns}
  253. Usage
  254. Inspect current model with /model
  255. Switch models with /model <name>"
  256. )
  257. }
  258. fn format_model_switch_report(previous: &str, next: &str, message_count: usize) -> String {
  259. format!(
  260. "Model updated
  261. Previous {previous}
  262. Current {next}
  263. Preserved msgs {message_count}"
  264. )
  265. }
  266. fn format_permissions_report(mode: &str) -> String {
  267. let modes = [
  268. ("read-only", "Read/search tools only", mode == "read-only"),
  269. (
  270. "workspace-write",
  271. "Edit files inside the workspace",
  272. mode == "workspace-write",
  273. ),
  274. (
  275. "danger-full-access",
  276. "Unrestricted tool access",
  277. mode == "danger-full-access",
  278. ),
  279. ]
  280. .into_iter()
  281. .map(|(name, description, is_current)| {
  282. let marker = if is_current {
  283. "● current"
  284. } else {
  285. "○ available"
  286. };
  287. format!(" {name:<18} {marker:<11} {description}")
  288. })
  289. .collect::<Vec<_>>()
  290. .join(
  291. "
  292. ",
  293. );
  294. format!(
  295. "Permissions
  296. Active mode {mode}
  297. Mode status live session default
  298. Modes
  299. {modes}
  300. Usage
  301. Inspect current mode with /permissions
  302. Switch modes with /permissions <mode>"
  303. )
  304. }
  305. fn format_permissions_switch_report(previous: &str, next: &str) -> String {
  306. format!(
  307. "Permissions updated
  308. Result mode switched
  309. Previous mode {previous}
  310. Active mode {next}
  311. Applies to subsequent tool calls
  312. Usage /permissions to inspect current mode"
  313. )
  314. }
  315. fn format_cost_report(usage: TokenUsage) -> String {
  316. format!(
  317. "Cost
  318. Input tokens {}
  319. Output tokens {}
  320. Cache create {}
  321. Cache read {}
  322. Total tokens {}",
  323. usage.input_tokens,
  324. usage.output_tokens,
  325. usage.cache_creation_input_tokens,
  326. usage.cache_read_input_tokens,
  327. usage.total_tokens(),
  328. )
  329. }
  330. fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String {
  331. format!(
  332. "Session resumed
  333. Session file {session_path}
  334. Messages {message_count}
  335. Turns {turns}"
  336. )
  337. }
  338. fn format_init_report(path: &Path, created: bool) -> String {
  339. if created {
  340. format!(
  341. "Init
  342. CLAUDE.md {}
  343. Result created
  344. Next step Review and tailor the generated guidance",
  345. path.display()
  346. )
  347. } else {
  348. format!(
  349. "Init
  350. CLAUDE.md {}
  351. Result skipped (already exists)
  352. Next step Edit the existing file intentionally if workflows changed",
  353. path.display()
  354. )
  355. }
  356. }
  357. fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String {
  358. if skipped {
  359. format!(
  360. "Compact
  361. Result skipped
  362. Reason session below compaction threshold
  363. Messages kept {resulting_messages}"
  364. )
  365. } else {
  366. format!(
  367. "Compact
  368. Result compacted
  369. Messages removed {removed}
  370. Messages kept {resulting_messages}"
  371. )
  372. }
  373. }
  374. fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<String>) {
  375. let Some(status) = status else {
  376. return (None, None);
  377. };
  378. let branch = status.lines().next().and_then(|line| {
  379. line.strip_prefix("## ")
  380. .map(|line| {
  381. line.split(['.', ' '])
  382. .next()
  383. .unwrap_or_default()
  384. .to_string()
  385. })
  386. .filter(|value| !value.is_empty())
  387. });
  388. let project_root = find_git_root().ok();
  389. (project_root, branch)
  390. }
  391. fn find_git_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
  392. let output = std::process::Command::new("git")
  393. .args(["rev-parse", "--show-toplevel"])
  394. .current_dir(env::current_dir()?)
  395. .output()?;
  396. if !output.status.success() {
  397. return Err("not a git repository".into());
  398. }
  399. let path = String::from_utf8(output.stdout)?.trim().to_string();
  400. if path.is_empty() {
  401. return Err("empty git root".into());
  402. }
  403. Ok(PathBuf::from(path))
  404. }
  405. fn run_resume_command(
  406. session_path: &Path,
  407. session: &Session,
  408. command: &SlashCommand,
  409. ) -> Result<ResumeCommandOutcome, Box<dyn std::error::Error>> {
  410. match command {
  411. SlashCommand::Help => Ok(ResumeCommandOutcome {
  412. session: session.clone(),
  413. message: Some(render_repl_help()),
  414. }),
  415. SlashCommand::Compact => {
  416. let result = runtime::compact_session(
  417. session,
  418. CompactionConfig {
  419. max_estimated_tokens: 0,
  420. ..CompactionConfig::default()
  421. },
  422. );
  423. let removed = result.removed_message_count;
  424. let kept = result.compacted_session.messages.len();
  425. let skipped = removed == 0;
  426. result.compacted_session.save_to_path(session_path)?;
  427. Ok(ResumeCommandOutcome {
  428. session: result.compacted_session,
  429. message: Some(format_compact_report(removed, kept, skipped)),
  430. })
  431. }
  432. SlashCommand::Clear { confirm } => {
  433. if !confirm {
  434. return Ok(ResumeCommandOutcome {
  435. session: session.clone(),
  436. message: Some(
  437. "clear: confirmation required; rerun with /clear --confirm".to_string(),
  438. ),
  439. });
  440. }
  441. let cleared = Session::new();
  442. cleared.save_to_path(session_path)?;
  443. Ok(ResumeCommandOutcome {
  444. session: cleared,
  445. message: Some(format!(
  446. "Cleared resumed session file {}.",
  447. session_path.display()
  448. )),
  449. })
  450. }
  451. SlashCommand::Status => {
  452. let tracker = UsageTracker::from_session(session);
  453. let usage = tracker.cumulative_usage();
  454. Ok(ResumeCommandOutcome {
  455. session: session.clone(),
  456. message: Some(format_status_report(
  457. "restored-session",
  458. StatusUsage {
  459. message_count: session.messages.len(),
  460. turns: tracker.turns(),
  461. latest: tracker.current_turn_usage(),
  462. cumulative: usage,
  463. estimated_tokens: 0,
  464. },
  465. permission_mode_label(),
  466. &status_context(Some(session_path))?,
  467. )),
  468. })
  469. }
  470. SlashCommand::Cost => {
  471. let usage = UsageTracker::from_session(session).cumulative_usage();
  472. Ok(ResumeCommandOutcome {
  473. session: session.clone(),
  474. message: Some(format_cost_report(usage)),
  475. })
  476. }
  477. SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
  478. session: session.clone(),
  479. message: Some(render_config_report(section.as_deref())?),
  480. }),
  481. SlashCommand::Memory => Ok(ResumeCommandOutcome {
  482. session: session.clone(),
  483. message: Some(render_memory_report()?),
  484. }),
  485. SlashCommand::Init => Ok(ResumeCommandOutcome {
  486. session: session.clone(),
  487. message: Some(init_claude_md()?),
  488. }),
  489. SlashCommand::Resume { .. }
  490. | SlashCommand::Model { .. }
  491. | SlashCommand::Permissions { .. }
  492. | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
  493. }
  494. }
  495. fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> {
  496. let mut cli = LiveCli::new(model, true)?;
  497. let editor = input::LineEditor::new("› ");
  498. println!("Rusty Claude CLI interactive mode");
  499. println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
  500. while let Some(input) = editor.read_line()? {
  501. let trimmed = input.trim();
  502. if trimmed.is_empty() {
  503. continue;
  504. }
  505. if matches!(trimmed, "/exit" | "/quit") {
  506. break;
  507. }
  508. if let Some(command) = SlashCommand::parse(trimmed) {
  509. cli.handle_repl_command(command)?;
  510. continue;
  511. }
  512. cli.run_turn(trimmed)?;
  513. }
  514. Ok(())
  515. }
  516. struct LiveCli {
  517. model: String,
  518. system_prompt: Vec<String>,
  519. runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
  520. }
  521. impl LiveCli {
  522. fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
  523. let system_prompt = build_system_prompt()?;
  524. let runtime = build_runtime(
  525. Session::new(),
  526. model.clone(),
  527. system_prompt.clone(),
  528. enable_tools,
  529. )?;
  530. Ok(Self {
  531. model,
  532. system_prompt,
  533. runtime,
  534. })
  535. }
  536. fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
  537. let mut spinner = Spinner::new();
  538. let mut stdout = io::stdout();
  539. spinner.tick(
  540. "Waiting for Claude",
  541. TerminalRenderer::new().color_theme(),
  542. &mut stdout,
  543. )?;
  544. let result = self.runtime.run_turn(input, None);
  545. match result {
  546. Ok(_) => {
  547. spinner.finish(
  548. "Claude response complete",
  549. TerminalRenderer::new().color_theme(),
  550. &mut stdout,
  551. )?;
  552. println!();
  553. Ok(())
  554. }
  555. Err(error) => {
  556. spinner.fail(
  557. "Claude request failed",
  558. TerminalRenderer::new().color_theme(),
  559. &mut stdout,
  560. )?;
  561. Err(Box::new(error))
  562. }
  563. }
  564. }
  565. fn handle_repl_command(
  566. &mut self,
  567. command: SlashCommand,
  568. ) -> Result<(), Box<dyn std::error::Error>> {
  569. match command {
  570. SlashCommand::Help => println!("{}", render_repl_help()),
  571. SlashCommand::Status => self.print_status(),
  572. SlashCommand::Compact => self.compact()?,
  573. SlashCommand::Model { model } => self.set_model(model)?,
  574. SlashCommand::Permissions { mode } => self.set_permissions(mode)?,
  575. SlashCommand::Clear { confirm } => self.clear_session(confirm)?,
  576. SlashCommand::Cost => self.print_cost(),
  577. SlashCommand::Resume { session_path } => self.resume_session(session_path)?,
  578. SlashCommand::Config { section } => Self::print_config(section.as_deref())?,
  579. SlashCommand::Memory => Self::print_memory()?,
  580. SlashCommand::Init => Self::run_init()?,
  581. SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
  582. }
  583. Ok(())
  584. }
  585. fn print_status(&self) {
  586. let cumulative = self.runtime.usage().cumulative_usage();
  587. let latest = self.runtime.usage().current_turn_usage();
  588. println!(
  589. "{}",
  590. format_status_report(
  591. &self.model,
  592. StatusUsage {
  593. message_count: self.runtime.session().messages.len(),
  594. turns: self.runtime.usage().turns(),
  595. latest,
  596. cumulative,
  597. estimated_tokens: self.runtime.estimated_tokens(),
  598. },
  599. permission_mode_label(),
  600. &status_context(None).expect("status context should load"),
  601. )
  602. );
  603. }
  604. fn set_model(&mut self, model: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
  605. let Some(model) = model else {
  606. println!(
  607. "{}",
  608. format_model_report(
  609. &self.model,
  610. self.runtime.session().messages.len(),
  611. self.runtime.usage().turns(),
  612. )
  613. );
  614. return Ok(());
  615. };
  616. if model == self.model {
  617. println!(
  618. "{}",
  619. format_model_report(
  620. &self.model,
  621. self.runtime.session().messages.len(),
  622. self.runtime.usage().turns(),
  623. )
  624. );
  625. return Ok(());
  626. }
  627. let previous = self.model.clone();
  628. let session = self.runtime.session().clone();
  629. let message_count = session.messages.len();
  630. self.runtime = build_runtime(session, model.clone(), self.system_prompt.clone(), true)?;
  631. self.model.clone_from(&model);
  632. println!(
  633. "{}",
  634. format_model_switch_report(&previous, &model, message_count)
  635. );
  636. Ok(())
  637. }
  638. fn set_permissions(&mut self, mode: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
  639. let Some(mode) = mode else {
  640. println!("{}", format_permissions_report(permission_mode_label()));
  641. return Ok(());
  642. };
  643. let normalized = normalize_permission_mode(&mode).ok_or_else(|| {
  644. format!(
  645. "Unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access."
  646. )
  647. })?;
  648. if normalized == permission_mode_label() {
  649. println!("{}", format_permissions_report(normalized));
  650. return Ok(());
  651. }
  652. let previous = permission_mode_label().to_string();
  653. let session = self.runtime.session().clone();
  654. self.runtime = build_runtime_with_permission_mode(
  655. session,
  656. self.model.clone(),
  657. self.system_prompt.clone(),
  658. true,
  659. normalized,
  660. )?;
  661. println!(
  662. "{}",
  663. format_permissions_switch_report(&previous, normalized)
  664. );
  665. Ok(())
  666. }
  667. fn clear_session(&mut self, confirm: bool) -> Result<(), Box<dyn std::error::Error>> {
  668. if !confirm {
  669. println!(
  670. "clear: confirmation required; run /clear --confirm to start a fresh session."
  671. );
  672. return Ok(());
  673. }
  674. self.runtime = build_runtime_with_permission_mode(
  675. Session::new(),
  676. self.model.clone(),
  677. self.system_prompt.clone(),
  678. true,
  679. permission_mode_label(),
  680. )?;
  681. println!(
  682. "Session cleared
  683. Mode fresh session
  684. Preserved model {}
  685. Permission mode {}",
  686. self.model,
  687. permission_mode_label()
  688. );
  689. Ok(())
  690. }
  691. fn print_cost(&self) {
  692. let cumulative = self.runtime.usage().cumulative_usage();
  693. println!("{}", format_cost_report(cumulative));
  694. }
  695. fn resume_session(
  696. &mut self,
  697. session_path: Option<String>,
  698. ) -> Result<(), Box<dyn std::error::Error>> {
  699. let Some(session_path) = session_path else {
  700. println!("Usage: /resume <session-path>");
  701. return Ok(());
  702. };
  703. let session = Session::load_from_path(&session_path)?;
  704. let message_count = session.messages.len();
  705. self.runtime = build_runtime_with_permission_mode(
  706. session,
  707. self.model.clone(),
  708. self.system_prompt.clone(),
  709. true,
  710. permission_mode_label(),
  711. )?;
  712. println!(
  713. "{}",
  714. format_resume_report(&session_path, message_count, self.runtime.usage().turns())
  715. );
  716. Ok(())
  717. }
  718. fn print_config(section: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
  719. println!("{}", render_config_report(section)?);
  720. Ok(())
  721. }
  722. fn print_memory() -> Result<(), Box<dyn std::error::Error>> {
  723. println!("{}", render_memory_report()?);
  724. Ok(())
  725. }
  726. fn run_init() -> Result<(), Box<dyn std::error::Error>> {
  727. println!("{}", init_claude_md()?);
  728. Ok(())
  729. }
  730. fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
  731. let result = self.runtime.compact(CompactionConfig::default());
  732. let removed = result.removed_message_count;
  733. let kept = result.compacted_session.messages.len();
  734. let skipped = removed == 0;
  735. self.runtime = build_runtime_with_permission_mode(
  736. result.compacted_session,
  737. self.model.clone(),
  738. self.system_prompt.clone(),
  739. true,
  740. permission_mode_label(),
  741. )?;
  742. println!("{}", format_compact_report(removed, kept, skipped));
  743. Ok(())
  744. }
  745. }
  746. fn render_repl_help() -> String {
  747. [
  748. "REPL".to_string(),
  749. " /exit Quit the REPL".to_string(),
  750. " /quit Quit the REPL".to_string(),
  751. String::new(),
  752. render_slash_command_help(),
  753. ]
  754. .join(
  755. "
  756. ",
  757. )
  758. }
  759. fn status_context(
  760. session_path: Option<&Path>,
  761. ) -> Result<StatusContext, Box<dyn std::error::Error>> {
  762. let cwd = env::current_dir()?;
  763. let loader = ConfigLoader::default_for(&cwd);
  764. let discovered_config_files = loader.discover().len();
  765. let runtime_config = loader.load()?;
  766. let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
  767. let (project_root, git_branch) =
  768. parse_git_status_metadata(project_context.git_status.as_deref());
  769. Ok(StatusContext {
  770. cwd,
  771. session_path: session_path.map(Path::to_path_buf),
  772. loaded_config_files: runtime_config.loaded_entries().len(),
  773. discovered_config_files,
  774. memory_file_count: project_context.instruction_files.len(),
  775. project_root,
  776. git_branch,
  777. })
  778. }
  779. fn format_status_report(
  780. model: &str,
  781. usage: StatusUsage,
  782. permission_mode: &str,
  783. context: &StatusContext,
  784. ) -> String {
  785. [
  786. format!(
  787. "Status
  788. Model {model}
  789. Permission mode {permission_mode}
  790. Messages {}
  791. Turns {}
  792. Estimated tokens {}",
  793. usage.message_count, usage.turns, usage.estimated_tokens,
  794. ),
  795. format!(
  796. "Usage
  797. Latest total {}
  798. Cumulative input {}
  799. Cumulative output {}
  800. Cumulative total {}",
  801. usage.latest.total_tokens(),
  802. usage.cumulative.input_tokens,
  803. usage.cumulative.output_tokens,
  804. usage.cumulative.total_tokens(),
  805. ),
  806. format!(
  807. "Workspace
  808. Cwd {}
  809. Project root {}
  810. Git branch {}
  811. Session {}
  812. Config files loaded {}/{}
  813. Memory files {}",
  814. context.cwd.display(),
  815. context
  816. .project_root
  817. .as_ref()
  818. .map_or_else(|| "unknown".to_string(), |path| path.display().to_string()),
  819. context.git_branch.as_deref().unwrap_or("unknown"),
  820. context.session_path.as_ref().map_or_else(
  821. || "live-repl".to_string(),
  822. |path| path.display().to_string()
  823. ),
  824. context.loaded_config_files,
  825. context.discovered_config_files,
  826. context.memory_file_count,
  827. ),
  828. ]
  829. .join(
  830. "
  831. ",
  832. )
  833. }
  834. fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::error::Error>> {
  835. let cwd = env::current_dir()?;
  836. let loader = ConfigLoader::default_for(&cwd);
  837. let discovered = loader.discover();
  838. let runtime_config = loader.load()?;
  839. let mut lines = vec![
  840. format!(
  841. "Config
  842. Working directory {}
  843. Loaded files {}
  844. Merged keys {}",
  845. cwd.display(),
  846. runtime_config.loaded_entries().len(),
  847. runtime_config.merged().len()
  848. ),
  849. "Discovered files".to_string(),
  850. ];
  851. for entry in discovered {
  852. let source = match entry.source {
  853. ConfigSource::User => "user",
  854. ConfigSource::Project => "project",
  855. ConfigSource::Local => "local",
  856. };
  857. let status = if runtime_config
  858. .loaded_entries()
  859. .iter()
  860. .any(|loaded_entry| loaded_entry.path == entry.path)
  861. {
  862. "loaded"
  863. } else {
  864. "missing"
  865. };
  866. lines.push(format!(
  867. " {source:<7} {status:<7} {}",
  868. entry.path.display()
  869. ));
  870. }
  871. if let Some(section) = section {
  872. lines.push(format!("Merged section: {section}"));
  873. let value = match section {
  874. "env" => runtime_config.get("env"),
  875. "hooks" => runtime_config.get("hooks"),
  876. "model" => runtime_config.get("model"),
  877. other => {
  878. lines.push(format!(
  879. " Unsupported config section '{other}'. Use env, hooks, or model."
  880. ));
  881. return Ok(lines.join(
  882. "
  883. ",
  884. ));
  885. }
  886. };
  887. lines.push(format!(
  888. " {}",
  889. match value {
  890. Some(value) => value.render(),
  891. None => "<unset>".to_string(),
  892. }
  893. ));
  894. return Ok(lines.join(
  895. "
  896. ",
  897. ));
  898. }
  899. lines.push("Merged JSON".to_string());
  900. lines.push(format!(" {}", runtime_config.as_json().render()));
  901. Ok(lines.join(
  902. "
  903. ",
  904. ))
  905. }
  906. fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
  907. let cwd = env::current_dir()?;
  908. let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
  909. let mut lines = vec![format!(
  910. "Memory
  911. Working directory {}
  912. Instruction files {}",
  913. cwd.display(),
  914. project_context.instruction_files.len()
  915. )];
  916. if project_context.instruction_files.is_empty() {
  917. lines.push("Discovered files".to_string());
  918. lines.push(
  919. " No CLAUDE instruction files discovered in the current directory ancestry."
  920. .to_string(),
  921. );
  922. } else {
  923. lines.push("Discovered files".to_string());
  924. for (index, file) in project_context.instruction_files.iter().enumerate() {
  925. let preview = file.content.lines().next().unwrap_or("").trim();
  926. let preview = if preview.is_empty() {
  927. "<empty>"
  928. } else {
  929. preview
  930. };
  931. lines.push(format!(" {}. {}", index + 1, file.path.display(),));
  932. lines.push(format!(
  933. " lines={} preview={}",
  934. file.content.lines().count(),
  935. preview
  936. ));
  937. }
  938. }
  939. Ok(lines.join(
  940. "
  941. ",
  942. ))
  943. }
  944. fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
  945. let cwd = env::current_dir()?;
  946. let claude_md = cwd.join("CLAUDE.md");
  947. if claude_md.exists() {
  948. return Ok(format_init_report(&claude_md, false));
  949. }
  950. let content = render_init_claude_md(&cwd);
  951. fs::write(&claude_md, content)?;
  952. Ok(format_init_report(&claude_md, true))
  953. }
  954. fn render_init_claude_md(cwd: &Path) -> String {
  955. let mut lines = vec![
  956. "# CLAUDE.md".to_string(),
  957. String::new(),
  958. "This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.".to_string(),
  959. String::new(),
  960. ];
  961. let mut command_lines = Vec::new();
  962. if cwd.join("rust").join("Cargo.toml").is_file() {
  963. command_lines.push("- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
  964. } else if cwd.join("Cargo.toml").is_file() {
  965. command_lines.push("- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
  966. }
  967. if cwd.join("tests").is_dir() && cwd.join("src").is_dir() {
  968. command_lines.push("- `src/` and `tests/` are also present; check those surfaces before removing or renaming Python-era compatibility assets.".to_string());
  969. }
  970. if !command_lines.is_empty() {
  971. lines.push("## Verification".to_string());
  972. lines.extend(command_lines);
  973. lines.push(String::new());
  974. }
  975. let mut structure_lines = Vec::new();
  976. if cwd.join("rust").is_dir() {
  977. structure_lines.push(
  978. "- `rust/` contains the Rust workspace and the active CLI/runtime implementation."
  979. .to_string(),
  980. );
  981. }
  982. if cwd.join("src").is_dir() {
  983. structure_lines.push("- `src/` contains the older Python-first workspace artifacts referenced by the repo history and tests.".to_string());
  984. }
  985. if cwd.join("tests").is_dir() {
  986. structure_lines.push("- `tests/` exercises compatibility and porting behavior across the repository surfaces.".to_string());
  987. }
  988. if !structure_lines.is_empty() {
  989. lines.push("## Repository shape".to_string());
  990. lines.extend(structure_lines);
  991. lines.push(String::new());
  992. }
  993. lines.push("## Working agreement".to_string());
  994. lines.push("- Prefer small, reviewable Rust changes and keep slash-command behavior aligned between the shared command registry and the CLI entrypoints.".to_string());
  995. lines.push("- Do not overwrite existing CLAUDE.md content automatically; update it intentionally when repo workflows change.".to_string());
  996. lines.push(String::new());
  997. lines.join(
  998. "
  999. ",
  1000. )
  1001. }
  1002. fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
  1003. match mode.trim() {
  1004. "read-only" => Some("read-only"),
  1005. "workspace-write" => Some("workspace-write"),
  1006. "danger-full-access" => Some("danger-full-access"),
  1007. _ => None,
  1008. }
  1009. }
  1010. fn permission_mode_label() -> &'static str {
  1011. match env::var("RUSTY_CLAUDE_PERMISSION_MODE") {
  1012. Ok(value) if value == "read-only" => "read-only",
  1013. Ok(value) if value == "danger-full-access" => "danger-full-access",
  1014. _ => "workspace-write",
  1015. }
  1016. }
  1017. fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
  1018. Ok(load_system_prompt(
  1019. env::current_dir()?,
  1020. DEFAULT_DATE,
  1021. env::consts::OS,
  1022. "unknown",
  1023. )?)
  1024. }
  1025. fn build_runtime(
  1026. session: Session,
  1027. model: String,
  1028. system_prompt: Vec<String>,
  1029. enable_tools: bool,
  1030. ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
  1031. {
  1032. build_runtime_with_permission_mode(
  1033. session,
  1034. model,
  1035. system_prompt,
  1036. enable_tools,
  1037. permission_mode_label(),
  1038. )
  1039. }
  1040. fn build_runtime_with_permission_mode(
  1041. session: Session,
  1042. model: String,
  1043. system_prompt: Vec<String>,
  1044. enable_tools: bool,
  1045. permission_mode: &str,
  1046. ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
  1047. {
  1048. Ok(ConversationRuntime::new(
  1049. session,
  1050. AnthropicRuntimeClient::new(model, enable_tools)?,
  1051. CliToolExecutor::new(),
  1052. permission_policy(permission_mode),
  1053. system_prompt,
  1054. ))
  1055. }
  1056. struct AnthropicRuntimeClient {
  1057. runtime: tokio::runtime::Runtime,
  1058. client: AnthropicClient,
  1059. model: String,
  1060. enable_tools: bool,
  1061. }
  1062. impl AnthropicRuntimeClient {
  1063. fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
  1064. Ok(Self {
  1065. runtime: tokio::runtime::Runtime::new()?,
  1066. client: AnthropicClient::from_env()?,
  1067. model,
  1068. enable_tools,
  1069. })
  1070. }
  1071. }
  1072. impl ApiClient for AnthropicRuntimeClient {
  1073. #[allow(clippy::too_many_lines)]
  1074. fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
  1075. let message_request = MessageRequest {
  1076. model: self.model.clone(),
  1077. max_tokens: DEFAULT_MAX_TOKENS,
  1078. messages: convert_messages(&request.messages),
  1079. system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
  1080. tools: self.enable_tools.then(|| {
  1081. mvp_tool_specs()
  1082. .into_iter()
  1083. .map(|spec| ToolDefinition {
  1084. name: spec.name.to_string(),
  1085. description: Some(spec.description.to_string()),
  1086. input_schema: spec.input_schema,
  1087. })
  1088. .collect()
  1089. }),
  1090. tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
  1091. stream: true,
  1092. };
  1093. self.runtime.block_on(async {
  1094. let mut stream = self
  1095. .client
  1096. .stream_message(&message_request)
  1097. .await
  1098. .map_err(|error| RuntimeError::new(error.to_string()))?;
  1099. let mut stdout = io::stdout();
  1100. let mut events = Vec::new();
  1101. let mut pending_tool: Option<(String, String, String)> = None;
  1102. let mut saw_stop = false;
  1103. while let Some(event) = stream
  1104. .next_event()
  1105. .await
  1106. .map_err(|error| RuntimeError::new(error.to_string()))?
  1107. {
  1108. match event {
  1109. ApiStreamEvent::MessageStart(start) => {
  1110. for block in start.message.content {
  1111. push_output_block(block, &mut stdout, &mut events, &mut pending_tool)?;
  1112. }
  1113. }
  1114. ApiStreamEvent::ContentBlockStart(start) => {
  1115. push_output_block(
  1116. start.content_block,
  1117. &mut stdout,
  1118. &mut events,
  1119. &mut pending_tool,
  1120. )?;
  1121. }
  1122. ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
  1123. ContentBlockDelta::TextDelta { text } => {
  1124. if !text.is_empty() {
  1125. write!(stdout, "{text}")
  1126. .and_then(|()| stdout.flush())
  1127. .map_err(|error| RuntimeError::new(error.to_string()))?;
  1128. events.push(AssistantEvent::TextDelta(text));
  1129. }
  1130. }
  1131. ContentBlockDelta::InputJsonDelta { partial_json } => {
  1132. if let Some((_, _, input)) = &mut pending_tool {
  1133. input.push_str(&partial_json);
  1134. }
  1135. }
  1136. },
  1137. ApiStreamEvent::ContentBlockStop(_) => {
  1138. if let Some((id, name, input)) = pending_tool.take() {
  1139. events.push(AssistantEvent::ToolUse { id, name, input });
  1140. }
  1141. }
  1142. ApiStreamEvent::MessageDelta(delta) => {
  1143. events.push(AssistantEvent::Usage(TokenUsage {
  1144. input_tokens: delta.usage.input_tokens,
  1145. output_tokens: delta.usage.output_tokens,
  1146. cache_creation_input_tokens: 0,
  1147. cache_read_input_tokens: 0,
  1148. }));
  1149. }
  1150. ApiStreamEvent::MessageStop(_) => {
  1151. saw_stop = true;
  1152. events.push(AssistantEvent::MessageStop);
  1153. }
  1154. }
  1155. }
  1156. if !saw_stop
  1157. && events.iter().any(|event| {
  1158. matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
  1159. || matches!(event, AssistantEvent::ToolUse { .. })
  1160. })
  1161. {
  1162. events.push(AssistantEvent::MessageStop);
  1163. }
  1164. if events
  1165. .iter()
  1166. .any(|event| matches!(event, AssistantEvent::MessageStop))
  1167. {
  1168. return Ok(events);
  1169. }
  1170. let response = self
  1171. .client
  1172. .send_message(&MessageRequest {
  1173. stream: false,
  1174. ..message_request.clone()
  1175. })
  1176. .await
  1177. .map_err(|error| RuntimeError::new(error.to_string()))?;
  1178. response_to_events(response, &mut stdout)
  1179. })
  1180. }
  1181. }
  1182. fn push_output_block(
  1183. block: OutputContentBlock,
  1184. out: &mut impl Write,
  1185. events: &mut Vec<AssistantEvent>,
  1186. pending_tool: &mut Option<(String, String, String)>,
  1187. ) -> Result<(), RuntimeError> {
  1188. match block {
  1189. OutputContentBlock::Text { text } => {
  1190. if !text.is_empty() {
  1191. write!(out, "{text}")
  1192. .and_then(|()| out.flush())
  1193. .map_err(|error| RuntimeError::new(error.to_string()))?;
  1194. events.push(AssistantEvent::TextDelta(text));
  1195. }
  1196. }
  1197. OutputContentBlock::ToolUse { id, name, input } => {
  1198. *pending_tool = Some((id, name, input.to_string()));
  1199. }
  1200. }
  1201. Ok(())
  1202. }
  1203. fn response_to_events(
  1204. response: MessageResponse,
  1205. out: &mut impl Write,
  1206. ) -> Result<Vec<AssistantEvent>, RuntimeError> {
  1207. let mut events = Vec::new();
  1208. let mut pending_tool = None;
  1209. for block in response.content {
  1210. push_output_block(block, out, &mut events, &mut pending_tool)?;
  1211. if let Some((id, name, input)) = pending_tool.take() {
  1212. events.push(AssistantEvent::ToolUse { id, name, input });
  1213. }
  1214. }
  1215. events.push(AssistantEvent::Usage(TokenUsage {
  1216. input_tokens: response.usage.input_tokens,
  1217. output_tokens: response.usage.output_tokens,
  1218. cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
  1219. cache_read_input_tokens: response.usage.cache_read_input_tokens,
  1220. }));
  1221. events.push(AssistantEvent::MessageStop);
  1222. Ok(events)
  1223. }
  1224. struct CliToolExecutor {
  1225. renderer: TerminalRenderer,
  1226. }
  1227. impl CliToolExecutor {
  1228. fn new() -> Self {
  1229. Self {
  1230. renderer: TerminalRenderer::new(),
  1231. }
  1232. }
  1233. }
  1234. impl ToolExecutor for CliToolExecutor {
  1235. fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
  1236. let value = serde_json::from_str(input)
  1237. .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
  1238. match execute_tool(tool_name, &value) {
  1239. Ok(output) => {
  1240. let markdown = format!("### Tool `{tool_name}`\n\n```json\n{output}\n```\n");
  1241. self.renderer
  1242. .stream_markdown(&markdown, &mut io::stdout())
  1243. .map_err(|error| ToolError::new(error.to_string()))?;
  1244. Ok(output)
  1245. }
  1246. Err(error) => Err(ToolError::new(error)),
  1247. }
  1248. }
  1249. }
  1250. fn permission_policy(mode: &str) -> PermissionPolicy {
  1251. if normalize_permission_mode(mode) == Some("read-only") {
  1252. PermissionPolicy::new(PermissionMode::Deny)
  1253. .with_tool_mode("read_file", PermissionMode::Allow)
  1254. .with_tool_mode("glob_search", PermissionMode::Allow)
  1255. .with_tool_mode("grep_search", PermissionMode::Allow)
  1256. } else {
  1257. PermissionPolicy::new(PermissionMode::Allow)
  1258. }
  1259. }
  1260. fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
  1261. messages
  1262. .iter()
  1263. .filter_map(|message| {
  1264. let role = match message.role {
  1265. MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
  1266. MessageRole::Assistant => "assistant",
  1267. };
  1268. let content = message
  1269. .blocks
  1270. .iter()
  1271. .map(|block| match block {
  1272. ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
  1273. ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
  1274. id: id.clone(),
  1275. name: name.clone(),
  1276. input: serde_json::from_str(input)
  1277. .unwrap_or_else(|_| serde_json::json!({ "raw": input })),
  1278. },
  1279. ContentBlock::ToolResult {
  1280. tool_use_id,
  1281. output,
  1282. is_error,
  1283. ..
  1284. } => InputContentBlock::ToolResult {
  1285. tool_use_id: tool_use_id.clone(),
  1286. content: vec![ToolResultContentBlock::Text {
  1287. text: output.clone(),
  1288. }],
  1289. is_error: *is_error,
  1290. },
  1291. })
  1292. .collect::<Vec<_>>();
  1293. (!content.is_empty()).then(|| InputMessage {
  1294. role: role.to_string(),
  1295. content,
  1296. })
  1297. })
  1298. .collect()
  1299. }
  1300. fn print_help() {
  1301. println!("rusty-claude-cli");
  1302. println!();
  1303. println!("Usage:");
  1304. println!(" rusty-claude-cli [--model MODEL]");
  1305. println!(" Start interactive REPL");
  1306. println!(" rusty-claude-cli [--model MODEL] prompt TEXT");
  1307. println!(" Send one prompt and stream the response");
  1308. println!(" rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]");
  1309. println!(" Inspect or maintain a saved session without entering the REPL");
  1310. println!(" rusty-claude-cli dump-manifests");
  1311. println!(" rusty-claude-cli bootstrap-plan");
  1312. println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
  1313. println!();
  1314. println!("Interactive slash commands:");
  1315. println!("{}", render_slash_command_help());
  1316. println!();
  1317. let resume_commands = resume_supported_slash_commands()
  1318. .into_iter()
  1319. .map(|spec| match spec.argument_hint {
  1320. Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
  1321. None => format!("/{}", spec.name),
  1322. })
  1323. .collect::<Vec<_>>()
  1324. .join(", ");
  1325. println!("Resume-safe commands: {resume_commands}");
  1326. println!("Examples:");
  1327. println!(" rusty-claude-cli --resume session.json /status /compact /cost");
  1328. println!(" rusty-claude-cli --resume session.json /memory /config");
  1329. }
  1330. #[cfg(test)]
  1331. mod tests {
  1332. use super::{
  1333. format_compact_report, format_cost_report, format_init_report, format_model_report,
  1334. format_model_switch_report, format_permissions_report, format_permissions_switch_report,
  1335. format_resume_report, format_status_report, normalize_permission_mode, parse_args,
  1336. parse_git_status_metadata, render_config_report, render_init_claude_md,
  1337. render_memory_report, render_repl_help, resume_supported_slash_commands, status_context,
  1338. CliAction, SlashCommand, StatusUsage, DEFAULT_MODEL,
  1339. };
  1340. use runtime::{ContentBlock, ConversationMessage, MessageRole};
  1341. use std::path::{Path, PathBuf};
  1342. #[test]
  1343. fn defaults_to_repl_when_no_args() {
  1344. assert_eq!(
  1345. parse_args(&[]).expect("args should parse"),
  1346. CliAction::Repl {
  1347. model: DEFAULT_MODEL.to_string(),
  1348. }
  1349. );
  1350. }
  1351. #[test]
  1352. fn parses_prompt_subcommand() {
  1353. let args = vec![
  1354. "prompt".to_string(),
  1355. "hello".to_string(),
  1356. "world".to_string(),
  1357. ];
  1358. assert_eq!(
  1359. parse_args(&args).expect("args should parse"),
  1360. CliAction::Prompt {
  1361. prompt: "hello world".to_string(),
  1362. model: DEFAULT_MODEL.to_string(),
  1363. }
  1364. );
  1365. }
  1366. #[test]
  1367. fn parses_system_prompt_options() {
  1368. let args = vec![
  1369. "system-prompt".to_string(),
  1370. "--cwd".to_string(),
  1371. "/tmp/project".to_string(),
  1372. "--date".to_string(),
  1373. "2026-04-01".to_string(),
  1374. ];
  1375. assert_eq!(
  1376. parse_args(&args).expect("args should parse"),
  1377. CliAction::PrintSystemPrompt {
  1378. cwd: PathBuf::from("/tmp/project"),
  1379. date: "2026-04-01".to_string(),
  1380. }
  1381. );
  1382. }
  1383. #[test]
  1384. fn parses_resume_flag_with_slash_command() {
  1385. let args = vec![
  1386. "--resume".to_string(),
  1387. "session.json".to_string(),
  1388. "/compact".to_string(),
  1389. ];
  1390. assert_eq!(
  1391. parse_args(&args).expect("args should parse"),
  1392. CliAction::ResumeSession {
  1393. session_path: PathBuf::from("session.json"),
  1394. commands: vec!["/compact".to_string()],
  1395. }
  1396. );
  1397. }
  1398. #[test]
  1399. fn parses_resume_flag_with_multiple_slash_commands() {
  1400. let args = vec![
  1401. "--resume".to_string(),
  1402. "session.json".to_string(),
  1403. "/status".to_string(),
  1404. "/compact".to_string(),
  1405. "/cost".to_string(),
  1406. ];
  1407. assert_eq!(
  1408. parse_args(&args).expect("args should parse"),
  1409. CliAction::ResumeSession {
  1410. session_path: PathBuf::from("session.json"),
  1411. commands: vec![
  1412. "/status".to_string(),
  1413. "/compact".to_string(),
  1414. "/cost".to_string(),
  1415. ],
  1416. }
  1417. );
  1418. }
  1419. #[test]
  1420. fn shared_help_uses_resume_annotation_copy() {
  1421. let help = commands::render_slash_command_help();
  1422. assert!(help.contains("Slash commands"));
  1423. assert!(help.contains("works with --resume SESSION.json"));
  1424. }
  1425. #[test]
  1426. fn repl_help_includes_shared_commands_and_exit() {
  1427. let help = render_repl_help();
  1428. assert!(help.contains("REPL"));
  1429. assert!(help.contains("/help"));
  1430. assert!(help.contains("/status"));
  1431. assert!(help.contains("/model [model]"));
  1432. assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
  1433. assert!(help.contains("/clear [--confirm]"));
  1434. assert!(help.contains("/cost"));
  1435. assert!(help.contains("/resume <session-path>"));
  1436. assert!(help.contains("/config [env|hooks|model]"));
  1437. assert!(help.contains("/memory"));
  1438. assert!(help.contains("/init"));
  1439. assert!(help.contains("/exit"));
  1440. }
  1441. #[test]
  1442. fn resume_supported_command_list_matches_expected_surface() {
  1443. let names = resume_supported_slash_commands()
  1444. .into_iter()
  1445. .map(|spec| spec.name)
  1446. .collect::<Vec<_>>();
  1447. assert_eq!(
  1448. names,
  1449. vec!["help", "status", "compact", "clear", "cost", "config", "memory", "init",]
  1450. );
  1451. }
  1452. #[test]
  1453. fn resume_report_uses_sectioned_layout() {
  1454. let report = format_resume_report("session.json", 14, 6);
  1455. assert!(report.contains("Session resumed"));
  1456. assert!(report.contains("Session file session.json"));
  1457. assert!(report.contains("Messages 14"));
  1458. assert!(report.contains("Turns 6"));
  1459. }
  1460. #[test]
  1461. fn compact_report_uses_structured_output() {
  1462. let compacted = format_compact_report(8, 5, false);
  1463. assert!(compacted.contains("Compact"));
  1464. assert!(compacted.contains("Result compacted"));
  1465. assert!(compacted.contains("Messages removed 8"));
  1466. let skipped = format_compact_report(0, 3, true);
  1467. assert!(skipped.contains("Result skipped"));
  1468. }
  1469. #[test]
  1470. fn cost_report_uses_sectioned_layout() {
  1471. let report = format_cost_report(runtime::TokenUsage {
  1472. input_tokens: 20,
  1473. output_tokens: 8,
  1474. cache_creation_input_tokens: 3,
  1475. cache_read_input_tokens: 1,
  1476. });
  1477. assert!(report.contains("Cost"));
  1478. assert!(report.contains("Input tokens 20"));
  1479. assert!(report.contains("Output tokens 8"));
  1480. assert!(report.contains("Cache create 3"));
  1481. assert!(report.contains("Cache read 1"));
  1482. assert!(report.contains("Total tokens 32"));
  1483. }
  1484. #[test]
  1485. fn permissions_report_uses_sectioned_layout() {
  1486. let report = format_permissions_report("workspace-write");
  1487. assert!(report.contains("Permissions"));
  1488. assert!(report.contains("Active mode workspace-write"));
  1489. assert!(report.contains("Modes"));
  1490. assert!(report.contains("read-only ○ available Read/search tools only"));
  1491. assert!(report.contains("workspace-write ● current Edit files inside the workspace"));
  1492. assert!(report.contains("danger-full-access ○ available Unrestricted tool access"));
  1493. }
  1494. #[test]
  1495. fn permissions_switch_report_is_structured() {
  1496. let report = format_permissions_switch_report("read-only", "workspace-write");
  1497. assert!(report.contains("Permissions updated"));
  1498. assert!(report.contains("Result mode switched"));
  1499. assert!(report.contains("Previous mode read-only"));
  1500. assert!(report.contains("Active mode workspace-write"));
  1501. assert!(report.contains("Applies to subsequent tool calls"));
  1502. }
  1503. #[test]
  1504. fn init_report_uses_structured_output() {
  1505. let created = format_init_report(Path::new("/tmp/CLAUDE.md"), true);
  1506. assert!(created.contains("Init"));
  1507. assert!(created.contains("Result created"));
  1508. let skipped = format_init_report(Path::new("/tmp/CLAUDE.md"), false);
  1509. assert!(skipped.contains("skipped (already exists)"));
  1510. }
  1511. #[test]
  1512. fn model_report_uses_sectioned_layout() {
  1513. let report = format_model_report("claude-sonnet", 12, 4);
  1514. assert!(report.contains("Model"));
  1515. assert!(report.contains("Current model claude-sonnet"));
  1516. assert!(report.contains("Session messages 12"));
  1517. assert!(report.contains("Switch models with /model <name>"));
  1518. }
  1519. #[test]
  1520. fn model_switch_report_preserves_context_summary() {
  1521. let report = format_model_switch_report("claude-sonnet", "claude-opus", 9);
  1522. assert!(report.contains("Model updated"));
  1523. assert!(report.contains("Previous claude-sonnet"));
  1524. assert!(report.contains("Current claude-opus"));
  1525. assert!(report.contains("Preserved msgs 9"));
  1526. }
  1527. #[test]
  1528. fn status_line_reports_model_and_token_totals() {
  1529. let status = format_status_report(
  1530. "claude-sonnet",
  1531. StatusUsage {
  1532. message_count: 7,
  1533. turns: 3,
  1534. latest: runtime::TokenUsage {
  1535. input_tokens: 5,
  1536. output_tokens: 4,
  1537. cache_creation_input_tokens: 1,
  1538. cache_read_input_tokens: 0,
  1539. },
  1540. cumulative: runtime::TokenUsage {
  1541. input_tokens: 20,
  1542. output_tokens: 8,
  1543. cache_creation_input_tokens: 2,
  1544. cache_read_input_tokens: 1,
  1545. },
  1546. estimated_tokens: 128,
  1547. },
  1548. "workspace-write",
  1549. &super::StatusContext {
  1550. cwd: PathBuf::from("/tmp/project"),
  1551. session_path: Some(PathBuf::from("session.json")),
  1552. loaded_config_files: 2,
  1553. discovered_config_files: 3,
  1554. memory_file_count: 4,
  1555. project_root: Some(PathBuf::from("/tmp")),
  1556. git_branch: Some("main".to_string()),
  1557. },
  1558. );
  1559. assert!(status.contains("Status"));
  1560. assert!(status.contains("Model claude-sonnet"));
  1561. assert!(status.contains("Permission mode workspace-write"));
  1562. assert!(status.contains("Messages 7"));
  1563. assert!(status.contains("Latest total 10"));
  1564. assert!(status.contains("Cumulative total 31"));
  1565. assert!(status.contains("Cwd /tmp/project"));
  1566. assert!(status.contains("Project root /tmp"));
  1567. assert!(status.contains("Git branch main"));
  1568. assert!(status.contains("Session session.json"));
  1569. assert!(status.contains("Config files loaded 2/3"));
  1570. assert!(status.contains("Memory files 4"));
  1571. }
  1572. #[test]
  1573. fn config_report_supports_section_views() {
  1574. let report = render_config_report(Some("env")).expect("config report should render");
  1575. assert!(report.contains("Merged section: env"));
  1576. }
  1577. #[test]
  1578. fn memory_report_uses_sectioned_layout() {
  1579. let report = render_memory_report().expect("memory report should render");
  1580. assert!(report.contains("Memory"));
  1581. assert!(report.contains("Working directory"));
  1582. assert!(report.contains("Instruction files"));
  1583. assert!(report.contains("Discovered files"));
  1584. }
  1585. #[test]
  1586. fn config_report_uses_sectioned_layout() {
  1587. let report = render_config_report(None).expect("config report should render");
  1588. assert!(report.contains("Config"));
  1589. assert!(report.contains("Discovered files"));
  1590. assert!(report.contains("Merged JSON"));
  1591. }
  1592. #[test]
  1593. fn parses_git_status_metadata() {
  1594. let (root, branch) = parse_git_status_metadata(Some(
  1595. "## rcc/cli...origin/rcc/cli
  1596. M src/main.rs",
  1597. ));
  1598. assert_eq!(branch.as_deref(), Some("rcc/cli"));
  1599. let _ = root;
  1600. }
  1601. #[test]
  1602. fn status_context_reads_real_workspace_metadata() {
  1603. let context = status_context(None).expect("status context should load");
  1604. assert!(context.cwd.is_absolute());
  1605. assert_eq!(context.discovered_config_files, 3);
  1606. assert!(context.loaded_config_files <= context.discovered_config_files);
  1607. }
  1608. #[test]
  1609. fn normalizes_supported_permission_modes() {
  1610. assert_eq!(normalize_permission_mode("read-only"), Some("read-only"));
  1611. assert_eq!(
  1612. normalize_permission_mode("workspace-write"),
  1613. Some("workspace-write")
  1614. );
  1615. assert_eq!(
  1616. normalize_permission_mode("danger-full-access"),
  1617. Some("danger-full-access")
  1618. );
  1619. assert_eq!(normalize_permission_mode("unknown"), None);
  1620. }
  1621. #[test]
  1622. fn clear_command_requires_explicit_confirmation_flag() {
  1623. assert_eq!(
  1624. SlashCommand::parse("/clear"),
  1625. Some(SlashCommand::Clear { confirm: false })
  1626. );
  1627. assert_eq!(
  1628. SlashCommand::parse("/clear --confirm"),
  1629. Some(SlashCommand::Clear { confirm: true })
  1630. );
  1631. }
  1632. #[test]
  1633. fn parses_resume_and_config_slash_commands() {
  1634. assert_eq!(
  1635. SlashCommand::parse("/resume saved-session.json"),
  1636. Some(SlashCommand::Resume {
  1637. session_path: Some("saved-session.json".to_string())
  1638. })
  1639. );
  1640. assert_eq!(
  1641. SlashCommand::parse("/clear --confirm"),
  1642. Some(SlashCommand::Clear { confirm: true })
  1643. );
  1644. assert_eq!(
  1645. SlashCommand::parse("/config"),
  1646. Some(SlashCommand::Config { section: None })
  1647. );
  1648. assert_eq!(
  1649. SlashCommand::parse("/config env"),
  1650. Some(SlashCommand::Config {
  1651. section: Some("env".to_string())
  1652. })
  1653. );
  1654. assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
  1655. assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
  1656. }
  1657. #[test]
  1658. fn init_template_mentions_detected_rust_workspace() {
  1659. let rendered = render_init_claude_md(Path::new("."));
  1660. assert!(rendered.contains("# CLAUDE.md"));
  1661. assert!(rendered.contains("cargo clippy --workspace --all-targets -- -D warnings"));
  1662. }
  1663. #[test]
  1664. fn converts_tool_roundtrip_messages() {
  1665. let messages = vec![
  1666. ConversationMessage::user_text("hello"),
  1667. ConversationMessage::assistant(vec![ContentBlock::ToolUse {
  1668. id: "tool-1".to_string(),
  1669. name: "bash".to_string(),
  1670. input: "{\"command\":\"pwd\"}".to_string(),
  1671. }]),
  1672. ConversationMessage {
  1673. role: MessageRole::Tool,
  1674. blocks: vec![ContentBlock::ToolResult {
  1675. tool_use_id: "tool-1".to_string(),
  1676. tool_name: "bash".to_string(),
  1677. output: "ok".to_string(),
  1678. is_error: false,
  1679. }],
  1680. usage: None,
  1681. },
  1682. ];
  1683. let converted = super::convert_messages(&messages);
  1684. assert_eq!(converted.len(), 3);
  1685. assert_eq!(converted[1].role, "assistant");
  1686. assert_eq!(converted[2].role, "user");
  1687. }
  1688. }