main.rs 107 KB

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