main.rs 102 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074
  1. mod input;
  2. mod render;
  3. use std::collections::{BTreeMap, BTreeSet};
  4. use std::env;
  5. use std::fs;
  6. use std::io::{self, Read, Write};
  7. use std::net::TcpListener;
  8. use std::path::{Path, PathBuf};
  9. use std::process::Command;
  10. use std::time::{SystemTime, UNIX_EPOCH};
  11. use api::{
  12. resolve_saved_oauth_token, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock,
  13. InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
  14. StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
  15. };
  16. use commands::{render_slash_command_help, resume_supported_slash_commands, SlashCommand};
  17. use compat_harness::{extract_manifest, UpstreamPaths};
  18. use render::{Spinner, TerminalRenderer};
  19. use runtime::{
  20. clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
  21. parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
  22. AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
  23. ConversationMessage, ConversationRuntime, McpServerManager, MessageRole,
  24. OAuthAuthorizationRequest, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy,
  25. ProjectContext, ResolvedPermissionMode, RuntimeError, Session, TokenUsage, ToolError,
  26. ToolExecutor, UsageTracker,
  27. };
  28. use serde_json::json;
  29. use tools::{execute_tool, mvp_tool_specs};
  30. const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
  31. const DEFAULT_MAX_TOKENS: u32 = 32;
  32. const DEFAULT_DATE: &str = "2026-03-31";
  33. const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
  34. const VERSION: &str = env!("CARGO_PKG_VERSION");
  35. const BUILD_TARGET: Option<&str> = option_env!("TARGET");
  36. const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
  37. type AllowedToolSet = BTreeSet<String>;
  38. fn main() {
  39. if let Err(error) = run() {
  40. eprintln!(
  41. "error: {error}
  42. Run `rusty-claude-cli --help` for usage."
  43. );
  44. std::process::exit(1);
  45. }
  46. }
  47. fn run() -> Result<(), Box<dyn std::error::Error>> {
  48. let args: Vec<String> = env::args().skip(1).collect();
  49. let runtime_config = load_runtime_config()?;
  50. let defaults = RuntimeDefaults::from_config(&runtime_config);
  51. match parse_args(&args, &defaults)? {
  52. CliAction::DumpManifests => dump_manifests(),
  53. CliAction::BootstrapPlan => print_bootstrap_plan(),
  54. CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
  55. CliAction::Version => print_version(),
  56. CliAction::ResumeSession {
  57. session_path,
  58. commands,
  59. } => resume_session(&session_path, &commands),
  60. CliAction::Prompt {
  61. prompt,
  62. model,
  63. output_format,
  64. allowed_tools,
  65. } => LiveCli::new(model, false, allowed_tools)?
  66. .run_turn_with_output(&prompt, output_format)?,
  67. CliAction::Login => run_login()?,
  68. CliAction::Logout => run_logout()?,
  69. CliAction::Repl {
  70. model,
  71. allowed_tools,
  72. } => run_repl(model, allowed_tools)?,
  73. CliAction::Help => print_help(),
  74. }
  75. Ok(())
  76. }
  77. fn load_runtime_config() -> Result<runtime::RuntimeConfig, Box<dyn std::error::Error>> {
  78. let cwd = env::current_dir()?;
  79. Ok(ConfigLoader::default_for(&cwd).load()?)
  80. }
  81. #[derive(Debug, Clone, PartialEq, Eq)]
  82. enum CliAction {
  83. DumpManifests,
  84. BootstrapPlan,
  85. PrintSystemPrompt {
  86. cwd: PathBuf,
  87. date: String,
  88. },
  89. Version,
  90. ResumeSession {
  91. session_path: PathBuf,
  92. commands: Vec<String>,
  93. },
  94. Prompt {
  95. prompt: String,
  96. model: String,
  97. output_format: CliOutputFormat,
  98. allowed_tools: Option<AllowedToolSet>,
  99. },
  100. Login,
  101. Logout,
  102. Repl {
  103. model: String,
  104. allowed_tools: Option<AllowedToolSet>,
  105. },
  106. // prompt-mode formatting is only supported for non-interactive runs
  107. Help,
  108. }
  109. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  110. enum CliOutputFormat {
  111. Text,
  112. Json,
  113. }
  114. impl CliOutputFormat {
  115. fn parse(value: &str) -> Result<Self, String> {
  116. match value {
  117. "text" => Ok(Self::Text),
  118. "json" => Ok(Self::Json),
  119. other => Err(format!(
  120. "unsupported value for --output-format: {other} (expected text or json)"
  121. )),
  122. }
  123. }
  124. }
  125. fn parse_args(args: &[String], defaults: &RuntimeDefaults) -> Result<CliAction, String> {
  126. let mut model = defaults.model.clone();
  127. let mut output_format = CliOutputFormat::Text;
  128. let mut wants_version = false;
  129. let mut allowed_tool_values = Vec::new();
  130. let mut rest = Vec::new();
  131. let mut index = 0;
  132. while index < args.len() {
  133. match args[index].as_str() {
  134. "--version" | "-V" => {
  135. wants_version = true;
  136. index += 1;
  137. }
  138. "--model" => {
  139. let value = args
  140. .get(index + 1)
  141. .ok_or_else(|| "missing value for --model".to_string())?;
  142. model.clone_from(value);
  143. index += 2;
  144. }
  145. flag if flag.starts_with("--model=") => {
  146. model = flag[8..].to_string();
  147. index += 1;
  148. }
  149. "--output-format" => {
  150. let value = args
  151. .get(index + 1)
  152. .ok_or_else(|| "missing value for --output-format".to_string())?;
  153. output_format = CliOutputFormat::parse(value)?;
  154. index += 2;
  155. }
  156. flag if flag.starts_with("--output-format=") => {
  157. output_format = CliOutputFormat::parse(&flag[16..])?;
  158. index += 1;
  159. }
  160. "--allowedTools" | "--allowed-tools" => {
  161. let value = args
  162. .get(index + 1)
  163. .ok_or_else(|| "missing value for --allowedTools".to_string())?;
  164. allowed_tool_values.push(value.clone());
  165. index += 2;
  166. }
  167. flag if flag.starts_with("--allowedTools=") => {
  168. allowed_tool_values.push(flag[15..].to_string());
  169. index += 1;
  170. }
  171. flag if flag.starts_with("--allowed-tools=") => {
  172. allowed_tool_values.push(flag[16..].to_string());
  173. index += 1;
  174. }
  175. other => {
  176. rest.push(other.to_string());
  177. index += 1;
  178. }
  179. }
  180. }
  181. if wants_version {
  182. return Ok(CliAction::Version);
  183. }
  184. let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?;
  185. if rest.is_empty() {
  186. return Ok(CliAction::Repl {
  187. model,
  188. allowed_tools,
  189. });
  190. }
  191. if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
  192. return Ok(CliAction::Help);
  193. }
  194. if rest.first().map(String::as_str) == Some("--resume") {
  195. return parse_resume_args(&rest[1..]);
  196. }
  197. match rest[0].as_str() {
  198. "dump-manifests" => Ok(CliAction::DumpManifests),
  199. "bootstrap-plan" => Ok(CliAction::BootstrapPlan),
  200. "system-prompt" => parse_system_prompt_args(&rest[1..]),
  201. "login" => Ok(CliAction::Login),
  202. "logout" => Ok(CliAction::Logout),
  203. "prompt" => {
  204. let prompt = rest[1..].join(" ");
  205. if prompt.trim().is_empty() {
  206. return Err("prompt subcommand requires a prompt string".to_string());
  207. }
  208. Ok(CliAction::Prompt {
  209. prompt,
  210. model,
  211. output_format,
  212. allowed_tools,
  213. })
  214. }
  215. other if !other.starts_with('/') => Ok(CliAction::Prompt {
  216. prompt: rest.join(" "),
  217. model,
  218. output_format,
  219. allowed_tools,
  220. }),
  221. other => Err(format!("unknown subcommand: {other}")),
  222. }
  223. }
  224. #[derive(Debug, Clone, PartialEq, Eq)]
  225. struct RuntimeDefaults {
  226. model: String,
  227. }
  228. impl RuntimeDefaults {
  229. fn from_config(config: &runtime::RuntimeConfig) -> Self {
  230. Self {
  231. model: config.model().unwrap_or(DEFAULT_MODEL).to_string(),
  232. }
  233. }
  234. }
  235. fn resolved_permission_mode_label(config: &runtime::RuntimeConfig) -> &'static str {
  236. match env::var("RUSTY_CLAUDE_PERMISSION_MODE") {
  237. Ok(value) if value == "read-only" => "read-only",
  238. Ok(value) if value == "danger-full-access" => "danger-full-access",
  239. Ok(value) if value == "workspace-write" => "workspace-write",
  240. _ => match config.permission_mode() {
  241. Some(ResolvedPermissionMode::ReadOnly) => "read-only",
  242. Some(ResolvedPermissionMode::DangerFullAccess) => "danger-full-access",
  243. Some(ResolvedPermissionMode::WorkspaceWrite) | None => "workspace-write",
  244. },
  245. }
  246. }
  247. fn normalize_allowed_tools(values: &[String]) -> Result<Option<AllowedToolSet>, String> {
  248. if values.is_empty() {
  249. return Ok(None);
  250. }
  251. let canonical_names = mvp_tool_specs()
  252. .into_iter()
  253. .map(|spec| spec.name.to_string())
  254. .collect::<Vec<_>>();
  255. let mut name_map = canonical_names
  256. .iter()
  257. .map(|name| (normalize_tool_name(name), name.clone()))
  258. .collect::<BTreeMap<_, _>>();
  259. for (alias, canonical) in [
  260. ("read", "read_file"),
  261. ("write", "write_file"),
  262. ("edit", "edit_file"),
  263. ("glob", "glob_search"),
  264. ("grep", "grep_search"),
  265. ] {
  266. name_map.insert(alias.to_string(), canonical.to_string());
  267. }
  268. let mut allowed = AllowedToolSet::new();
  269. for value in values {
  270. for token in value
  271. .split(|ch: char| ch == ',' || ch.is_whitespace())
  272. .filter(|token| !token.is_empty())
  273. {
  274. let normalized = normalize_tool_name(token);
  275. let canonical = name_map.get(&normalized).ok_or_else(|| {
  276. format!(
  277. "unsupported tool in --allowedTools: {token} (expected one of: {})",
  278. canonical_names.join(", ")
  279. )
  280. })?;
  281. allowed.insert(canonical.clone());
  282. }
  283. }
  284. Ok(Some(allowed))
  285. }
  286. fn normalize_tool_name(value: &str) -> String {
  287. value.trim().replace('-', "_").to_ascii_lowercase()
  288. }
  289. fn filter_tool_specs(allowed_tools: Option<&AllowedToolSet>) -> Vec<tools::ToolSpec> {
  290. mvp_tool_specs()
  291. .into_iter()
  292. .filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
  293. .collect()
  294. }
  295. fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
  296. let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
  297. let mut date = DEFAULT_DATE.to_string();
  298. let mut index = 0;
  299. while index < args.len() {
  300. match args[index].as_str() {
  301. "--cwd" => {
  302. let value = args
  303. .get(index + 1)
  304. .ok_or_else(|| "missing value for --cwd".to_string())?;
  305. cwd = PathBuf::from(value);
  306. index += 2;
  307. }
  308. "--date" => {
  309. let value = args
  310. .get(index + 1)
  311. .ok_or_else(|| "missing value for --date".to_string())?;
  312. date.clone_from(value);
  313. index += 2;
  314. }
  315. other => return Err(format!("unknown system-prompt option: {other}")),
  316. }
  317. }
  318. Ok(CliAction::PrintSystemPrompt { cwd, date })
  319. }
  320. fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
  321. let session_path = args
  322. .first()
  323. .ok_or_else(|| "missing session path for --resume".to_string())
  324. .map(PathBuf::from)?;
  325. let commands = args[1..].to_vec();
  326. if commands
  327. .iter()
  328. .any(|command| !command.trim_start().starts_with('/'))
  329. {
  330. return Err("--resume trailing arguments must be slash commands".to_string());
  331. }
  332. Ok(CliAction::ResumeSession {
  333. session_path,
  334. commands,
  335. })
  336. }
  337. fn dump_manifests() {
  338. let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
  339. let paths = UpstreamPaths::from_workspace_dir(&workspace_dir);
  340. match extract_manifest(&paths) {
  341. Ok(manifest) => {
  342. println!("commands: {}", manifest.commands.entries().len());
  343. println!("tools: {}", manifest.tools.entries().len());
  344. println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
  345. }
  346. Err(error) => {
  347. eprintln!("failed to extract manifests: {error}");
  348. std::process::exit(1);
  349. }
  350. }
  351. }
  352. fn print_bootstrap_plan() {
  353. for phase in runtime::BootstrapPlan::claude_code_default().phases() {
  354. println!("- {phase:?}");
  355. }
  356. }
  357. fn run_login() -> Result<(), Box<dyn std::error::Error>> {
  358. let cwd = env::current_dir()?;
  359. let config = ConfigLoader::default_for(&cwd).load()?;
  360. let oauth = config.oauth().ok_or_else(|| {
  361. io::Error::new(
  362. io::ErrorKind::NotFound,
  363. "OAuth config is missing. Add settings.oauth.clientId/authorizeUrl/tokenUrl first.",
  364. )
  365. })?;
  366. let callback_port = oauth.callback_port.unwrap_or(DEFAULT_OAUTH_CALLBACK_PORT);
  367. let redirect_uri = runtime::loopback_redirect_uri(callback_port);
  368. let pkce = generate_pkce_pair()?;
  369. let state = generate_state()?;
  370. let authorize_url =
  371. OAuthAuthorizationRequest::from_config(oauth, redirect_uri.clone(), state.clone(), &pkce)
  372. .build_url();
  373. println!("Starting Claude OAuth login...");
  374. println!("Listening for callback on {redirect_uri}");
  375. if let Err(error) = open_browser(&authorize_url) {
  376. eprintln!("warning: failed to open browser automatically: {error}");
  377. println!("Open this URL manually:\n{authorize_url}");
  378. }
  379. let callback = wait_for_oauth_callback(callback_port)?;
  380. if let Some(error) = callback.error {
  381. let description = callback
  382. .error_description
  383. .unwrap_or_else(|| "authorization failed".to_string());
  384. return Err(io::Error::other(format!("{error}: {description}")).into());
  385. }
  386. let code = callback.code.ok_or_else(|| {
  387. io::Error::new(io::ErrorKind::InvalidData, "callback did not include code")
  388. })?;
  389. let returned_state = callback.state.ok_or_else(|| {
  390. io::Error::new(io::ErrorKind::InvalidData, "callback did not include state")
  391. })?;
  392. if returned_state != state {
  393. return Err(io::Error::new(io::ErrorKind::InvalidData, "oauth state mismatch").into());
  394. }
  395. let client = AnthropicClient::from_auth(AuthSource::None);
  396. let exchange_request =
  397. OAuthTokenExchangeRequest::from_config(oauth, code, state, pkce.verifier, redirect_uri);
  398. let runtime = tokio::runtime::Runtime::new()?;
  399. let token_set = runtime.block_on(client.exchange_oauth_code(oauth, &exchange_request))?;
  400. save_oauth_credentials(&runtime::OAuthTokenSet {
  401. access_token: token_set.access_token,
  402. refresh_token: token_set.refresh_token,
  403. expires_at: token_set.expires_at,
  404. scopes: token_set.scopes,
  405. })?;
  406. println!("Claude OAuth login complete.");
  407. Ok(())
  408. }
  409. fn run_logout() -> Result<(), Box<dyn std::error::Error>> {
  410. clear_oauth_credentials()?;
  411. println!("Claude OAuth credentials cleared.");
  412. Ok(())
  413. }
  414. fn open_browser(url: &str) -> io::Result<()> {
  415. let commands = if cfg!(target_os = "macos") {
  416. vec![("open", vec![url])]
  417. } else if cfg!(target_os = "windows") {
  418. vec![("cmd", vec!["/C", "start", "", url])]
  419. } else {
  420. vec![("xdg-open", vec![url])]
  421. };
  422. for (program, args) in commands {
  423. match Command::new(program).args(args).spawn() {
  424. Ok(_) => return Ok(()),
  425. Err(error) if error.kind() == io::ErrorKind::NotFound => {}
  426. Err(error) => return Err(error),
  427. }
  428. }
  429. Err(io::Error::new(
  430. io::ErrorKind::NotFound,
  431. "no supported browser opener command found",
  432. ))
  433. }
  434. fn wait_for_oauth_callback(
  435. port: u16,
  436. ) -> Result<runtime::OAuthCallbackParams, Box<dyn std::error::Error>> {
  437. let listener = TcpListener::bind(("127.0.0.1", port))?;
  438. let (mut stream, _) = listener.accept()?;
  439. let mut buffer = [0_u8; 4096];
  440. let bytes_read = stream.read(&mut buffer)?;
  441. let request = String::from_utf8_lossy(&buffer[..bytes_read]);
  442. let request_line = request.lines().next().ok_or_else(|| {
  443. io::Error::new(io::ErrorKind::InvalidData, "missing callback request line")
  444. })?;
  445. let target = request_line.split_whitespace().nth(1).ok_or_else(|| {
  446. io::Error::new(
  447. io::ErrorKind::InvalidData,
  448. "missing callback request target",
  449. )
  450. })?;
  451. let callback = parse_oauth_callback_request_target(target)
  452. .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
  453. let body = if callback.error.is_some() {
  454. "Claude OAuth login failed. You can close this window."
  455. } else {
  456. "Claude OAuth login succeeded. You can close this window."
  457. };
  458. let response = format!(
  459. "HTTP/1.1 200 OK\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
  460. body.len(),
  461. body
  462. );
  463. stream.write_all(response.as_bytes())?;
  464. Ok(callback)
  465. }
  466. fn print_system_prompt(cwd: PathBuf, date: String) {
  467. match load_system_prompt(cwd, date, env::consts::OS, "unknown") {
  468. Ok(sections) => println!("{}", sections.join("\n\n")),
  469. Err(error) => {
  470. eprintln!("failed to build system prompt: {error}");
  471. std::process::exit(1);
  472. }
  473. }
  474. }
  475. fn print_version() {
  476. println!("{}", render_version_report());
  477. }
  478. fn resume_session(session_path: &Path, commands: &[String]) {
  479. let session = match Session::load_from_path(session_path) {
  480. Ok(session) => session,
  481. Err(error) => {
  482. eprintln!("failed to restore session: {error}");
  483. std::process::exit(1);
  484. }
  485. };
  486. if commands.is_empty() {
  487. println!(
  488. "Restored session from {} ({} messages).",
  489. session_path.display(),
  490. session.messages.len()
  491. );
  492. return;
  493. }
  494. let mut session = session;
  495. for raw_command in commands {
  496. let Some(command) = SlashCommand::parse(raw_command) else {
  497. eprintln!("unsupported resumed command: {raw_command}");
  498. std::process::exit(2);
  499. };
  500. match run_resume_command(session_path, &session, &command) {
  501. Ok(ResumeCommandOutcome {
  502. session: next_session,
  503. message,
  504. }) => {
  505. session = next_session;
  506. if let Some(message) = message {
  507. println!("{message}");
  508. }
  509. }
  510. Err(error) => {
  511. eprintln!("{error}");
  512. std::process::exit(2);
  513. }
  514. }
  515. }
  516. }
  517. #[derive(Debug, Clone)]
  518. struct ResumeCommandOutcome {
  519. session: Session,
  520. message: Option<String>,
  521. }
  522. #[derive(Debug, Clone)]
  523. struct StatusContext {
  524. cwd: PathBuf,
  525. session_path: Option<PathBuf>,
  526. loaded_config_files: usize,
  527. discovered_config_files: usize,
  528. memory_file_count: usize,
  529. project_root: Option<PathBuf>,
  530. git_branch: Option<String>,
  531. }
  532. #[derive(Debug, Clone, Copy)]
  533. struct StatusUsage {
  534. message_count: usize,
  535. turns: u32,
  536. latest: TokenUsage,
  537. cumulative: TokenUsage,
  538. estimated_tokens: usize,
  539. }
  540. fn format_model_report(model: &str, message_count: usize, turns: u32) -> String {
  541. format!(
  542. "Model
  543. Current model {model}
  544. Session messages {message_count}
  545. Session turns {turns}
  546. Usage
  547. Inspect current model with /model
  548. Switch models with /model <name>"
  549. )
  550. }
  551. fn format_model_switch_report(previous: &str, next: &str, message_count: usize) -> String {
  552. format!(
  553. "Model updated
  554. Previous {previous}
  555. Current {next}
  556. Preserved msgs {message_count}"
  557. )
  558. }
  559. fn format_permissions_report(mode: &str) -> String {
  560. let modes = [
  561. ("read-only", "Read/search tools only", mode == "read-only"),
  562. (
  563. "workspace-write",
  564. "Edit files inside the workspace",
  565. mode == "workspace-write",
  566. ),
  567. (
  568. "danger-full-access",
  569. "Unrestricted tool access",
  570. mode == "danger-full-access",
  571. ),
  572. ]
  573. .into_iter()
  574. .map(|(name, description, is_current)| {
  575. let marker = if is_current {
  576. "● current"
  577. } else {
  578. "○ available"
  579. };
  580. format!(" {name:<18} {marker:<11} {description}")
  581. })
  582. .collect::<Vec<_>>()
  583. .join(
  584. "
  585. ",
  586. );
  587. format!(
  588. "Permissions
  589. Active mode {mode}
  590. Mode status live session default
  591. Modes
  592. {modes}
  593. Usage
  594. Inspect current mode with /permissions
  595. Switch modes with /permissions <mode>"
  596. )
  597. }
  598. fn format_permissions_switch_report(previous: &str, next: &str) -> String {
  599. format!(
  600. "Permissions updated
  601. Result mode switched
  602. Previous mode {previous}
  603. Active mode {next}
  604. Applies to subsequent tool calls
  605. Usage /permissions to inspect current mode"
  606. )
  607. }
  608. fn format_cost_report(usage: TokenUsage) -> String {
  609. format!(
  610. "Cost
  611. Input tokens {}
  612. Output tokens {}
  613. Cache create {}
  614. Cache read {}
  615. Total tokens {}",
  616. usage.input_tokens,
  617. usage.output_tokens,
  618. usage.cache_creation_input_tokens,
  619. usage.cache_read_input_tokens,
  620. usage.total_tokens(),
  621. )
  622. }
  623. fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String {
  624. format!(
  625. "Session resumed
  626. Session file {session_path}
  627. Messages {message_count}
  628. Turns {turns}"
  629. )
  630. }
  631. fn format_init_report(path: &Path, created: bool) -> String {
  632. if created {
  633. format!(
  634. "Init
  635. CLAUDE.md {}
  636. Result created
  637. Next step Review and tailor the generated guidance",
  638. path.display()
  639. )
  640. } else {
  641. format!(
  642. "Init
  643. CLAUDE.md {}
  644. Result skipped (already exists)
  645. Next step Edit the existing file intentionally if workflows changed",
  646. path.display()
  647. )
  648. }
  649. }
  650. fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String {
  651. if skipped {
  652. format!(
  653. "Compact
  654. Result skipped
  655. Reason session below compaction threshold
  656. Messages kept {resulting_messages}"
  657. )
  658. } else {
  659. format!(
  660. "Compact
  661. Result compacted
  662. Messages removed {removed}
  663. Messages kept {resulting_messages}"
  664. )
  665. }
  666. }
  667. fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<String>) {
  668. let Some(status) = status else {
  669. return (None, None);
  670. };
  671. let branch = status.lines().next().and_then(|line| {
  672. line.strip_prefix("## ")
  673. .map(|line| {
  674. line.split(['.', ' '])
  675. .next()
  676. .unwrap_or_default()
  677. .to_string()
  678. })
  679. .filter(|value| !value.is_empty())
  680. });
  681. let project_root = find_git_root().ok();
  682. (project_root, branch)
  683. }
  684. fn find_git_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
  685. let output = std::process::Command::new("git")
  686. .args(["rev-parse", "--show-toplevel"])
  687. .current_dir(env::current_dir()?)
  688. .output()?;
  689. if !output.status.success() {
  690. return Err("not a git repository".into());
  691. }
  692. let path = String::from_utf8(output.stdout)?.trim().to_string();
  693. if path.is_empty() {
  694. return Err("empty git root".into());
  695. }
  696. Ok(PathBuf::from(path))
  697. }
  698. #[allow(clippy::too_many_lines)]
  699. fn run_resume_command(
  700. session_path: &Path,
  701. session: &Session,
  702. command: &SlashCommand,
  703. ) -> Result<ResumeCommandOutcome, Box<dyn std::error::Error>> {
  704. match command {
  705. SlashCommand::Help => Ok(ResumeCommandOutcome {
  706. session: session.clone(),
  707. message: Some(render_repl_help()),
  708. }),
  709. SlashCommand::Compact => {
  710. let result = runtime::compact_session(
  711. session,
  712. CompactionConfig {
  713. max_estimated_tokens: 0,
  714. ..CompactionConfig::default()
  715. },
  716. );
  717. let removed = result.removed_message_count;
  718. let kept = result.compacted_session.messages.len();
  719. let skipped = removed == 0;
  720. result.compacted_session.save_to_path(session_path)?;
  721. Ok(ResumeCommandOutcome {
  722. session: result.compacted_session,
  723. message: Some(format_compact_report(removed, kept, skipped)),
  724. })
  725. }
  726. SlashCommand::Clear { confirm } => {
  727. if !confirm {
  728. return Ok(ResumeCommandOutcome {
  729. session: session.clone(),
  730. message: Some(
  731. "clear: confirmation required; rerun with /clear --confirm".to_string(),
  732. ),
  733. });
  734. }
  735. let cleared = Session::new();
  736. cleared.save_to_path(session_path)?;
  737. Ok(ResumeCommandOutcome {
  738. session: cleared,
  739. message: Some(format!(
  740. "Cleared resumed session file {}.",
  741. session_path.display()
  742. )),
  743. })
  744. }
  745. SlashCommand::Status => {
  746. let tracker = UsageTracker::from_session(session);
  747. let usage = tracker.cumulative_usage();
  748. Ok(ResumeCommandOutcome {
  749. session: session.clone(),
  750. message: Some(format_status_report(
  751. "restored-session",
  752. StatusUsage {
  753. message_count: session.messages.len(),
  754. turns: tracker.turns(),
  755. latest: tracker.current_turn_usage(),
  756. cumulative: usage,
  757. estimated_tokens: 0,
  758. },
  759. permission_mode_label(),
  760. &status_context(Some(session_path))?,
  761. )),
  762. })
  763. }
  764. SlashCommand::Cost => {
  765. let usage = UsageTracker::from_session(session).cumulative_usage();
  766. Ok(ResumeCommandOutcome {
  767. session: session.clone(),
  768. message: Some(format_cost_report(usage)),
  769. })
  770. }
  771. SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
  772. session: session.clone(),
  773. message: Some(render_config_report(section.as_deref())?),
  774. }),
  775. SlashCommand::Memory => Ok(ResumeCommandOutcome {
  776. session: session.clone(),
  777. message: Some(render_memory_report()?),
  778. }),
  779. SlashCommand::Init => Ok(ResumeCommandOutcome {
  780. session: session.clone(),
  781. message: Some(init_claude_md()?),
  782. }),
  783. SlashCommand::Diff => Ok(ResumeCommandOutcome {
  784. session: session.clone(),
  785. message: Some(render_diff_report()?),
  786. }),
  787. SlashCommand::Version => Ok(ResumeCommandOutcome {
  788. session: session.clone(),
  789. message: Some(render_version_report()),
  790. }),
  791. SlashCommand::Export { path } => {
  792. let export_path = resolve_export_path(path.as_deref(), session)?;
  793. fs::write(&export_path, render_export_text(session))?;
  794. Ok(ResumeCommandOutcome {
  795. session: session.clone(),
  796. message: Some(format!(
  797. "Export\n Result wrote transcript\n File {}\n Messages {}",
  798. export_path.display(),
  799. session.messages.len(),
  800. )),
  801. })
  802. }
  803. SlashCommand::Resume { .. }
  804. | SlashCommand::Model { .. }
  805. | SlashCommand::Permissions { .. }
  806. | SlashCommand::Session { .. }
  807. | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
  808. }
  809. }
  810. fn run_repl(
  811. model: String,
  812. allowed_tools: Option<AllowedToolSet>,
  813. ) -> Result<(), Box<dyn std::error::Error>> {
  814. let mut cli = LiveCli::new(model, true, allowed_tools)?;
  815. let editor = input::LineEditor::new("› ");
  816. println!("{}", cli.startup_banner());
  817. while let Some(input) = editor.read_line()? {
  818. let trimmed = input.trim();
  819. if trimmed.is_empty() {
  820. continue;
  821. }
  822. if matches!(trimmed, "/exit" | "/quit") {
  823. break;
  824. }
  825. if let Some(command) = SlashCommand::parse(trimmed) {
  826. cli.handle_repl_command(command)?;
  827. continue;
  828. }
  829. cli.run_turn(trimmed)?;
  830. }
  831. Ok(())
  832. }
  833. #[derive(Debug, Clone)]
  834. struct SessionHandle {
  835. id: String,
  836. path: PathBuf,
  837. }
  838. #[derive(Debug, Clone)]
  839. struct ManagedSessionSummary {
  840. id: String,
  841. path: PathBuf,
  842. modified_epoch_secs: u64,
  843. message_count: usize,
  844. }
  845. struct LiveCli {
  846. model: String,
  847. allowed_tools: Option<AllowedToolSet>,
  848. system_prompt: Vec<String>,
  849. runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
  850. session: SessionHandle,
  851. }
  852. impl LiveCli {
  853. fn new(
  854. model: String,
  855. enable_tools: bool,
  856. allowed_tools: Option<AllowedToolSet>,
  857. ) -> Result<Self, Box<dyn std::error::Error>> {
  858. let config = load_runtime_config()?;
  859. let system_prompt = build_system_prompt()?;
  860. let session = create_managed_session_handle()?;
  861. let permission_mode = resolved_permission_mode_label(&config);
  862. let runtime = build_runtime(
  863. Session::new(),
  864. model.clone(),
  865. system_prompt.clone(),
  866. enable_tools,
  867. allowed_tools.clone(),
  868. &config,
  869. permission_mode,
  870. )?;
  871. let cli = Self {
  872. model,
  873. allowed_tools,
  874. system_prompt,
  875. runtime,
  876. session,
  877. };
  878. cli.persist_session()?;
  879. Ok(cli)
  880. }
  881. fn startup_banner(&self) -> String {
  882. format!(
  883. "Rusty Claude CLI\n Model {}\n Working directory {}\n Session {}\n\nType /help for commands. Shift+Enter or Ctrl+J inserts a newline.",
  884. self.model,
  885. env::current_dir().map_or_else(
  886. |_| "<unknown>".to_string(),
  887. |path| path.display().to_string(),
  888. ),
  889. self.session.id,
  890. )
  891. }
  892. fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
  893. let mut spinner = Spinner::new();
  894. let mut stdout = io::stdout();
  895. spinner.tick(
  896. "Waiting for Claude",
  897. TerminalRenderer::new().color_theme(),
  898. &mut stdout,
  899. )?;
  900. let result = self.runtime.run_turn(input, None);
  901. match result {
  902. Ok(_) => {
  903. spinner.finish(
  904. "Claude response complete",
  905. TerminalRenderer::new().color_theme(),
  906. &mut stdout,
  907. )?;
  908. println!();
  909. self.persist_session()?;
  910. Ok(())
  911. }
  912. Err(error) => {
  913. spinner.fail(
  914. "Claude request failed",
  915. TerminalRenderer::new().color_theme(),
  916. &mut stdout,
  917. )?;
  918. Err(Box::new(error))
  919. }
  920. }
  921. }
  922. fn run_turn_with_output(
  923. &mut self,
  924. input: &str,
  925. output_format: CliOutputFormat,
  926. ) -> Result<(), Box<dyn std::error::Error>> {
  927. match output_format {
  928. CliOutputFormat::Text => self.run_turn(input),
  929. CliOutputFormat::Json => self.run_prompt_json(input),
  930. }
  931. }
  932. fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
  933. let client = AnthropicClient::from_auth(resolve_cli_auth_source()?);
  934. let request = MessageRequest {
  935. model: self.model.clone(),
  936. max_tokens: DEFAULT_MAX_TOKENS,
  937. messages: vec![InputMessage {
  938. role: "user".to_string(),
  939. content: vec![InputContentBlock::Text {
  940. text: input.to_string(),
  941. }],
  942. }],
  943. system: (!self.system_prompt.is_empty()).then(|| self.system_prompt.join("\n\n")),
  944. tools: None,
  945. tool_choice: None,
  946. stream: false,
  947. };
  948. let runtime = tokio::runtime::Runtime::new()?;
  949. let response = runtime.block_on(client.send_message(&request))?;
  950. let text = response
  951. .content
  952. .iter()
  953. .filter_map(|block| match block {
  954. OutputContentBlock::Text { text } => Some(text.as_str()),
  955. OutputContentBlock::ToolUse { .. } => None,
  956. })
  957. .collect::<Vec<_>>()
  958. .join("");
  959. println!(
  960. "{}",
  961. json!({
  962. "message": text,
  963. "model": self.model,
  964. "usage": {
  965. "input_tokens": response.usage.input_tokens,
  966. "output_tokens": response.usage.output_tokens,
  967. "cache_creation_input_tokens": response.usage.cache_creation_input_tokens,
  968. "cache_read_input_tokens": response.usage.cache_read_input_tokens,
  969. }
  970. })
  971. );
  972. Ok(())
  973. }
  974. fn handle_repl_command(
  975. &mut self,
  976. command: SlashCommand,
  977. ) -> Result<(), Box<dyn std::error::Error>> {
  978. match command {
  979. SlashCommand::Help => println!("{}", render_repl_help()),
  980. SlashCommand::Status => self.print_status(),
  981. SlashCommand::Compact => self.compact()?,
  982. SlashCommand::Model { model } => self.set_model(model)?,
  983. SlashCommand::Permissions { mode } => self.set_permissions(mode)?,
  984. SlashCommand::Clear { confirm } => self.clear_session(confirm)?,
  985. SlashCommand::Cost => self.print_cost(),
  986. SlashCommand::Resume { session_path } => self.resume_session(session_path)?,
  987. SlashCommand::Config { section } => Self::print_config(section.as_deref())?,
  988. SlashCommand::Memory => Self::print_memory()?,
  989. SlashCommand::Init => Self::run_init()?,
  990. SlashCommand::Diff => Self::print_diff()?,
  991. SlashCommand::Version => Self::print_version(),
  992. SlashCommand::Export { path } => self.export_session(path.as_deref())?,
  993. SlashCommand::Session { action, target } => {
  994. self.handle_session_command(action.as_deref(), target.as_deref())?;
  995. }
  996. SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
  997. }
  998. Ok(())
  999. }
  1000. fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
  1001. self.runtime.session().save_to_path(&self.session.path)?;
  1002. Ok(())
  1003. }
  1004. fn print_status(&self) {
  1005. let cumulative = self.runtime.usage().cumulative_usage();
  1006. let latest = self.runtime.usage().current_turn_usage();
  1007. println!(
  1008. "{}",
  1009. format_status_report(
  1010. &self.model,
  1011. StatusUsage {
  1012. message_count: self.runtime.session().messages.len(),
  1013. turns: self.runtime.usage().turns(),
  1014. latest,
  1015. cumulative,
  1016. estimated_tokens: self.runtime.estimated_tokens(),
  1017. },
  1018. permission_mode_label(),
  1019. &status_context(Some(&self.session.path)).expect("status context should load"),
  1020. )
  1021. );
  1022. }
  1023. fn set_model(&mut self, model: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
  1024. let Some(model) = model else {
  1025. println!(
  1026. "{}",
  1027. format_model_report(
  1028. &self.model,
  1029. self.runtime.session().messages.len(),
  1030. self.runtime.usage().turns(),
  1031. )
  1032. );
  1033. return Ok(());
  1034. };
  1035. if model == self.model {
  1036. println!(
  1037. "{}",
  1038. format_model_report(
  1039. &self.model,
  1040. self.runtime.session().messages.len(),
  1041. self.runtime.usage().turns(),
  1042. )
  1043. );
  1044. return Ok(());
  1045. }
  1046. let previous = self.model.clone();
  1047. let session = self.runtime.session().clone();
  1048. let message_count = session.messages.len();
  1049. let config = load_runtime_config()?;
  1050. self.runtime = build_runtime(
  1051. session,
  1052. model.clone(),
  1053. self.system_prompt.clone(),
  1054. true,
  1055. self.allowed_tools.clone(),
  1056. &config,
  1057. resolved_permission_mode_label(&config),
  1058. )?;
  1059. self.model.clone_from(&model);
  1060. self.persist_session()?;
  1061. println!(
  1062. "{}",
  1063. format_model_switch_report(&previous, &model, message_count)
  1064. );
  1065. Ok(())
  1066. }
  1067. fn set_permissions(&mut self, mode: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
  1068. let Some(mode) = mode else {
  1069. println!("{}", format_permissions_report(permission_mode_label()));
  1070. return Ok(());
  1071. };
  1072. let normalized = normalize_permission_mode(&mode).ok_or_else(|| {
  1073. format!(
  1074. "unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access."
  1075. )
  1076. })?;
  1077. if normalized == permission_mode_label() {
  1078. println!("{}", format_permissions_report(normalized));
  1079. return Ok(());
  1080. }
  1081. let previous = permission_mode_label().to_string();
  1082. let session = self.runtime.session().clone();
  1083. let config = load_runtime_config()?;
  1084. self.runtime = build_runtime_with_permission_mode(
  1085. session,
  1086. self.model.clone(),
  1087. self.system_prompt.clone(),
  1088. true,
  1089. self.allowed_tools.clone(),
  1090. &config,
  1091. normalized,
  1092. )?;
  1093. self.persist_session()?;
  1094. println!(
  1095. "{}",
  1096. format_permissions_switch_report(&previous, normalized)
  1097. );
  1098. Ok(())
  1099. }
  1100. fn clear_session(&mut self, confirm: bool) -> Result<(), Box<dyn std::error::Error>> {
  1101. if !confirm {
  1102. println!(
  1103. "clear: confirmation required; run /clear --confirm to start a fresh session."
  1104. );
  1105. return Ok(());
  1106. }
  1107. self.session = create_managed_session_handle()?;
  1108. let config = load_runtime_config()?;
  1109. self.runtime = build_runtime_with_permission_mode(
  1110. Session::new(),
  1111. self.model.clone(),
  1112. self.system_prompt.clone(),
  1113. true,
  1114. self.allowed_tools.clone(),
  1115. &config,
  1116. permission_mode_label(),
  1117. )?;
  1118. self.persist_session()?;
  1119. println!(
  1120. "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}",
  1121. self.model,
  1122. permission_mode_label(),
  1123. self.session.id,
  1124. );
  1125. Ok(())
  1126. }
  1127. fn print_cost(&self) {
  1128. let cumulative = self.runtime.usage().cumulative_usage();
  1129. println!("{}", format_cost_report(cumulative));
  1130. }
  1131. fn resume_session(
  1132. &mut self,
  1133. session_path: Option<String>,
  1134. ) -> Result<(), Box<dyn std::error::Error>> {
  1135. let Some(session_ref) = session_path else {
  1136. println!("Usage: /resume <session-path>");
  1137. return Ok(());
  1138. };
  1139. let handle = resolve_session_reference(&session_ref)?;
  1140. let session = Session::load_from_path(&handle.path)?;
  1141. let message_count = session.messages.len();
  1142. let config = load_runtime_config()?;
  1143. self.runtime = build_runtime_with_permission_mode(
  1144. session,
  1145. self.model.clone(),
  1146. self.system_prompt.clone(),
  1147. true,
  1148. self.allowed_tools.clone(),
  1149. &config,
  1150. permission_mode_label(),
  1151. )?;
  1152. self.session = handle;
  1153. self.persist_session()?;
  1154. println!(
  1155. "{}",
  1156. format_resume_report(
  1157. &self.session.path.display().to_string(),
  1158. message_count,
  1159. self.runtime.usage().turns(),
  1160. )
  1161. );
  1162. Ok(())
  1163. }
  1164. fn print_config(section: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
  1165. println!("{}", render_config_report(section)?);
  1166. Ok(())
  1167. }
  1168. fn print_memory() -> Result<(), Box<dyn std::error::Error>> {
  1169. println!("{}", render_memory_report()?);
  1170. Ok(())
  1171. }
  1172. fn run_init() -> Result<(), Box<dyn std::error::Error>> {
  1173. println!("{}", init_claude_md()?);
  1174. Ok(())
  1175. }
  1176. fn print_diff() -> Result<(), Box<dyn std::error::Error>> {
  1177. println!("{}", render_diff_report()?);
  1178. Ok(())
  1179. }
  1180. fn print_version() {
  1181. println!("{}", render_version_report());
  1182. }
  1183. fn export_session(
  1184. &self,
  1185. requested_path: Option<&str>,
  1186. ) -> Result<(), Box<dyn std::error::Error>> {
  1187. let export_path = resolve_export_path(requested_path, self.runtime.session())?;
  1188. fs::write(&export_path, render_export_text(self.runtime.session()))?;
  1189. println!(
  1190. "Export\n Result wrote transcript\n File {}\n Messages {}",
  1191. export_path.display(),
  1192. self.runtime.session().messages.len(),
  1193. );
  1194. Ok(())
  1195. }
  1196. fn handle_session_command(
  1197. &mut self,
  1198. action: Option<&str>,
  1199. target: Option<&str>,
  1200. ) -> Result<(), Box<dyn std::error::Error>> {
  1201. match action {
  1202. None | Some("list") => {
  1203. println!("{}", render_session_list(&self.session.id)?);
  1204. Ok(())
  1205. }
  1206. Some("switch") => {
  1207. let Some(target) = target else {
  1208. println!("Usage: /session switch <session-id>");
  1209. return Ok(());
  1210. };
  1211. let handle = resolve_session_reference(target)?;
  1212. let session = Session::load_from_path(&handle.path)?;
  1213. let message_count = session.messages.len();
  1214. let config = load_runtime_config()?;
  1215. self.runtime = build_runtime_with_permission_mode(
  1216. session,
  1217. self.model.clone(),
  1218. self.system_prompt.clone(),
  1219. true,
  1220. self.allowed_tools.clone(),
  1221. &config,
  1222. permission_mode_label(),
  1223. )?;
  1224. self.session = handle;
  1225. self.persist_session()?;
  1226. println!(
  1227. "Session switched\n Active session {}\n File {}\n Messages {}",
  1228. self.session.id,
  1229. self.session.path.display(),
  1230. message_count,
  1231. );
  1232. Ok(())
  1233. }
  1234. Some(other) => {
  1235. println!("Unknown /session action '{other}'. Use /session list or /session switch <session-id>.");
  1236. Ok(())
  1237. }
  1238. }
  1239. }
  1240. fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
  1241. let result = self.runtime.compact(CompactionConfig::default());
  1242. let removed = result.removed_message_count;
  1243. let kept = result.compacted_session.messages.len();
  1244. let skipped = removed == 0;
  1245. let config = load_runtime_config()?;
  1246. self.runtime = build_runtime_with_permission_mode(
  1247. result.compacted_session,
  1248. self.model.clone(),
  1249. self.system_prompt.clone(),
  1250. true,
  1251. self.allowed_tools.clone(),
  1252. &config,
  1253. permission_mode_label(),
  1254. )?;
  1255. self.persist_session()?;
  1256. println!("{}", format_compact_report(removed, kept, skipped));
  1257. Ok(())
  1258. }
  1259. }
  1260. fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
  1261. let cwd = env::current_dir()?;
  1262. let path = cwd.join(".claude").join("sessions");
  1263. fs::create_dir_all(&path)?;
  1264. Ok(path)
  1265. }
  1266. fn create_managed_session_handle() -> Result<SessionHandle, Box<dyn std::error::Error>> {
  1267. let id = generate_session_id();
  1268. let path = sessions_dir()?.join(format!("{id}.json"));
  1269. Ok(SessionHandle { id, path })
  1270. }
  1271. fn generate_session_id() -> String {
  1272. let millis = SystemTime::now()
  1273. .duration_since(UNIX_EPOCH)
  1274. .map(|duration| duration.as_millis())
  1275. .unwrap_or_default();
  1276. format!("session-{millis}")
  1277. }
  1278. fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> {
  1279. let direct = PathBuf::from(reference);
  1280. let path = if direct.exists() {
  1281. direct
  1282. } else {
  1283. sessions_dir()?.join(format!("{reference}.json"))
  1284. };
  1285. if !path.exists() {
  1286. return Err(format!("session not found: {reference}").into());
  1287. }
  1288. let id = path
  1289. .file_stem()
  1290. .and_then(|value| value.to_str())
  1291. .unwrap_or(reference)
  1292. .to_string();
  1293. Ok(SessionHandle { id, path })
  1294. }
  1295. fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> {
  1296. let mut sessions = Vec::new();
  1297. for entry in fs::read_dir(sessions_dir()?)? {
  1298. let entry = entry?;
  1299. let path = entry.path();
  1300. if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
  1301. continue;
  1302. }
  1303. let metadata = entry.metadata()?;
  1304. let modified_epoch_secs = metadata
  1305. .modified()
  1306. .ok()
  1307. .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
  1308. .map(|duration| duration.as_secs())
  1309. .unwrap_or_default();
  1310. let message_count = Session::load_from_path(&path)
  1311. .map(|session| session.messages.len())
  1312. .unwrap_or_default();
  1313. let id = path
  1314. .file_stem()
  1315. .and_then(|value| value.to_str())
  1316. .unwrap_or("unknown")
  1317. .to_string();
  1318. sessions.push(ManagedSessionSummary {
  1319. id,
  1320. path,
  1321. modified_epoch_secs,
  1322. message_count,
  1323. });
  1324. }
  1325. sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs));
  1326. Ok(sessions)
  1327. }
  1328. fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::error::Error>> {
  1329. let sessions = list_managed_sessions()?;
  1330. let mut lines = vec![
  1331. "Sessions".to_string(),
  1332. format!(" Directory {}", sessions_dir()?.display()),
  1333. ];
  1334. if sessions.is_empty() {
  1335. lines.push(" No managed sessions saved yet.".to_string());
  1336. return Ok(lines.join("\n"));
  1337. }
  1338. for session in sessions {
  1339. let marker = if session.id == active_session_id {
  1340. "● current"
  1341. } else {
  1342. "○ saved"
  1343. };
  1344. lines.push(format!(
  1345. " {id:<20} {marker:<10} msgs={msgs:<4} modified={modified} path={path}",
  1346. id = session.id,
  1347. msgs = session.message_count,
  1348. modified = session.modified_epoch_secs,
  1349. path = session.path.display(),
  1350. ));
  1351. }
  1352. Ok(lines.join("\n"))
  1353. }
  1354. fn render_repl_help() -> String {
  1355. [
  1356. "REPL".to_string(),
  1357. " /exit Quit the REPL".to_string(),
  1358. " /quit Quit the REPL".to_string(),
  1359. String::new(),
  1360. render_slash_command_help(),
  1361. ]
  1362. .join(
  1363. "
  1364. ",
  1365. )
  1366. }
  1367. fn status_context(
  1368. session_path: Option<&Path>,
  1369. ) -> Result<StatusContext, Box<dyn std::error::Error>> {
  1370. let cwd = env::current_dir()?;
  1371. let loader = ConfigLoader::default_for(&cwd);
  1372. let discovered_config_files = loader.discover().len();
  1373. let runtime_config = loader.load()?;
  1374. let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
  1375. let (project_root, git_branch) =
  1376. parse_git_status_metadata(project_context.git_status.as_deref());
  1377. Ok(StatusContext {
  1378. cwd,
  1379. session_path: session_path.map(Path::to_path_buf),
  1380. loaded_config_files: runtime_config.loaded_entries().len(),
  1381. discovered_config_files,
  1382. memory_file_count: project_context.instruction_files.len(),
  1383. project_root,
  1384. git_branch,
  1385. })
  1386. }
  1387. fn format_status_report(
  1388. model: &str,
  1389. usage: StatusUsage,
  1390. permission_mode: &str,
  1391. context: &StatusContext,
  1392. ) -> String {
  1393. [
  1394. format!(
  1395. "Status
  1396. Model {model}
  1397. Permission mode {permission_mode}
  1398. Messages {}
  1399. Turns {}
  1400. Estimated tokens {}",
  1401. usage.message_count, usage.turns, usage.estimated_tokens,
  1402. ),
  1403. format!(
  1404. "Usage
  1405. Latest total {}
  1406. Cumulative input {}
  1407. Cumulative output {}
  1408. Cumulative total {}",
  1409. usage.latest.total_tokens(),
  1410. usage.cumulative.input_tokens,
  1411. usage.cumulative.output_tokens,
  1412. usage.cumulative.total_tokens(),
  1413. ),
  1414. format!(
  1415. "Workspace
  1416. Cwd {}
  1417. Project root {}
  1418. Git branch {}
  1419. Session {}
  1420. Config files loaded {}/{}
  1421. Memory files {}",
  1422. context.cwd.display(),
  1423. context
  1424. .project_root
  1425. .as_ref()
  1426. .map_or_else(|| "unknown".to_string(), |path| path.display().to_string()),
  1427. context.git_branch.as_deref().unwrap_or("unknown"),
  1428. context.session_path.as_ref().map_or_else(
  1429. || "live-repl".to_string(),
  1430. |path| path.display().to_string()
  1431. ),
  1432. context.loaded_config_files,
  1433. context.discovered_config_files,
  1434. context.memory_file_count,
  1435. ),
  1436. ]
  1437. .join(
  1438. "
  1439. ",
  1440. )
  1441. }
  1442. fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::error::Error>> {
  1443. let cwd = env::current_dir()?;
  1444. let loader = ConfigLoader::default_for(&cwd);
  1445. let discovered = loader.discover();
  1446. let runtime_config = loader.load()?;
  1447. let mut lines = vec![
  1448. format!(
  1449. "Config
  1450. Working directory {}
  1451. Loaded files {}
  1452. Merged keys {}",
  1453. cwd.display(),
  1454. runtime_config.loaded_entries().len(),
  1455. runtime_config.merged().len()
  1456. ),
  1457. "Discovered files".to_string(),
  1458. ];
  1459. for entry in discovered {
  1460. let source = match entry.source {
  1461. ConfigSource::User => "user",
  1462. ConfigSource::Project => "project",
  1463. ConfigSource::Local => "local",
  1464. };
  1465. let status = if runtime_config
  1466. .loaded_entries()
  1467. .iter()
  1468. .any(|loaded_entry| loaded_entry.path == entry.path)
  1469. {
  1470. "loaded"
  1471. } else {
  1472. "missing"
  1473. };
  1474. lines.push(format!(
  1475. " {source:<7} {status:<7} {}",
  1476. entry.path.display()
  1477. ));
  1478. }
  1479. if let Some(section) = section {
  1480. lines.push(format!("Merged section: {section}"));
  1481. let value = match section {
  1482. "env" => runtime_config.get("env"),
  1483. "hooks" => runtime_config.get("hooks"),
  1484. "model" => runtime_config.get("model"),
  1485. other => {
  1486. lines.push(format!(
  1487. " Unsupported config section '{other}'. Use env, hooks, or model."
  1488. ));
  1489. return Ok(lines.join(
  1490. "
  1491. ",
  1492. ));
  1493. }
  1494. };
  1495. lines.push(format!(
  1496. " {}",
  1497. match value {
  1498. Some(value) => value.render(),
  1499. None => "<unset>".to_string(),
  1500. }
  1501. ));
  1502. return Ok(lines.join(
  1503. "
  1504. ",
  1505. ));
  1506. }
  1507. lines.push("Merged JSON".to_string());
  1508. lines.push(format!(" {}", runtime_config.as_json().render()));
  1509. Ok(lines.join(
  1510. "
  1511. ",
  1512. ))
  1513. }
  1514. fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
  1515. let cwd = env::current_dir()?;
  1516. let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
  1517. let mut lines = vec![format!(
  1518. "Memory
  1519. Working directory {}
  1520. Instruction files {}",
  1521. cwd.display(),
  1522. project_context.instruction_files.len()
  1523. )];
  1524. if project_context.instruction_files.is_empty() {
  1525. lines.push("Discovered files".to_string());
  1526. lines.push(
  1527. " No CLAUDE instruction files discovered in the current directory ancestry."
  1528. .to_string(),
  1529. );
  1530. } else {
  1531. lines.push("Discovered files".to_string());
  1532. for (index, file) in project_context.instruction_files.iter().enumerate() {
  1533. let preview = file.content.lines().next().unwrap_or("").trim();
  1534. let preview = if preview.is_empty() {
  1535. "<empty>"
  1536. } else {
  1537. preview
  1538. };
  1539. lines.push(format!(" {}. {}", index + 1, file.path.display(),));
  1540. lines.push(format!(
  1541. " lines={} preview={}",
  1542. file.content.lines().count(),
  1543. preview
  1544. ));
  1545. }
  1546. }
  1547. Ok(lines.join(
  1548. "
  1549. ",
  1550. ))
  1551. }
  1552. fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
  1553. let cwd = env::current_dir()?;
  1554. let claude_md = cwd.join("CLAUDE.md");
  1555. if claude_md.exists() {
  1556. return Ok(format_init_report(&claude_md, false));
  1557. }
  1558. let content = render_init_claude_md(&cwd);
  1559. fs::write(&claude_md, content)?;
  1560. Ok(format_init_report(&claude_md, true))
  1561. }
  1562. fn render_init_claude_md(cwd: &Path) -> String {
  1563. let mut lines = vec![
  1564. "# CLAUDE.md".to_string(),
  1565. String::new(),
  1566. "This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.".to_string(),
  1567. String::new(),
  1568. ];
  1569. let mut command_lines = Vec::new();
  1570. if cwd.join("rust").join("Cargo.toml").is_file() {
  1571. command_lines.push("- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
  1572. } else if cwd.join("Cargo.toml").is_file() {
  1573. command_lines.push("- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
  1574. }
  1575. if cwd.join("tests").is_dir() && cwd.join("src").is_dir() {
  1576. command_lines.push("- `src/` and `tests/` are also present; check those surfaces before removing or renaming Python-era compatibility assets.".to_string());
  1577. }
  1578. if !command_lines.is_empty() {
  1579. lines.push("## Verification".to_string());
  1580. lines.extend(command_lines);
  1581. lines.push(String::new());
  1582. }
  1583. let mut structure_lines = Vec::new();
  1584. if cwd.join("rust").is_dir() {
  1585. structure_lines.push(
  1586. "- `rust/` contains the Rust workspace and the active CLI/runtime implementation."
  1587. .to_string(),
  1588. );
  1589. }
  1590. if cwd.join("src").is_dir() {
  1591. structure_lines.push("- `src/` contains the older Python-first workspace artifacts referenced by the repo history and tests.".to_string());
  1592. }
  1593. if cwd.join("tests").is_dir() {
  1594. structure_lines.push("- `tests/` exercises compatibility and porting behavior across the repository surfaces.".to_string());
  1595. }
  1596. if !structure_lines.is_empty() {
  1597. lines.push("## Repository shape".to_string());
  1598. lines.extend(structure_lines);
  1599. lines.push(String::new());
  1600. }
  1601. lines.push("## Working agreement".to_string());
  1602. lines.push("- Prefer small, reviewable Rust changes and keep slash-command behavior aligned between the shared command registry and the CLI entrypoints.".to_string());
  1603. lines.push("- Do not overwrite existing CLAUDE.md content automatically; update it intentionally when repo workflows change.".to_string());
  1604. lines.push(String::new());
  1605. lines.join(
  1606. "
  1607. ",
  1608. )
  1609. }
  1610. fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
  1611. match mode.trim() {
  1612. "read-only" => Some("read-only"),
  1613. "workspace-write" => Some("workspace-write"),
  1614. "danger-full-access" => Some("danger-full-access"),
  1615. _ => None,
  1616. }
  1617. }
  1618. fn permission_mode_label() -> &'static str {
  1619. let cwd = env::current_dir().ok();
  1620. let config = cwd.and_then(|cwd| ConfigLoader::default_for(cwd).load().ok());
  1621. config
  1622. .as_ref()
  1623. .map_or("workspace-write", resolved_permission_mode_label)
  1624. }
  1625. fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
  1626. let output = std::process::Command::new("git")
  1627. .args(["diff", "--", ":(exclude).omx"])
  1628. .current_dir(env::current_dir()?)
  1629. .output()?;
  1630. if !output.status.success() {
  1631. let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
  1632. return Err(format!("git diff failed: {stderr}").into());
  1633. }
  1634. let diff = String::from_utf8(output.stdout)?;
  1635. if diff.trim().is_empty() {
  1636. return Ok(
  1637. "Diff\n Result clean working tree\n Detail no current changes"
  1638. .to_string(),
  1639. );
  1640. }
  1641. Ok(format!("Diff\n\n{}", diff.trim_end()))
  1642. }
  1643. fn render_version_report() -> String {
  1644. let git_sha = GIT_SHA.unwrap_or("unknown");
  1645. let target = BUILD_TARGET.unwrap_or("unknown");
  1646. format!(
  1647. "Version\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}"
  1648. )
  1649. }
  1650. fn render_export_text(session: &Session) -> String {
  1651. let mut lines = vec!["# Conversation Export".to_string(), String::new()];
  1652. for (index, message) in session.messages.iter().enumerate() {
  1653. let role = match message.role {
  1654. MessageRole::System => "system",
  1655. MessageRole::User => "user",
  1656. MessageRole::Assistant => "assistant",
  1657. MessageRole::Tool => "tool",
  1658. };
  1659. lines.push(format!("## {}. {role}", index + 1));
  1660. for block in &message.blocks {
  1661. match block {
  1662. ContentBlock::Text { text } => lines.push(text.clone()),
  1663. ContentBlock::ToolUse { id, name, input } => {
  1664. lines.push(format!("[tool_use id={id} name={name}] {input}"));
  1665. }
  1666. ContentBlock::ToolResult {
  1667. tool_use_id,
  1668. tool_name,
  1669. output,
  1670. is_error,
  1671. } => {
  1672. lines.push(format!(
  1673. "[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}"
  1674. ));
  1675. }
  1676. }
  1677. }
  1678. lines.push(String::new());
  1679. }
  1680. lines.join("\n")
  1681. }
  1682. fn default_export_filename(session: &Session) -> String {
  1683. let stem = session
  1684. .messages
  1685. .iter()
  1686. .find_map(|message| match message.role {
  1687. MessageRole::User => message.blocks.iter().find_map(|block| match block {
  1688. ContentBlock::Text { text } => Some(text.as_str()),
  1689. _ => None,
  1690. }),
  1691. _ => None,
  1692. })
  1693. .map_or("conversation", |text| {
  1694. text.lines().next().unwrap_or("conversation")
  1695. })
  1696. .chars()
  1697. .map(|ch| {
  1698. if ch.is_ascii_alphanumeric() {
  1699. ch.to_ascii_lowercase()
  1700. } else {
  1701. '-'
  1702. }
  1703. })
  1704. .collect::<String>()
  1705. .split('-')
  1706. .filter(|part| !part.is_empty())
  1707. .take(8)
  1708. .collect::<Vec<_>>()
  1709. .join("-");
  1710. let fallback = if stem.is_empty() {
  1711. "conversation"
  1712. } else {
  1713. &stem
  1714. };
  1715. format!("{fallback}.txt")
  1716. }
  1717. fn resolve_export_path(
  1718. requested_path: Option<&str>,
  1719. session: &Session,
  1720. ) -> Result<PathBuf, Box<dyn std::error::Error>> {
  1721. let cwd = env::current_dir()?;
  1722. let file_name =
  1723. requested_path.map_or_else(|| default_export_filename(session), ToOwned::to_owned);
  1724. let final_name = if Path::new(&file_name)
  1725. .extension()
  1726. .is_some_and(|ext| ext.eq_ignore_ascii_case("txt"))
  1727. {
  1728. file_name
  1729. } else {
  1730. format!("{file_name}.txt")
  1731. };
  1732. Ok(cwd.join(final_name))
  1733. }
  1734. fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
  1735. Ok(load_system_prompt(
  1736. env::current_dir()?,
  1737. DEFAULT_DATE,
  1738. env::consts::OS,
  1739. "unknown",
  1740. )?)
  1741. }
  1742. fn build_runtime(
  1743. session: Session,
  1744. model: String,
  1745. system_prompt: Vec<String>,
  1746. enable_tools: bool,
  1747. allowed_tools: Option<AllowedToolSet>,
  1748. config: &runtime::RuntimeConfig,
  1749. permission_mode: &str,
  1750. ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
  1751. {
  1752. build_runtime_with_permission_mode(
  1753. session,
  1754. model,
  1755. system_prompt,
  1756. enable_tools,
  1757. allowed_tools,
  1758. config,
  1759. permission_mode,
  1760. )
  1761. }
  1762. fn build_runtime_with_permission_mode(
  1763. session: Session,
  1764. model: String,
  1765. system_prompt: Vec<String>,
  1766. enable_tools: bool,
  1767. allowed_tools: Option<AllowedToolSet>,
  1768. config: &runtime::RuntimeConfig,
  1769. permission_mode: &str,
  1770. ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
  1771. {
  1772. Ok(ConversationRuntime::new(
  1773. session,
  1774. AnthropicRuntimeClient::new(model, enable_tools, allowed_tools.clone(), config)?,
  1775. CliToolExecutor::new(allowed_tools, config),
  1776. permission_policy(permission_mode),
  1777. system_prompt,
  1778. ))
  1779. }
  1780. struct AnthropicRuntimeClient {
  1781. runtime: tokio::runtime::Runtime,
  1782. client: AnthropicClient,
  1783. model: String,
  1784. enable_tools: bool,
  1785. allowed_tools: Option<AllowedToolSet>,
  1786. mcp_tool_definitions: Vec<ToolDefinition>,
  1787. }
  1788. impl AnthropicRuntimeClient {
  1789. fn new(
  1790. model: String,
  1791. enable_tools: bool,
  1792. allowed_tools: Option<AllowedToolSet>,
  1793. config: &runtime::RuntimeConfig,
  1794. ) -> Result<Self, Box<dyn std::error::Error>> {
  1795. let mcp_tool_definitions = discover_mcp_tool_definitions(config, allowed_tools.as_ref())?;
  1796. Ok(Self {
  1797. runtime: tokio::runtime::Runtime::new()?,
  1798. client: AnthropicClient::from_auth(resolve_cli_auth_source()?),
  1799. model,
  1800. enable_tools,
  1801. allowed_tools,
  1802. mcp_tool_definitions,
  1803. })
  1804. }
  1805. }
  1806. fn discover_mcp_tool_definitions(
  1807. config: &runtime::RuntimeConfig,
  1808. allowed_tools: Option<&AllowedToolSet>,
  1809. ) -> Result<Vec<ToolDefinition>, Box<dyn std::error::Error>> {
  1810. if allowed_tools.is_some() || config.mcp().servers().is_empty() {
  1811. return Ok(Vec::new());
  1812. }
  1813. let runtime = tokio::runtime::Runtime::new()?;
  1814. let tools = runtime.block_on(async {
  1815. let mut manager = McpServerManager::from_runtime_config(config);
  1816. let tools = manager.discover_tools().await?;
  1817. manager.shutdown().await?;
  1818. Ok::<_, runtime::McpServerManagerError>(tools)
  1819. })?;
  1820. Ok(tools
  1821. .into_iter()
  1822. .map(|tool| ToolDefinition {
  1823. name: tool.qualified_name,
  1824. description: tool.tool.description,
  1825. input_schema: tool
  1826. .tool
  1827. .input_schema
  1828. .unwrap_or_else(|| serde_json::json!({"type":"object"})),
  1829. })
  1830. .collect())
  1831. }
  1832. fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
  1833. match AuthSource::from_env() {
  1834. Ok(auth) => Ok(auth),
  1835. Err(api::ApiError::MissingApiKey) => {
  1836. let cwd = env::current_dir()?;
  1837. let config = ConfigLoader::default_for(&cwd).load()?;
  1838. if let Some(oauth) = config.oauth() {
  1839. if let Some(token_set) = resolve_saved_oauth_token(oauth)? {
  1840. return Ok(AuthSource::from(token_set));
  1841. }
  1842. }
  1843. Ok(AuthSource::from_env_or_saved()?)
  1844. }
  1845. Err(error) => Err(Box::new(error)),
  1846. }
  1847. }
  1848. impl ApiClient for AnthropicRuntimeClient {
  1849. #[allow(clippy::too_many_lines)]
  1850. fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
  1851. let message_request = MessageRequest {
  1852. model: self.model.clone(),
  1853. max_tokens: DEFAULT_MAX_TOKENS,
  1854. messages: convert_messages(&request.messages),
  1855. system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
  1856. tools: self.enable_tools.then(|| {
  1857. filter_tool_specs(self.allowed_tools.as_ref())
  1858. .into_iter()
  1859. .map(|spec| ToolDefinition {
  1860. name: spec.name.to_string(),
  1861. description: Some(spec.description.to_string()),
  1862. input_schema: spec.input_schema,
  1863. })
  1864. .chain(self.mcp_tool_definitions.iter().cloned())
  1865. .collect()
  1866. }),
  1867. tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
  1868. stream: true,
  1869. };
  1870. self.runtime.block_on(async {
  1871. let mut stream = self
  1872. .client
  1873. .stream_message(&message_request)
  1874. .await
  1875. .map_err(|error| RuntimeError::new(error.to_string()))?;
  1876. let mut stdout = io::stdout();
  1877. let mut events = Vec::new();
  1878. let mut pending_tool: Option<(String, String, String)> = None;
  1879. let mut saw_stop = false;
  1880. while let Some(event) = stream
  1881. .next_event()
  1882. .await
  1883. .map_err(|error| RuntimeError::new(error.to_string()))?
  1884. {
  1885. match event {
  1886. ApiStreamEvent::MessageStart(start) => {
  1887. for block in start.message.content {
  1888. push_output_block(block, &mut stdout, &mut events, &mut pending_tool)?;
  1889. }
  1890. }
  1891. ApiStreamEvent::ContentBlockStart(start) => {
  1892. push_output_block(
  1893. start.content_block,
  1894. &mut stdout,
  1895. &mut events,
  1896. &mut pending_tool,
  1897. )?;
  1898. }
  1899. ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
  1900. ContentBlockDelta::TextDelta { text } => {
  1901. if !text.is_empty() {
  1902. write!(stdout, "{text}")
  1903. .and_then(|()| stdout.flush())
  1904. .map_err(|error| RuntimeError::new(error.to_string()))?;
  1905. events.push(AssistantEvent::TextDelta(text));
  1906. }
  1907. }
  1908. ContentBlockDelta::InputJsonDelta { partial_json } => {
  1909. if let Some((_, _, input)) = &mut pending_tool {
  1910. input.push_str(&partial_json);
  1911. }
  1912. }
  1913. },
  1914. ApiStreamEvent::ContentBlockStop(_) => {
  1915. if let Some((id, name, input)) = pending_tool.take() {
  1916. events.push(AssistantEvent::ToolUse { id, name, input });
  1917. }
  1918. }
  1919. ApiStreamEvent::MessageDelta(delta) => {
  1920. events.push(AssistantEvent::Usage(TokenUsage {
  1921. input_tokens: delta.usage.input_tokens,
  1922. output_tokens: delta.usage.output_tokens,
  1923. cache_creation_input_tokens: 0,
  1924. cache_read_input_tokens: 0,
  1925. }));
  1926. }
  1927. ApiStreamEvent::MessageStop(_) => {
  1928. saw_stop = true;
  1929. events.push(AssistantEvent::MessageStop);
  1930. }
  1931. }
  1932. }
  1933. if !saw_stop
  1934. && events.iter().any(|event| {
  1935. matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
  1936. || matches!(event, AssistantEvent::ToolUse { .. })
  1937. })
  1938. {
  1939. events.push(AssistantEvent::MessageStop);
  1940. }
  1941. if events
  1942. .iter()
  1943. .any(|event| matches!(event, AssistantEvent::MessageStop))
  1944. {
  1945. return Ok(events);
  1946. }
  1947. let response = self
  1948. .client
  1949. .send_message(&MessageRequest {
  1950. stream: false,
  1951. ..message_request.clone()
  1952. })
  1953. .await
  1954. .map_err(|error| RuntimeError::new(error.to_string()))?;
  1955. response_to_events(response, &mut stdout)
  1956. })
  1957. }
  1958. }
  1959. fn push_output_block(
  1960. block: OutputContentBlock,
  1961. out: &mut impl Write,
  1962. events: &mut Vec<AssistantEvent>,
  1963. pending_tool: &mut Option<(String, String, String)>,
  1964. ) -> Result<(), RuntimeError> {
  1965. match block {
  1966. OutputContentBlock::Text { text } => {
  1967. if !text.is_empty() {
  1968. write!(out, "{text}")
  1969. .and_then(|()| out.flush())
  1970. .map_err(|error| RuntimeError::new(error.to_string()))?;
  1971. events.push(AssistantEvent::TextDelta(text));
  1972. }
  1973. }
  1974. OutputContentBlock::ToolUse { id, name, input } => {
  1975. *pending_tool = Some((id, name, input.to_string()));
  1976. }
  1977. }
  1978. Ok(())
  1979. }
  1980. fn response_to_events(
  1981. response: MessageResponse,
  1982. out: &mut impl Write,
  1983. ) -> Result<Vec<AssistantEvent>, RuntimeError> {
  1984. let mut events = Vec::new();
  1985. let mut pending_tool = None;
  1986. for block in response.content {
  1987. push_output_block(block, out, &mut events, &mut pending_tool)?;
  1988. if let Some((id, name, input)) = pending_tool.take() {
  1989. events.push(AssistantEvent::ToolUse { id, name, input });
  1990. }
  1991. }
  1992. events.push(AssistantEvent::Usage(TokenUsage {
  1993. input_tokens: response.usage.input_tokens,
  1994. output_tokens: response.usage.output_tokens,
  1995. cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
  1996. cache_read_input_tokens: response.usage.cache_read_input_tokens,
  1997. }));
  1998. events.push(AssistantEvent::MessageStop);
  1999. Ok(events)
  2000. }
  2001. struct CliToolExecutor {
  2002. renderer: TerminalRenderer,
  2003. allowed_tools: Option<AllowedToolSet>,
  2004. mcp_runtime: Option<tokio::runtime::Runtime>,
  2005. mcp_servers: Option<McpServerManager>,
  2006. }
  2007. impl CliToolExecutor {
  2008. fn new(allowed_tools: Option<AllowedToolSet>, config: &runtime::RuntimeConfig) -> Self {
  2009. let mcp_servers = (!config.mcp().servers().is_empty())
  2010. .then(|| McpServerManager::from_runtime_config(config));
  2011. let mcp_runtime = mcp_servers
  2012. .as_ref()
  2013. .map(|_| tokio::runtime::Runtime::new().expect("mcp runtime"));
  2014. Self {
  2015. renderer: TerminalRenderer::new(),
  2016. allowed_tools,
  2017. mcp_runtime,
  2018. mcp_servers,
  2019. }
  2020. }
  2021. }
  2022. impl ToolExecutor for CliToolExecutor {
  2023. fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
  2024. if self
  2025. .allowed_tools
  2026. .as_ref()
  2027. .is_some_and(|allowed| !allowed.contains(tool_name))
  2028. {
  2029. return Err(ToolError::new(format!(
  2030. "tool `{tool_name}` is not enabled by the current --allowedTools setting"
  2031. )));
  2032. }
  2033. let value: serde_json::Value = serde_json::from_str(input)
  2034. .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
  2035. if tool_name.starts_with("mcp__") {
  2036. let runtime = self
  2037. .mcp_runtime
  2038. .as_mut()
  2039. .ok_or_else(|| ToolError::new("MCP runtime is not configured"))?;
  2040. let manager = self
  2041. .mcp_servers
  2042. .as_mut()
  2043. .ok_or_else(|| ToolError::new("MCP servers are not configured"))?;
  2044. let response = runtime
  2045. .block_on(manager.call_tool(tool_name, Some(value.clone())))
  2046. .map_err(|error| ToolError::new(error.to_string()))?;
  2047. let output = serde_json::to_string_pretty(&response)
  2048. .map_err(|error| ToolError::new(error.to_string()))?;
  2049. let markdown = format!(
  2050. "### Tool `{tool_name}`
  2051. ```json
  2052. {output}
  2053. ```
  2054. "
  2055. );
  2056. self.renderer
  2057. .stream_markdown(&markdown, &mut io::stdout())
  2058. .map_err(|error| ToolError::new(error.to_string()))?;
  2059. return Ok(output);
  2060. }
  2061. match execute_tool(tool_name, &value) {
  2062. Ok(output) => {
  2063. let markdown = format!("### Tool `{tool_name}`\n\n```json\n{output}\n```\n");
  2064. self.renderer
  2065. .stream_markdown(&markdown, &mut io::stdout())
  2066. .map_err(|error| ToolError::new(error.to_string()))?;
  2067. Ok(output)
  2068. }
  2069. Err(error) => Err(ToolError::new(error)),
  2070. }
  2071. }
  2072. }
  2073. fn permission_policy(mode: &str) -> PermissionPolicy {
  2074. if normalize_permission_mode(mode) == Some("read-only") {
  2075. PermissionPolicy::new(PermissionMode::Deny)
  2076. .with_tool_mode("read_file", PermissionMode::Allow)
  2077. .with_tool_mode("glob_search", PermissionMode::Allow)
  2078. .with_tool_mode("grep_search", PermissionMode::Allow)
  2079. } else {
  2080. PermissionPolicy::new(PermissionMode::Allow)
  2081. }
  2082. }
  2083. fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
  2084. messages
  2085. .iter()
  2086. .filter_map(|message| {
  2087. let role = match message.role {
  2088. MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
  2089. MessageRole::Assistant => "assistant",
  2090. };
  2091. let content = message
  2092. .blocks
  2093. .iter()
  2094. .map(|block| match block {
  2095. ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
  2096. ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
  2097. id: id.clone(),
  2098. name: name.clone(),
  2099. input: serde_json::from_str(input)
  2100. .unwrap_or_else(|_| serde_json::json!({ "raw": input })),
  2101. },
  2102. ContentBlock::ToolResult {
  2103. tool_use_id,
  2104. output,
  2105. is_error,
  2106. ..
  2107. } => InputContentBlock::ToolResult {
  2108. tool_use_id: tool_use_id.clone(),
  2109. content: vec![ToolResultContentBlock::Text {
  2110. text: output.clone(),
  2111. }],
  2112. is_error: *is_error,
  2113. },
  2114. })
  2115. .collect::<Vec<_>>();
  2116. (!content.is_empty()).then(|| InputMessage {
  2117. role: role.to_string(),
  2118. content,
  2119. })
  2120. })
  2121. .collect()
  2122. }
  2123. fn print_help() {
  2124. println!("rusty-claude-cli v{VERSION}");
  2125. println!();
  2126. println!("Usage:");
  2127. println!(" rusty-claude-cli [--model MODEL] [--allowedTools TOOL[,TOOL...]]");
  2128. println!(" Start the interactive REPL");
  2129. println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] prompt TEXT");
  2130. println!(" Send one prompt and exit");
  2131. println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] TEXT");
  2132. println!(" Shorthand non-interactive prompt mode");
  2133. println!(" rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]");
  2134. println!(" Inspect or maintain a saved session without entering the REPL");
  2135. println!(" rusty-claude-cli dump-manifests");
  2136. println!(" rusty-claude-cli bootstrap-plan");
  2137. println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
  2138. println!(" rusty-claude-cli login");
  2139. println!(" rusty-claude-cli logout");
  2140. println!();
  2141. println!("Flags:");
  2142. println!(" --model MODEL Override the active model");
  2143. println!(" --output-format FORMAT Non-interactive output format: text or json");
  2144. println!(" --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)");
  2145. println!(" --version, -V Print version and build information locally");
  2146. println!();
  2147. println!("Interactive slash commands:");
  2148. println!("{}", render_slash_command_help());
  2149. println!();
  2150. let resume_commands = resume_supported_slash_commands()
  2151. .into_iter()
  2152. .map(|spec| match spec.argument_hint {
  2153. Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
  2154. None => format!("/{}", spec.name),
  2155. })
  2156. .collect::<Vec<_>>()
  2157. .join(", ");
  2158. println!("Resume-safe commands: {resume_commands}");
  2159. println!("Examples:");
  2160. println!(" rusty-claude-cli --model claude-opus \"summarize this repo\"");
  2161. println!(" rusty-claude-cli --output-format json prompt \"explain src/main.rs\"");
  2162. println!(" rusty-claude-cli --allowedTools read,glob \"summarize Cargo.toml\"");
  2163. println!(" rusty-claude-cli --resume session.json /status /diff /export notes.txt");
  2164. println!(" rusty-claude-cli login");
  2165. }
  2166. #[cfg(test)]
  2167. mod tests {
  2168. use super::{
  2169. discover_mcp_tool_definitions, filter_tool_specs, format_compact_report,
  2170. format_cost_report, format_init_report, format_model_report, format_model_switch_report,
  2171. format_permissions_report, format_permissions_switch_report, format_resume_report,
  2172. format_status_report, normalize_permission_mode, parse_args, parse_git_status_metadata,
  2173. render_config_report, render_init_claude_md, render_memory_report, render_repl_help,
  2174. resolved_permission_mode_label, resume_supported_slash_commands, status_context, CliAction,
  2175. CliOutputFormat, RuntimeDefaults, SlashCommand, StatusUsage, DEFAULT_MODEL,
  2176. };
  2177. use runtime::{
  2178. ConfigLoader, ContentBlock, ConversationMessage, MessageRole, ResolvedPermissionMode,
  2179. };
  2180. use std::fs;
  2181. use std::os::unix::fs::PermissionsExt;
  2182. use std::path::{Path, PathBuf};
  2183. use std::time::{SystemTime, UNIX_EPOCH};
  2184. fn temp_dir() -> PathBuf {
  2185. let nanos = SystemTime::now()
  2186. .duration_since(UNIX_EPOCH)
  2187. .expect("time should be after epoch")
  2188. .as_nanos();
  2189. std::env::temp_dir().join(format!("rusty-claude-cli-tests-{nanos}"))
  2190. }
  2191. fn write_mcp_server_script() -> PathBuf {
  2192. let root = temp_dir();
  2193. fs::create_dir_all(&root).expect("temp dir");
  2194. let path = root.join("mcp-server.py");
  2195. fs::write(
  2196. &path,
  2197. r#"#!/usr/bin/env python3
  2198. import json
  2199. import sys
  2200. def send(obj):
  2201. payload = json.dumps(obj)
  2202. sys.stdout.write(f"Content-Length: {len(payload)}\r\n\r\n{payload}")
  2203. sys.stdout.flush()
  2204. def read_request():
  2205. headers = {}
  2206. while True:
  2207. line = sys.stdin.buffer.readline()
  2208. if not line:
  2209. return None
  2210. if line in (b"\r\n", b"\n"):
  2211. break
  2212. key, _, value = line.decode().partition(":")
  2213. headers[key.strip().lower()] = value.strip()
  2214. length = int(headers.get("content-length", "0"))
  2215. if length <= 0:
  2216. return None
  2217. payload = sys.stdin.buffer.read(length)
  2218. return json.loads(payload.decode())
  2219. while True:
  2220. req = read_request()
  2221. if req is None:
  2222. break
  2223. method = req.get("method")
  2224. req_id = req.get("id")
  2225. if method == "initialize":
  2226. send({
  2227. "jsonrpc": "2.0",
  2228. "id": req_id,
  2229. "result": {
  2230. "protocolVersion": "2025-03-26",
  2231. "capabilities": {},
  2232. "serverInfo": {"name": "test-server", "version": "0.1.0"}
  2233. }
  2234. })
  2235. elif method == "tools/list":
  2236. send({
  2237. "jsonrpc": "2.0",
  2238. "id": req_id,
  2239. "result": {
  2240. "tools": [{
  2241. "name": "echo",
  2242. "description": "Echo from MCP",
  2243. "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}}
  2244. }]
  2245. }
  2246. })
  2247. elif method == "tools/call":
  2248. send({
  2249. "jsonrpc": "2.0",
  2250. "id": req_id,
  2251. "result": {
  2252. "content": [{"type": "text", "text": req.get("params", {}).get("name", "")}]
  2253. }
  2254. })
  2255. "#,
  2256. )
  2257. .expect("write mcp server");
  2258. let mut permissions = fs::metadata(&path).expect("metadata").permissions();
  2259. permissions.set_mode(0o755);
  2260. fs::set_permissions(&path, permissions).expect("chmod");
  2261. path
  2262. }
  2263. #[test]
  2264. fn defaults_to_repl_when_no_args() {
  2265. assert_eq!(
  2266. parse_args(
  2267. &[],
  2268. &RuntimeDefaults {
  2269. model: DEFAULT_MODEL.to_string()
  2270. }
  2271. )
  2272. .expect("args should parse"),
  2273. CliAction::Repl {
  2274. model: DEFAULT_MODEL.to_string(),
  2275. allowed_tools: None,
  2276. }
  2277. );
  2278. }
  2279. #[test]
  2280. fn parse_args_uses_config_default_model_when_no_override_is_supplied() {
  2281. let parsed = parse_args(
  2282. &[],
  2283. &RuntimeDefaults {
  2284. model: "claude-opus-config".to_string(),
  2285. },
  2286. )
  2287. .expect("args should parse");
  2288. assert_eq!(
  2289. parsed,
  2290. CliAction::Repl {
  2291. model: "claude-opus-config".to_string(),
  2292. allowed_tools: None,
  2293. }
  2294. );
  2295. }
  2296. #[test]
  2297. fn explicit_model_flag_beats_config_default_model() {
  2298. let parsed = parse_args(
  2299. &["--model".to_string(), "cli-model".to_string()],
  2300. &RuntimeDefaults {
  2301. model: "config-model".to_string(),
  2302. },
  2303. )
  2304. .expect("args should parse");
  2305. assert_eq!(
  2306. parsed,
  2307. CliAction::Repl {
  2308. model: "cli-model".to_string(),
  2309. allowed_tools: None,
  2310. }
  2311. );
  2312. }
  2313. #[test]
  2314. fn parses_prompt_subcommand() {
  2315. let args = vec![
  2316. "prompt".to_string(),
  2317. "hello".to_string(),
  2318. "world".to_string(),
  2319. ];
  2320. assert_eq!(
  2321. parse_args(
  2322. &args,
  2323. &RuntimeDefaults {
  2324. model: DEFAULT_MODEL.to_string()
  2325. }
  2326. )
  2327. .expect("args should parse"),
  2328. CliAction::Prompt {
  2329. prompt: "hello world".to_string(),
  2330. model: DEFAULT_MODEL.to_string(),
  2331. output_format: CliOutputFormat::Text,
  2332. allowed_tools: None,
  2333. }
  2334. );
  2335. }
  2336. #[test]
  2337. fn parses_bare_prompt_and_json_output_flag() {
  2338. let args = vec![
  2339. "--output-format=json".to_string(),
  2340. "--model".to_string(),
  2341. "claude-opus".to_string(),
  2342. "explain".to_string(),
  2343. "this".to_string(),
  2344. ];
  2345. assert_eq!(
  2346. parse_args(
  2347. &args,
  2348. &RuntimeDefaults {
  2349. model: DEFAULT_MODEL.to_string()
  2350. }
  2351. )
  2352. .expect("args should parse"),
  2353. CliAction::Prompt {
  2354. prompt: "explain this".to_string(),
  2355. model: "claude-opus".to_string(),
  2356. output_format: CliOutputFormat::Json,
  2357. allowed_tools: None,
  2358. }
  2359. );
  2360. }
  2361. #[test]
  2362. fn parses_version_flags_without_initializing_prompt_mode() {
  2363. assert_eq!(
  2364. parse_args(
  2365. &["--version".to_string()],
  2366. &RuntimeDefaults {
  2367. model: DEFAULT_MODEL.to_string()
  2368. }
  2369. )
  2370. .expect("args should parse"),
  2371. CliAction::Version
  2372. );
  2373. assert_eq!(
  2374. parse_args(
  2375. &["-V".to_string()],
  2376. &RuntimeDefaults {
  2377. model: DEFAULT_MODEL.to_string()
  2378. }
  2379. )
  2380. .expect("args should parse"),
  2381. CliAction::Version
  2382. );
  2383. }
  2384. #[test]
  2385. fn parses_allowed_tools_flags_with_aliases_and_lists() {
  2386. let args = vec![
  2387. "--allowedTools".to_string(),
  2388. "read,glob".to_string(),
  2389. "--allowed-tools=write_file".to_string(),
  2390. ];
  2391. assert_eq!(
  2392. parse_args(
  2393. &args,
  2394. &RuntimeDefaults {
  2395. model: DEFAULT_MODEL.to_string()
  2396. }
  2397. )
  2398. .expect("args should parse"),
  2399. CliAction::Repl {
  2400. model: DEFAULT_MODEL.to_string(),
  2401. allowed_tools: Some(
  2402. ["glob_search", "read_file", "write_file"]
  2403. .into_iter()
  2404. .map(str::to_string)
  2405. .collect()
  2406. ),
  2407. }
  2408. );
  2409. }
  2410. #[test]
  2411. fn rejects_unknown_allowed_tools() {
  2412. let error = parse_args(
  2413. &["--allowedTools".to_string(), "teleport".to_string()],
  2414. &RuntimeDefaults {
  2415. model: DEFAULT_MODEL.to_string(),
  2416. },
  2417. )
  2418. .expect_err("tool should be rejected");
  2419. assert!(error.contains("unsupported tool in --allowedTools: teleport"));
  2420. }
  2421. #[test]
  2422. fn parses_system_prompt_options() {
  2423. let args = vec![
  2424. "system-prompt".to_string(),
  2425. "--cwd".to_string(),
  2426. "/tmp/project".to_string(),
  2427. "--date".to_string(),
  2428. "2026-04-01".to_string(),
  2429. ];
  2430. assert_eq!(
  2431. parse_args(
  2432. &args,
  2433. &RuntimeDefaults {
  2434. model: DEFAULT_MODEL.to_string()
  2435. }
  2436. )
  2437. .expect("args should parse"),
  2438. CliAction::PrintSystemPrompt {
  2439. cwd: PathBuf::from("/tmp/project"),
  2440. date: "2026-04-01".to_string(),
  2441. }
  2442. );
  2443. }
  2444. #[test]
  2445. fn parses_login_and_logout_subcommands() {
  2446. assert_eq!(
  2447. parse_args(
  2448. &["login".to_string()],
  2449. &RuntimeDefaults {
  2450. model: DEFAULT_MODEL.to_string(),
  2451. },
  2452. )
  2453. .expect("login should parse"),
  2454. CliAction::Login
  2455. );
  2456. assert_eq!(
  2457. parse_args(
  2458. &["logout".to_string()],
  2459. &RuntimeDefaults {
  2460. model: DEFAULT_MODEL.to_string(),
  2461. },
  2462. )
  2463. .expect("logout should parse"),
  2464. CliAction::Logout
  2465. );
  2466. }
  2467. #[test]
  2468. fn parses_resume_flag_with_slash_command() {
  2469. let args = vec![
  2470. "--resume".to_string(),
  2471. "session.json".to_string(),
  2472. "/compact".to_string(),
  2473. ];
  2474. assert_eq!(
  2475. parse_args(
  2476. &args,
  2477. &RuntimeDefaults {
  2478. model: DEFAULT_MODEL.to_string()
  2479. }
  2480. )
  2481. .expect("args should parse"),
  2482. CliAction::ResumeSession {
  2483. session_path: PathBuf::from("session.json"),
  2484. commands: vec!["/compact".to_string()],
  2485. }
  2486. );
  2487. }
  2488. #[test]
  2489. fn parses_resume_flag_with_multiple_slash_commands() {
  2490. let args = vec![
  2491. "--resume".to_string(),
  2492. "session.json".to_string(),
  2493. "/status".to_string(),
  2494. "/compact".to_string(),
  2495. "/cost".to_string(),
  2496. ];
  2497. assert_eq!(
  2498. parse_args(
  2499. &args,
  2500. &RuntimeDefaults {
  2501. model: DEFAULT_MODEL.to_string()
  2502. }
  2503. )
  2504. .expect("args should parse"),
  2505. CliAction::ResumeSession {
  2506. session_path: PathBuf::from("session.json"),
  2507. commands: vec![
  2508. "/status".to_string(),
  2509. "/compact".to_string(),
  2510. "/cost".to_string(),
  2511. ],
  2512. }
  2513. );
  2514. }
  2515. #[test]
  2516. fn filtered_tool_specs_respect_allowlist() {
  2517. let allowed = ["read_file", "grep_search"]
  2518. .into_iter()
  2519. .map(str::to_string)
  2520. .collect();
  2521. let filtered = filter_tool_specs(Some(&allowed));
  2522. let names = filtered
  2523. .into_iter()
  2524. .map(|spec| spec.name)
  2525. .collect::<Vec<_>>();
  2526. assert_eq!(names, vec!["read_file", "grep_search"]);
  2527. }
  2528. #[test]
  2529. fn shared_help_uses_resume_annotation_copy() {
  2530. let help = commands::render_slash_command_help();
  2531. assert!(help.contains("Slash commands"));
  2532. assert!(help.contains("works with --resume SESSION.json"));
  2533. }
  2534. #[test]
  2535. fn repl_help_includes_shared_commands_and_exit() {
  2536. let help = render_repl_help();
  2537. assert!(help.contains("REPL"));
  2538. assert!(help.contains("/help"));
  2539. assert!(help.contains("/status"));
  2540. assert!(help.contains("/model [model]"));
  2541. assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
  2542. assert!(help.contains("/clear [--confirm]"));
  2543. assert!(help.contains("/cost"));
  2544. assert!(help.contains("/resume <session-path>"));
  2545. assert!(help.contains("/config [env|hooks|model]"));
  2546. assert!(help.contains("/memory"));
  2547. assert!(help.contains("/init"));
  2548. assert!(help.contains("/diff"));
  2549. assert!(help.contains("/version"));
  2550. assert!(help.contains("/export [file]"));
  2551. assert!(help.contains("/session [list|switch <session-id>]"));
  2552. assert!(help.contains("/exit"));
  2553. }
  2554. #[test]
  2555. fn resume_supported_command_list_matches_expected_surface() {
  2556. let names = resume_supported_slash_commands()
  2557. .into_iter()
  2558. .map(|spec| spec.name)
  2559. .collect::<Vec<_>>();
  2560. assert_eq!(
  2561. names,
  2562. vec![
  2563. "help", "status", "compact", "clear", "cost", "config", "memory", "init", "diff",
  2564. "version", "export",
  2565. ]
  2566. );
  2567. }
  2568. #[test]
  2569. fn resume_report_uses_sectioned_layout() {
  2570. let report = format_resume_report("session.json", 14, 6);
  2571. assert!(report.contains("Session resumed"));
  2572. assert!(report.contains("Session file session.json"));
  2573. assert!(report.contains("Messages 14"));
  2574. assert!(report.contains("Turns 6"));
  2575. }
  2576. #[test]
  2577. fn compact_report_uses_structured_output() {
  2578. let compacted = format_compact_report(8, 5, false);
  2579. assert!(compacted.contains("Compact"));
  2580. assert!(compacted.contains("Result compacted"));
  2581. assert!(compacted.contains("Messages removed 8"));
  2582. let skipped = format_compact_report(0, 3, true);
  2583. assert!(skipped.contains("Result skipped"));
  2584. }
  2585. #[test]
  2586. fn cost_report_uses_sectioned_layout() {
  2587. let report = format_cost_report(runtime::TokenUsage {
  2588. input_tokens: 20,
  2589. output_tokens: 8,
  2590. cache_creation_input_tokens: 3,
  2591. cache_read_input_tokens: 1,
  2592. });
  2593. assert!(report.contains("Cost"));
  2594. assert!(report.contains("Input tokens 20"));
  2595. assert!(report.contains("Output tokens 8"));
  2596. assert!(report.contains("Cache create 3"));
  2597. assert!(report.contains("Cache read 1"));
  2598. assert!(report.contains("Total tokens 32"));
  2599. }
  2600. #[test]
  2601. fn permissions_report_uses_sectioned_layout() {
  2602. let report = format_permissions_report("workspace-write");
  2603. assert!(report.contains("Permissions"));
  2604. assert!(report.contains("Active mode workspace-write"));
  2605. assert!(report.contains("Modes"));
  2606. assert!(report.contains("read-only ○ available Read/search tools only"));
  2607. assert!(report.contains("workspace-write ● current Edit files inside the workspace"));
  2608. assert!(report.contains("danger-full-access ○ available Unrestricted tool access"));
  2609. }
  2610. #[test]
  2611. fn permissions_switch_report_is_structured() {
  2612. let report = format_permissions_switch_report("read-only", "workspace-write");
  2613. assert!(report.contains("Permissions updated"));
  2614. assert!(report.contains("Result mode switched"));
  2615. assert!(report.contains("Previous mode read-only"));
  2616. assert!(report.contains("Active mode workspace-write"));
  2617. assert!(report.contains("Applies to subsequent tool calls"));
  2618. }
  2619. #[test]
  2620. fn init_report_uses_structured_output() {
  2621. let created = format_init_report(Path::new("/tmp/CLAUDE.md"), true);
  2622. assert!(created.contains("Init"));
  2623. assert!(created.contains("Result created"));
  2624. let skipped = format_init_report(Path::new("/tmp/CLAUDE.md"), false);
  2625. assert!(skipped.contains("skipped (already exists)"));
  2626. }
  2627. #[test]
  2628. fn model_report_uses_sectioned_layout() {
  2629. let report = format_model_report("claude-sonnet", 12, 4);
  2630. assert!(report.contains("Model"));
  2631. assert!(report.contains("Current model claude-sonnet"));
  2632. assert!(report.contains("Session messages 12"));
  2633. assert!(report.contains("Switch models with /model <name>"));
  2634. }
  2635. #[test]
  2636. fn model_switch_report_preserves_context_summary() {
  2637. let report = format_model_switch_report("claude-sonnet", "claude-opus", 9);
  2638. assert!(report.contains("Model updated"));
  2639. assert!(report.contains("Previous claude-sonnet"));
  2640. assert!(report.contains("Current claude-opus"));
  2641. assert!(report.contains("Preserved msgs 9"));
  2642. }
  2643. #[test]
  2644. fn status_line_reports_model_and_token_totals() {
  2645. let status = format_status_report(
  2646. "claude-sonnet",
  2647. StatusUsage {
  2648. message_count: 7,
  2649. turns: 3,
  2650. latest: runtime::TokenUsage {
  2651. input_tokens: 5,
  2652. output_tokens: 4,
  2653. cache_creation_input_tokens: 1,
  2654. cache_read_input_tokens: 0,
  2655. },
  2656. cumulative: runtime::TokenUsage {
  2657. input_tokens: 20,
  2658. output_tokens: 8,
  2659. cache_creation_input_tokens: 2,
  2660. cache_read_input_tokens: 1,
  2661. },
  2662. estimated_tokens: 128,
  2663. },
  2664. "workspace-write",
  2665. &super::StatusContext {
  2666. cwd: PathBuf::from("/tmp/project"),
  2667. session_path: Some(PathBuf::from("session.json")),
  2668. loaded_config_files: 2,
  2669. discovered_config_files: 3,
  2670. memory_file_count: 4,
  2671. project_root: Some(PathBuf::from("/tmp")),
  2672. git_branch: Some("main".to_string()),
  2673. },
  2674. );
  2675. assert!(status.contains("Status"));
  2676. assert!(status.contains("Model claude-sonnet"));
  2677. assert!(status.contains("Permission mode workspace-write"));
  2678. assert!(status.contains("Messages 7"));
  2679. assert!(status.contains("Latest total 10"));
  2680. assert!(status.contains("Cumulative total 31"));
  2681. assert!(status.contains("Cwd /tmp/project"));
  2682. assert!(status.contains("Project root /tmp"));
  2683. assert!(status.contains("Git branch main"));
  2684. assert!(status.contains("Session session.json"));
  2685. assert!(status.contains("Config files loaded 2/3"));
  2686. assert!(status.contains("Memory files 4"));
  2687. }
  2688. #[test]
  2689. fn config_report_supports_section_views() {
  2690. let report = render_config_report(Some("env")).expect("config report should render");
  2691. assert!(report.contains("Merged section: env"));
  2692. }
  2693. #[test]
  2694. fn memory_report_uses_sectioned_layout() {
  2695. let report = render_memory_report().expect("memory report should render");
  2696. assert!(report.contains("Memory"));
  2697. assert!(report.contains("Working directory"));
  2698. assert!(report.contains("Instruction files"));
  2699. assert!(report.contains("Discovered files"));
  2700. }
  2701. #[test]
  2702. fn config_report_uses_sectioned_layout() {
  2703. let report = render_config_report(None).expect("config report should render");
  2704. assert!(report.contains("Config"));
  2705. assert!(report.contains("Discovered files"));
  2706. assert!(report.contains("Merged JSON"));
  2707. }
  2708. #[test]
  2709. fn parses_git_status_metadata() {
  2710. let (root, branch) = parse_git_status_metadata(Some(
  2711. "## rcc/cli...origin/rcc/cli
  2712. M src/main.rs",
  2713. ));
  2714. assert_eq!(branch.as_deref(), Some("rcc/cli"));
  2715. let _ = root;
  2716. }
  2717. #[test]
  2718. fn status_context_reads_real_workspace_metadata() {
  2719. let context = status_context(None).expect("status context should load");
  2720. assert!(context.cwd.is_absolute());
  2721. assert_eq!(context.discovered_config_files, 5);
  2722. assert!(context.loaded_config_files <= context.discovered_config_files);
  2723. }
  2724. #[test]
  2725. fn resolved_permission_mode_prefers_env_override() {
  2726. let original = std::env::var("RUSTY_CLAUDE_PERMISSION_MODE").ok();
  2727. std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", "danger-full-access");
  2728. let config = runtime::RuntimeConfig::empty();
  2729. assert_eq!(
  2730. super::resolved_permission_mode_label(&config),
  2731. "danger-full-access"
  2732. );
  2733. if let Some(value) = original {
  2734. std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", value);
  2735. } else {
  2736. std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
  2737. }
  2738. }
  2739. #[test]
  2740. fn normalizes_supported_permission_modes() {
  2741. assert_eq!(normalize_permission_mode("read-only"), Some("read-only"));
  2742. assert_eq!(
  2743. normalize_permission_mode("workspace-write"),
  2744. Some("workspace-write")
  2745. );
  2746. assert_eq!(
  2747. normalize_permission_mode("danger-full-access"),
  2748. Some("danger-full-access")
  2749. );
  2750. assert_eq!(normalize_permission_mode("unknown"), None);
  2751. }
  2752. #[test]
  2753. fn resolves_permission_mode_from_config_defaults() {
  2754. let root = temp_dir();
  2755. let cwd = root.join("project");
  2756. let home = root.join("home").join(".claude");
  2757. fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
  2758. fs::create_dir_all(&home).expect("home config dir");
  2759. fs::write(
  2760. cwd.join(".claude").join("settings.json"),
  2761. r#"{"permissions":{"defaultMode":"dontAsk"}}"#,
  2762. )
  2763. .expect("write settings");
  2764. let config = ConfigLoader::new(&cwd, &home)
  2765. .load()
  2766. .expect("config should load");
  2767. assert_eq!(
  2768. config.permission_mode(),
  2769. Some(ResolvedPermissionMode::DangerFullAccess)
  2770. );
  2771. assert_eq!(
  2772. resolved_permission_mode_label(&config),
  2773. "danger-full-access"
  2774. );
  2775. fs::remove_dir_all(root).expect("cleanup temp dir");
  2776. }
  2777. #[test]
  2778. fn discovers_mcp_tool_definitions_from_config() {
  2779. let root = temp_dir();
  2780. let cwd = root.join("project");
  2781. let home = root.join("home").join(".claude");
  2782. fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
  2783. fs::create_dir_all(&home).expect("home config dir");
  2784. let script = write_mcp_server_script();
  2785. fs::write(
  2786. cwd.join(".claude").join("settings.json"),
  2787. format!(
  2788. r#"{{"mcpServers":{{"alpha":{{"command":"{}"}}}}}}"#,
  2789. script.display()
  2790. ),
  2791. )
  2792. .expect("write settings");
  2793. let config = ConfigLoader::new(&cwd, &home)
  2794. .load()
  2795. .expect("config should load");
  2796. let tool_defs =
  2797. discover_mcp_tool_definitions(&config, None).expect("mcp tool definitions should load");
  2798. assert_eq!(tool_defs.len(), 1);
  2799. assert_eq!(tool_defs[0].name, "mcp__alpha__echo");
  2800. assert_eq!(tool_defs[0].description.as_deref(), Some("Echo from MCP"));
  2801. fs::remove_dir_all(root).expect("cleanup temp dir");
  2802. fs::remove_file(&script).ok();
  2803. fs::remove_dir_all(script.parent().expect("script parent")).ok();
  2804. }
  2805. #[test]
  2806. fn clear_command_requires_explicit_confirmation_flag() {
  2807. assert_eq!(
  2808. SlashCommand::parse("/clear"),
  2809. Some(SlashCommand::Clear { confirm: false })
  2810. );
  2811. assert_eq!(
  2812. SlashCommand::parse("/clear --confirm"),
  2813. Some(SlashCommand::Clear { confirm: true })
  2814. );
  2815. }
  2816. #[test]
  2817. fn parses_resume_and_config_slash_commands() {
  2818. assert_eq!(
  2819. SlashCommand::parse("/resume saved-session.json"),
  2820. Some(SlashCommand::Resume {
  2821. session_path: Some("saved-session.json".to_string())
  2822. })
  2823. );
  2824. assert_eq!(
  2825. SlashCommand::parse("/clear --confirm"),
  2826. Some(SlashCommand::Clear { confirm: true })
  2827. );
  2828. assert_eq!(
  2829. SlashCommand::parse("/config"),
  2830. Some(SlashCommand::Config { section: None })
  2831. );
  2832. assert_eq!(
  2833. SlashCommand::parse("/config env"),
  2834. Some(SlashCommand::Config {
  2835. section: Some("env".to_string())
  2836. })
  2837. );
  2838. assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
  2839. assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
  2840. }
  2841. #[test]
  2842. fn init_template_mentions_detected_rust_workspace() {
  2843. let rendered = render_init_claude_md(Path::new("."));
  2844. assert!(rendered.contains("# CLAUDE.md"));
  2845. assert!(rendered.contains("cargo clippy --workspace --all-targets -- -D warnings"));
  2846. }
  2847. #[test]
  2848. fn converts_tool_roundtrip_messages() {
  2849. let messages = vec![
  2850. ConversationMessage::user_text("hello"),
  2851. ConversationMessage::assistant(vec![ContentBlock::ToolUse {
  2852. id: "tool-1".to_string(),
  2853. name: "bash".to_string(),
  2854. input: "{\"command\":\"pwd\"}".to_string(),
  2855. }]),
  2856. ConversationMessage {
  2857. role: MessageRole::Tool,
  2858. blocks: vec![ContentBlock::ToolResult {
  2859. tool_use_id: "tool-1".to_string(),
  2860. tool_name: "bash".to_string(),
  2861. output: "ok".to_string(),
  2862. is_error: false,
  2863. }],
  2864. usage: None,
  2865. },
  2866. ];
  2867. let converted = super::convert_messages(&messages);
  2868. assert_eq!(converted.len(), 3);
  2869. assert_eq!(converted[1].role, "assistant");
  2870. assert_eq!(converted[2].role, "user");
  2871. }
  2872. }