main.rs 142 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233
  1. mod init;
  2. mod input;
  3. mod render;
  4. use std::collections::BTreeSet;
  5. use std::env;
  6. use std::fmt::Write as _;
  7. use std::fs;
  8. use std::io::{self, Read, Write};
  9. use std::net::TcpListener;
  10. use std::path::{Path, PathBuf};
  11. use std::process::Command;
  12. use std::sync::mpsc::{self, RecvTimeoutError};
  13. use std::sync::{Arc, Mutex};
  14. use std::thread;
  15. use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
  16. use api::{
  17. resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock,
  18. InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
  19. StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
  20. };
  21. use commands::{
  22. handle_plugins_slash_command, render_slash_command_help, resume_supported_slash_commands,
  23. slash_command_specs, SlashCommand,
  24. };
  25. use compat_harness::{extract_manifest, UpstreamPaths};
  26. use init::initialize_repo;
  27. use plugins::{PluginManager, PluginManagerConfig, PluginRegistry};
  28. use render::{MarkdownStreamState, Spinner, TerminalRenderer};
  29. use runtime::{
  30. clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
  31. parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
  32. AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
  33. ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig,
  34. OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
  35. Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
  36. };
  37. use serde_json::json;
  38. use tools::GlobalToolRegistry;
  39. const DEFAULT_MODEL: &str = "claude-opus-4-6";
  40. fn max_tokens_for_model(model: &str) -> u32 {
  41. if model.contains("opus") {
  42. 32_000
  43. } else {
  44. 64_000
  45. }
  46. }
  47. const DEFAULT_DATE: &str = "2026-03-31";
  48. const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
  49. const VERSION: &str = env!("CARGO_PKG_VERSION");
  50. const BUILD_TARGET: Option<&str> = option_env!("TARGET");
  51. const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
  52. const INTERNAL_PROGRESS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(3);
  53. type AllowedToolSet = BTreeSet<String>;
  54. fn main() {
  55. if let Err(error) = run() {
  56. eprintln!(
  57. "error: {error}
  58. Run `claw --help` for usage."
  59. );
  60. std::process::exit(1);
  61. }
  62. }
  63. fn run() -> Result<(), Box<dyn std::error::Error>> {
  64. let args: Vec<String> = env::args().skip(1).collect();
  65. match parse_args(&args)? {
  66. CliAction::DumpManifests => dump_manifests(),
  67. CliAction::BootstrapPlan => print_bootstrap_plan(),
  68. CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
  69. CliAction::Version => print_version(),
  70. CliAction::ResumeSession {
  71. session_path,
  72. commands,
  73. } => resume_session(&session_path, &commands),
  74. CliAction::Prompt {
  75. prompt,
  76. model,
  77. output_format,
  78. allowed_tools,
  79. permission_mode,
  80. } => LiveCli::new(model, true, allowed_tools, permission_mode)?
  81. .run_turn_with_output(&prompt, output_format)?,
  82. CliAction::Login => run_login()?,
  83. CliAction::Logout => run_logout()?,
  84. CliAction::Init => run_init()?,
  85. CliAction::Repl {
  86. model,
  87. allowed_tools,
  88. permission_mode,
  89. } => run_repl(model, allowed_tools, permission_mode)?,
  90. CliAction::Help => print_help(),
  91. }
  92. Ok(())
  93. }
  94. #[derive(Debug, Clone, PartialEq, Eq)]
  95. enum CliAction {
  96. DumpManifests,
  97. BootstrapPlan,
  98. PrintSystemPrompt {
  99. cwd: PathBuf,
  100. date: String,
  101. },
  102. Version,
  103. ResumeSession {
  104. session_path: PathBuf,
  105. commands: Vec<String>,
  106. },
  107. Prompt {
  108. prompt: String,
  109. model: String,
  110. output_format: CliOutputFormat,
  111. allowed_tools: Option<AllowedToolSet>,
  112. permission_mode: PermissionMode,
  113. },
  114. Login,
  115. Logout,
  116. Init,
  117. Repl {
  118. model: String,
  119. allowed_tools: Option<AllowedToolSet>,
  120. permission_mode: PermissionMode,
  121. },
  122. // prompt-mode formatting is only supported for non-interactive runs
  123. Help,
  124. }
  125. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  126. enum CliOutputFormat {
  127. Text,
  128. Json,
  129. }
  130. impl CliOutputFormat {
  131. fn parse(value: &str) -> Result<Self, String> {
  132. match value {
  133. "text" => Ok(Self::Text),
  134. "json" => Ok(Self::Json),
  135. other => Err(format!(
  136. "unsupported value for --output-format: {other} (expected text or json)"
  137. )),
  138. }
  139. }
  140. }
  141. #[allow(clippy::too_many_lines)]
  142. fn parse_args(args: &[String]) -> Result<CliAction, String> {
  143. let mut model = DEFAULT_MODEL.to_string();
  144. let mut output_format = CliOutputFormat::Text;
  145. let mut permission_mode = default_permission_mode();
  146. let mut wants_version = false;
  147. let mut allowed_tool_values = Vec::new();
  148. let mut rest = Vec::new();
  149. let mut index = 0;
  150. while index < args.len() {
  151. match args[index].as_str() {
  152. "--version" | "-V" => {
  153. wants_version = true;
  154. index += 1;
  155. }
  156. "--model" => {
  157. let value = args
  158. .get(index + 1)
  159. .ok_or_else(|| "missing value for --model".to_string())?;
  160. model = resolve_model_alias(value).to_string();
  161. index += 2;
  162. }
  163. flag if flag.starts_with("--model=") => {
  164. model = resolve_model_alias(&flag[8..]).to_string();
  165. index += 1;
  166. }
  167. "--output-format" => {
  168. let value = args
  169. .get(index + 1)
  170. .ok_or_else(|| "missing value for --output-format".to_string())?;
  171. output_format = CliOutputFormat::parse(value)?;
  172. index += 2;
  173. }
  174. "--permission-mode" => {
  175. let value = args
  176. .get(index + 1)
  177. .ok_or_else(|| "missing value for --permission-mode".to_string())?;
  178. permission_mode = parse_permission_mode_arg(value)?;
  179. index += 2;
  180. }
  181. flag if flag.starts_with("--output-format=") => {
  182. output_format = CliOutputFormat::parse(&flag[16..])?;
  183. index += 1;
  184. }
  185. flag if flag.starts_with("--permission-mode=") => {
  186. permission_mode = parse_permission_mode_arg(&flag[18..])?;
  187. index += 1;
  188. }
  189. "--dangerously-skip-permissions" => {
  190. permission_mode = PermissionMode::DangerFullAccess;
  191. index += 1;
  192. }
  193. "-p" => {
  194. // Claw Code compat: -p "prompt" = one-shot prompt
  195. let prompt = args[index + 1..].join(" ");
  196. if prompt.trim().is_empty() {
  197. return Err("-p requires a prompt string".to_string());
  198. }
  199. return Ok(CliAction::Prompt {
  200. prompt,
  201. model: resolve_model_alias(&model).to_string(),
  202. output_format,
  203. allowed_tools: normalize_allowed_tools(&allowed_tool_values)?,
  204. permission_mode,
  205. });
  206. }
  207. "--print" => {
  208. // Claw Code compat: --print makes output non-interactive
  209. output_format = CliOutputFormat::Text;
  210. index += 1;
  211. }
  212. "--allowedTools" | "--allowed-tools" => {
  213. let value = args
  214. .get(index + 1)
  215. .ok_or_else(|| "missing value for --allowedTools".to_string())?;
  216. allowed_tool_values.push(value.clone());
  217. index += 2;
  218. }
  219. flag if flag.starts_with("--allowedTools=") => {
  220. allowed_tool_values.push(flag[15..].to_string());
  221. index += 1;
  222. }
  223. flag if flag.starts_with("--allowed-tools=") => {
  224. allowed_tool_values.push(flag[16..].to_string());
  225. index += 1;
  226. }
  227. other => {
  228. rest.push(other.to_string());
  229. index += 1;
  230. }
  231. }
  232. }
  233. if wants_version {
  234. return Ok(CliAction::Version);
  235. }
  236. let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?;
  237. if rest.is_empty() {
  238. return Ok(CliAction::Repl {
  239. model,
  240. allowed_tools,
  241. permission_mode,
  242. });
  243. }
  244. if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
  245. return Ok(CliAction::Help);
  246. }
  247. if rest.first().map(String::as_str) == Some("--resume") {
  248. return parse_resume_args(&rest[1..]);
  249. }
  250. match rest[0].as_str() {
  251. "dump-manifests" => Ok(CliAction::DumpManifests),
  252. "bootstrap-plan" => Ok(CliAction::BootstrapPlan),
  253. "system-prompt" => parse_system_prompt_args(&rest[1..]),
  254. "login" => Ok(CliAction::Login),
  255. "logout" => Ok(CliAction::Logout),
  256. "init" => Ok(CliAction::Init),
  257. "prompt" => {
  258. let prompt = rest[1..].join(" ");
  259. if prompt.trim().is_empty() {
  260. return Err("prompt subcommand requires a prompt string".to_string());
  261. }
  262. Ok(CliAction::Prompt {
  263. prompt,
  264. model,
  265. output_format,
  266. allowed_tools,
  267. permission_mode,
  268. })
  269. }
  270. other if !other.starts_with('/') => Ok(CliAction::Prompt {
  271. prompt: rest.join(" "),
  272. model,
  273. output_format,
  274. allowed_tools,
  275. permission_mode,
  276. }),
  277. other => Err(format!("unknown subcommand: {other}")),
  278. }
  279. }
  280. fn resolve_model_alias(model: &str) -> &str {
  281. match model {
  282. "opus" => "claude-opus-4-6",
  283. "sonnet" => "claude-sonnet-4-6",
  284. "haiku" => "claude-haiku-4-5-20251213",
  285. _ => model,
  286. }
  287. }
  288. fn normalize_allowed_tools(values: &[String]) -> Result<Option<AllowedToolSet>, String> {
  289. current_tool_registry()?.normalize_allowed_tools(values)
  290. }
  291. fn current_tool_registry() -> Result<GlobalToolRegistry, String> {
  292. let cwd = env::current_dir().map_err(|error| error.to_string())?;
  293. let loader = ConfigLoader::default_for(&cwd);
  294. let runtime_config = loader.load().map_err(|error| error.to_string())?;
  295. let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
  296. let plugin_tools = plugin_manager
  297. .aggregated_tools()
  298. .map_err(|error| error.to_string())?;
  299. GlobalToolRegistry::with_plugin_tools(plugin_tools)
  300. }
  301. fn parse_permission_mode_arg(value: &str) -> Result<PermissionMode, String> {
  302. normalize_permission_mode(value)
  303. .ok_or_else(|| {
  304. format!(
  305. "unsupported permission mode '{value}'. Use read-only, workspace-write, or danger-full-access."
  306. )
  307. })
  308. .map(permission_mode_from_label)
  309. }
  310. fn permission_mode_from_label(mode: &str) -> PermissionMode {
  311. match mode {
  312. "read-only" => PermissionMode::ReadOnly,
  313. "workspace-write" => PermissionMode::WorkspaceWrite,
  314. "danger-full-access" => PermissionMode::DangerFullAccess,
  315. other => panic!("unsupported permission mode label: {other}"),
  316. }
  317. }
  318. fn default_permission_mode() -> PermissionMode {
  319. env::var("RUSTY_CLAUDE_PERMISSION_MODE")
  320. .ok()
  321. .as_deref()
  322. .and_then(normalize_permission_mode)
  323. .map_or(PermissionMode::DangerFullAccess, permission_mode_from_label)
  324. }
  325. fn filter_tool_specs(
  326. tool_registry: &GlobalToolRegistry,
  327. allowed_tools: Option<&AllowedToolSet>,
  328. ) -> Vec<ToolDefinition> {
  329. tool_registry.definitions(allowed_tools)
  330. }
  331. fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
  332. let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
  333. let mut date = DEFAULT_DATE.to_string();
  334. let mut index = 0;
  335. while index < args.len() {
  336. match args[index].as_str() {
  337. "--cwd" => {
  338. let value = args
  339. .get(index + 1)
  340. .ok_or_else(|| "missing value for --cwd".to_string())?;
  341. cwd = PathBuf::from(value);
  342. index += 2;
  343. }
  344. "--date" => {
  345. let value = args
  346. .get(index + 1)
  347. .ok_or_else(|| "missing value for --date".to_string())?;
  348. date.clone_from(value);
  349. index += 2;
  350. }
  351. other => return Err(format!("unknown system-prompt option: {other}")),
  352. }
  353. }
  354. Ok(CliAction::PrintSystemPrompt { cwd, date })
  355. }
  356. fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
  357. let session_path = args
  358. .first()
  359. .ok_or_else(|| "missing session path for --resume".to_string())
  360. .map(PathBuf::from)?;
  361. let commands = args[1..].to_vec();
  362. if commands
  363. .iter()
  364. .any(|command| !command.trim_start().starts_with('/'))
  365. {
  366. return Err("--resume trailing arguments must be slash commands".to_string());
  367. }
  368. Ok(CliAction::ResumeSession {
  369. session_path,
  370. commands,
  371. })
  372. }
  373. fn dump_manifests() {
  374. let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
  375. let paths = UpstreamPaths::from_workspace_dir(&workspace_dir);
  376. match extract_manifest(&paths) {
  377. Ok(manifest) => {
  378. println!("commands: {}", manifest.commands.entries().len());
  379. println!("tools: {}", manifest.tools.entries().len());
  380. println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
  381. }
  382. Err(error) => {
  383. eprintln!("failed to extract manifests: {error}");
  384. std::process::exit(1);
  385. }
  386. }
  387. }
  388. fn print_bootstrap_plan() {
  389. for phase in runtime::BootstrapPlan::claude_code_default().phases() {
  390. println!("- {phase:?}");
  391. }
  392. }
  393. fn default_oauth_config() -> OAuthConfig {
  394. OAuthConfig {
  395. client_id: String::from("9d1c250a-e61b-44d9-88ed-5944d1962f5e"),
  396. authorize_url: String::from("https://platform.claude.com/oauth/authorize"),
  397. token_url: String::from("https://platform.claude.com/v1/oauth/token"),
  398. callback_port: None,
  399. manual_redirect_url: None,
  400. scopes: vec![
  401. String::from("user:profile"),
  402. String::from("user:inference"),
  403. String::from("user:sessions:claude_code"),
  404. ],
  405. }
  406. }
  407. fn run_login() -> Result<(), Box<dyn std::error::Error>> {
  408. let cwd = env::current_dir()?;
  409. let config = ConfigLoader::default_for(&cwd).load()?;
  410. let default_oauth = default_oauth_config();
  411. let oauth = config.oauth().unwrap_or(&default_oauth);
  412. let callback_port = oauth.callback_port.unwrap_or(DEFAULT_OAUTH_CALLBACK_PORT);
  413. let redirect_uri = runtime::loopback_redirect_uri(callback_port);
  414. let pkce = generate_pkce_pair()?;
  415. let state = generate_state()?;
  416. let authorize_url =
  417. OAuthAuthorizationRequest::from_config(oauth, redirect_uri.clone(), state.clone(), &pkce)
  418. .build_url();
  419. println!("Starting Claude OAuth login...");
  420. println!("Listening for callback on {redirect_uri}");
  421. if let Err(error) = open_browser(&authorize_url) {
  422. eprintln!("warning: failed to open browser automatically: {error}");
  423. println!("Open this URL manually:\n{authorize_url}");
  424. }
  425. let callback = wait_for_oauth_callback(callback_port)?;
  426. if let Some(error) = callback.error {
  427. let description = callback
  428. .error_description
  429. .unwrap_or_else(|| "authorization failed".to_string());
  430. return Err(io::Error::other(format!("{error}: {description}")).into());
  431. }
  432. let code = callback.code.ok_or_else(|| {
  433. io::Error::new(io::ErrorKind::InvalidData, "callback did not include code")
  434. })?;
  435. let returned_state = callback.state.ok_or_else(|| {
  436. io::Error::new(io::ErrorKind::InvalidData, "callback did not include state")
  437. })?;
  438. if returned_state != state {
  439. return Err(io::Error::new(io::ErrorKind::InvalidData, "oauth state mismatch").into());
  440. }
  441. let client = AnthropicClient::from_auth(AuthSource::None).with_base_url(api::read_base_url());
  442. let exchange_request =
  443. OAuthTokenExchangeRequest::from_config(oauth, code, state, pkce.verifier, redirect_uri);
  444. let runtime = tokio::runtime::Runtime::new()?;
  445. let token_set = runtime.block_on(client.exchange_oauth_code(oauth, &exchange_request))?;
  446. save_oauth_credentials(&runtime::OAuthTokenSet {
  447. access_token: token_set.access_token,
  448. refresh_token: token_set.refresh_token,
  449. expires_at: token_set.expires_at,
  450. scopes: token_set.scopes,
  451. })?;
  452. println!("Claude OAuth login complete.");
  453. Ok(())
  454. }
  455. fn run_logout() -> Result<(), Box<dyn std::error::Error>> {
  456. clear_oauth_credentials()?;
  457. println!("Claude OAuth credentials cleared.");
  458. Ok(())
  459. }
  460. fn open_browser(url: &str) -> io::Result<()> {
  461. let commands = if cfg!(target_os = "macos") {
  462. vec![("open", vec![url])]
  463. } else if cfg!(target_os = "windows") {
  464. vec![("cmd", vec!["/C", "start", "", url])]
  465. } else {
  466. vec![("xdg-open", vec![url])]
  467. };
  468. for (program, args) in commands {
  469. match Command::new(program).args(args).spawn() {
  470. Ok(_) => return Ok(()),
  471. Err(error) if error.kind() == io::ErrorKind::NotFound => {}
  472. Err(error) => return Err(error),
  473. }
  474. }
  475. Err(io::Error::new(
  476. io::ErrorKind::NotFound,
  477. "no supported browser opener command found",
  478. ))
  479. }
  480. fn wait_for_oauth_callback(
  481. port: u16,
  482. ) -> Result<runtime::OAuthCallbackParams, Box<dyn std::error::Error>> {
  483. let listener = TcpListener::bind(("127.0.0.1", port))?;
  484. let (mut stream, _) = listener.accept()?;
  485. let mut buffer = [0_u8; 4096];
  486. let bytes_read = stream.read(&mut buffer)?;
  487. let request = String::from_utf8_lossy(&buffer[..bytes_read]);
  488. let request_line = request.lines().next().ok_or_else(|| {
  489. io::Error::new(io::ErrorKind::InvalidData, "missing callback request line")
  490. })?;
  491. let target = request_line.split_whitespace().nth(1).ok_or_else(|| {
  492. io::Error::new(
  493. io::ErrorKind::InvalidData,
  494. "missing callback request target",
  495. )
  496. })?;
  497. let callback = parse_oauth_callback_request_target(target)
  498. .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
  499. let body = if callback.error.is_some() {
  500. "Claude OAuth login failed. You can close this window."
  501. } else {
  502. "Claude OAuth login succeeded. You can close this window."
  503. };
  504. let response = format!(
  505. "HTTP/1.1 200 OK\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
  506. body.len(),
  507. body
  508. );
  509. stream.write_all(response.as_bytes())?;
  510. Ok(callback)
  511. }
  512. fn print_system_prompt(cwd: PathBuf, date: String) {
  513. match load_system_prompt(cwd, date, env::consts::OS, "unknown") {
  514. Ok(sections) => println!("{}", sections.join("\n\n")),
  515. Err(error) => {
  516. eprintln!("failed to build system prompt: {error}");
  517. std::process::exit(1);
  518. }
  519. }
  520. }
  521. fn print_version() {
  522. println!("{}", render_version_report());
  523. }
  524. fn resume_session(session_path: &Path, commands: &[String]) {
  525. let session = match Session::load_from_path(session_path) {
  526. Ok(session) => session,
  527. Err(error) => {
  528. eprintln!("failed to restore session: {error}");
  529. std::process::exit(1);
  530. }
  531. };
  532. if commands.is_empty() {
  533. println!(
  534. "Restored session from {} ({} messages).",
  535. session_path.display(),
  536. session.messages.len()
  537. );
  538. return;
  539. }
  540. let mut session = session;
  541. for raw_command in commands {
  542. let Some(command) = SlashCommand::parse(raw_command) else {
  543. eprintln!("unsupported resumed command: {raw_command}");
  544. std::process::exit(2);
  545. };
  546. match run_resume_command(session_path, &session, &command) {
  547. Ok(ResumeCommandOutcome {
  548. session: next_session,
  549. message,
  550. }) => {
  551. session = next_session;
  552. if let Some(message) = message {
  553. println!("{message}");
  554. }
  555. }
  556. Err(error) => {
  557. eprintln!("{error}");
  558. std::process::exit(2);
  559. }
  560. }
  561. }
  562. }
  563. #[derive(Debug, Clone)]
  564. struct ResumeCommandOutcome {
  565. session: Session,
  566. message: Option<String>,
  567. }
  568. #[derive(Debug, Clone)]
  569. struct StatusContext {
  570. cwd: PathBuf,
  571. session_path: Option<PathBuf>,
  572. loaded_config_files: usize,
  573. discovered_config_files: usize,
  574. memory_file_count: usize,
  575. project_root: Option<PathBuf>,
  576. git_branch: Option<String>,
  577. }
  578. #[derive(Debug, Clone, Copy)]
  579. struct StatusUsage {
  580. message_count: usize,
  581. turns: u32,
  582. latest: TokenUsage,
  583. cumulative: TokenUsage,
  584. estimated_tokens: usize,
  585. }
  586. fn format_model_report(model: &str, message_count: usize, turns: u32) -> String {
  587. format!(
  588. "Model
  589. Current model {model}
  590. Session messages {message_count}
  591. Session turns {turns}
  592. Usage
  593. Inspect current model with /model
  594. Switch models with /model <name>"
  595. )
  596. }
  597. fn format_model_switch_report(previous: &str, next: &str, message_count: usize) -> String {
  598. format!(
  599. "Model updated
  600. Previous {previous}
  601. Current {next}
  602. Preserved msgs {message_count}"
  603. )
  604. }
  605. fn format_permissions_report(mode: &str) -> String {
  606. let modes = [
  607. ("read-only", "Read/search tools only", mode == "read-only"),
  608. (
  609. "workspace-write",
  610. "Edit files inside the workspace",
  611. mode == "workspace-write",
  612. ),
  613. (
  614. "danger-full-access",
  615. "Unrestricted tool access",
  616. mode == "danger-full-access",
  617. ),
  618. ]
  619. .into_iter()
  620. .map(|(name, description, is_current)| {
  621. let marker = if is_current {
  622. "● current"
  623. } else {
  624. "○ available"
  625. };
  626. format!(" {name:<18} {marker:<11} {description}")
  627. })
  628. .collect::<Vec<_>>()
  629. .join(
  630. "
  631. ",
  632. );
  633. format!(
  634. "Permissions
  635. Active mode {mode}
  636. Mode status live session default
  637. Modes
  638. {modes}
  639. Usage
  640. Inspect current mode with /permissions
  641. Switch modes with /permissions <mode>"
  642. )
  643. }
  644. fn format_permissions_switch_report(previous: &str, next: &str) -> String {
  645. format!(
  646. "Permissions updated
  647. Result mode switched
  648. Previous mode {previous}
  649. Active mode {next}
  650. Applies to subsequent tool calls
  651. Usage /permissions to inspect current mode"
  652. )
  653. }
  654. fn format_cost_report(usage: TokenUsage) -> String {
  655. format!(
  656. "Cost
  657. Input tokens {}
  658. Output tokens {}
  659. Cache create {}
  660. Cache read {}
  661. Total tokens {}",
  662. usage.input_tokens,
  663. usage.output_tokens,
  664. usage.cache_creation_input_tokens,
  665. usage.cache_read_input_tokens,
  666. usage.total_tokens(),
  667. )
  668. }
  669. fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String {
  670. format!(
  671. "Session resumed
  672. Session file {session_path}
  673. Messages {message_count}
  674. Turns {turns}"
  675. )
  676. }
  677. fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String {
  678. if skipped {
  679. format!(
  680. "Compact
  681. Result skipped
  682. Reason session below compaction threshold
  683. Messages kept {resulting_messages}"
  684. )
  685. } else {
  686. format!(
  687. "Compact
  688. Result compacted
  689. Messages removed {removed}
  690. Messages kept {resulting_messages}"
  691. )
  692. }
  693. }
  694. fn format_auto_compaction_notice(removed: usize) -> String {
  695. format!("[auto-compacted: removed {removed} messages]")
  696. }
  697. fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<String>) {
  698. let Some(status) = status else {
  699. return (None, None);
  700. };
  701. let branch = status.lines().next().and_then(|line| {
  702. line.strip_prefix("## ")
  703. .map(|line| {
  704. line.split(['.', ' '])
  705. .next()
  706. .unwrap_or_default()
  707. .to_string()
  708. })
  709. .filter(|value| !value.is_empty())
  710. });
  711. let project_root = find_git_root().ok();
  712. (project_root, branch)
  713. }
  714. fn find_git_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
  715. let output = std::process::Command::new("git")
  716. .args(["rev-parse", "--show-toplevel"])
  717. .current_dir(env::current_dir()?)
  718. .output()?;
  719. if !output.status.success() {
  720. return Err("not a git repository".into());
  721. }
  722. let path = String::from_utf8(output.stdout)?.trim().to_string();
  723. if path.is_empty() {
  724. return Err("empty git root".into());
  725. }
  726. Ok(PathBuf::from(path))
  727. }
  728. #[allow(clippy::too_many_lines)]
  729. fn run_resume_command(
  730. session_path: &Path,
  731. session: &Session,
  732. command: &SlashCommand,
  733. ) -> Result<ResumeCommandOutcome, Box<dyn std::error::Error>> {
  734. match command {
  735. SlashCommand::Help => Ok(ResumeCommandOutcome {
  736. session: session.clone(),
  737. message: Some(render_repl_help()),
  738. }),
  739. SlashCommand::Compact => {
  740. let result = runtime::compact_session(
  741. session,
  742. CompactionConfig {
  743. max_estimated_tokens: 0,
  744. ..CompactionConfig::default()
  745. },
  746. );
  747. let removed = result.removed_message_count;
  748. let kept = result.compacted_session.messages.len();
  749. let skipped = removed == 0;
  750. result.compacted_session.save_to_path(session_path)?;
  751. Ok(ResumeCommandOutcome {
  752. session: result.compacted_session,
  753. message: Some(format_compact_report(removed, kept, skipped)),
  754. })
  755. }
  756. SlashCommand::Clear { confirm } => {
  757. if !confirm {
  758. return Ok(ResumeCommandOutcome {
  759. session: session.clone(),
  760. message: Some(
  761. "clear: confirmation required; rerun with /clear --confirm".to_string(),
  762. ),
  763. });
  764. }
  765. let cleared = Session::new();
  766. cleared.save_to_path(session_path)?;
  767. Ok(ResumeCommandOutcome {
  768. session: cleared,
  769. message: Some(format!(
  770. "Cleared resumed session file {}.",
  771. session_path.display()
  772. )),
  773. })
  774. }
  775. SlashCommand::Status => {
  776. let tracker = UsageTracker::from_session(session);
  777. let usage = tracker.cumulative_usage();
  778. Ok(ResumeCommandOutcome {
  779. session: session.clone(),
  780. message: Some(format_status_report(
  781. "restored-session",
  782. StatusUsage {
  783. message_count: session.messages.len(),
  784. turns: tracker.turns(),
  785. latest: tracker.current_turn_usage(),
  786. cumulative: usage,
  787. estimated_tokens: 0,
  788. },
  789. default_permission_mode().as_str(),
  790. &status_context(Some(session_path))?,
  791. )),
  792. })
  793. }
  794. SlashCommand::Cost => {
  795. let usage = UsageTracker::from_session(session).cumulative_usage();
  796. Ok(ResumeCommandOutcome {
  797. session: session.clone(),
  798. message: Some(format_cost_report(usage)),
  799. })
  800. }
  801. SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
  802. session: session.clone(),
  803. message: Some(render_config_report(section.as_deref())?),
  804. }),
  805. SlashCommand::Memory => Ok(ResumeCommandOutcome {
  806. session: session.clone(),
  807. message: Some(render_memory_report()?),
  808. }),
  809. SlashCommand::Init => Ok(ResumeCommandOutcome {
  810. session: session.clone(),
  811. message: Some(init_claude_md()?),
  812. }),
  813. SlashCommand::Diff => Ok(ResumeCommandOutcome {
  814. session: session.clone(),
  815. message: Some(render_diff_report()?),
  816. }),
  817. SlashCommand::Version => Ok(ResumeCommandOutcome {
  818. session: session.clone(),
  819. message: Some(render_version_report()),
  820. }),
  821. SlashCommand::Export { path } => {
  822. let export_path = resolve_export_path(path.as_deref(), session)?;
  823. fs::write(&export_path, render_export_text(session))?;
  824. Ok(ResumeCommandOutcome {
  825. session: session.clone(),
  826. message: Some(format!(
  827. "Export\n Result wrote transcript\n File {}\n Messages {}",
  828. export_path.display(),
  829. session.messages.len(),
  830. )),
  831. })
  832. }
  833. SlashCommand::Bughunter { .. }
  834. | SlashCommand::Commit
  835. | SlashCommand::Pr { .. }
  836. | SlashCommand::Issue { .. }
  837. | SlashCommand::Ultraplan { .. }
  838. | SlashCommand::Teleport { .. }
  839. | SlashCommand::DebugToolCall
  840. | SlashCommand::Resume { .. }
  841. | SlashCommand::Model { .. }
  842. | SlashCommand::Permissions { .. }
  843. | SlashCommand::Session { .. }
  844. | SlashCommand::Plugins { .. }
  845. | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
  846. }
  847. }
  848. fn run_repl(
  849. model: String,
  850. allowed_tools: Option<AllowedToolSet>,
  851. permission_mode: PermissionMode,
  852. ) -> Result<(), Box<dyn std::error::Error>> {
  853. let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?;
  854. let mut editor = input::LineEditor::new("> ", slash_command_completion_candidates());
  855. println!("{}", cli.startup_banner());
  856. loop {
  857. match editor.read_line()? {
  858. input::ReadOutcome::Submit(input) => {
  859. let trimmed = input.trim().to_string();
  860. if trimmed.is_empty() {
  861. continue;
  862. }
  863. if matches!(trimmed.as_str(), "/exit" | "/quit") {
  864. cli.persist_session()?;
  865. break;
  866. }
  867. if let Some(command) = SlashCommand::parse(&trimmed) {
  868. if cli.handle_repl_command(command)? {
  869. cli.persist_session()?;
  870. }
  871. continue;
  872. }
  873. editor.push_history(input);
  874. cli.run_turn(&trimmed)?;
  875. }
  876. input::ReadOutcome::Cancel => {}
  877. input::ReadOutcome::Exit => {
  878. cli.persist_session()?;
  879. break;
  880. }
  881. }
  882. }
  883. Ok(())
  884. }
  885. #[derive(Debug, Clone)]
  886. struct SessionHandle {
  887. id: String,
  888. path: PathBuf,
  889. }
  890. #[derive(Debug, Clone)]
  891. struct ManagedSessionSummary {
  892. id: String,
  893. path: PathBuf,
  894. modified_epoch_secs: u64,
  895. message_count: usize,
  896. }
  897. struct LiveCli {
  898. model: String,
  899. allowed_tools: Option<AllowedToolSet>,
  900. permission_mode: PermissionMode,
  901. system_prompt: Vec<String>,
  902. runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
  903. session: SessionHandle,
  904. }
  905. impl LiveCli {
  906. fn new(
  907. model: String,
  908. enable_tools: bool,
  909. allowed_tools: Option<AllowedToolSet>,
  910. permission_mode: PermissionMode,
  911. ) -> Result<Self, Box<dyn std::error::Error>> {
  912. let system_prompt = build_system_prompt()?;
  913. let session = create_managed_session_handle()?;
  914. let runtime = build_runtime(
  915. Session::new(),
  916. model.clone(),
  917. system_prompt.clone(),
  918. enable_tools,
  919. true,
  920. allowed_tools.clone(),
  921. permission_mode,
  922. )?;
  923. let cli = Self {
  924. model,
  925. allowed_tools,
  926. permission_mode,
  927. system_prompt,
  928. runtime,
  929. session,
  930. };
  931. cli.persist_session()?;
  932. Ok(cli)
  933. }
  934. fn startup_banner(&self) -> String {
  935. let cwd = env::current_dir().map_or_else(
  936. |_| "<unknown>".to_string(),
  937. |path| path.display().to_string(),
  938. );
  939. format!(
  940. "\x1b[38;5;196m\
  941. ██████╗██╗ █████╗ ██╗ ██╗\n\
  942. ██╔════╝██║ ██╔══██╗██║ ██║\n\
  943. ██║ ██║ ███████║██║ █╗ ██║\n\
  944. ██║ ██║ ██╔══██║██║███╗██║\n\
  945. ╚██████╗███████╗██║ ██║╚███╔███╔╝\n\
  946. ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\x1b[0m \x1b[38;5;208mCode\x1b[0m 🦞\n\n\
  947. \x1b[2mModel\x1b[0m {}\n\
  948. \x1b[2mPermissions\x1b[0m {}\n\
  949. \x1b[2mDirectory\x1b[0m {}\n\
  950. \x1b[2mSession\x1b[0m {}\n\n\
  951. Type \x1b[1m/help\x1b[0m for commands · \x1b[2mShift+Enter\x1b[0m for newline",
  952. self.model,
  953. self.permission_mode.as_str(),
  954. cwd,
  955. self.session.id,
  956. )
  957. }
  958. fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
  959. let mut spinner = Spinner::new();
  960. let mut stdout = io::stdout();
  961. spinner.tick(
  962. "🦀 Thinking...",
  963. TerminalRenderer::new().color_theme(),
  964. &mut stdout,
  965. )?;
  966. let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
  967. let result = self.runtime.run_turn(input, Some(&mut permission_prompter));
  968. match result {
  969. Ok(summary) => {
  970. spinner.finish(
  971. "✨ Done",
  972. TerminalRenderer::new().color_theme(),
  973. &mut stdout,
  974. )?;
  975. println!();
  976. if let Some(event) = summary.auto_compaction {
  977. println!(
  978. "{}",
  979. format_auto_compaction_notice(event.removed_message_count)
  980. );
  981. }
  982. self.persist_session()?;
  983. Ok(())
  984. }
  985. Err(error) => {
  986. spinner.fail(
  987. "❌ Request failed",
  988. TerminalRenderer::new().color_theme(),
  989. &mut stdout,
  990. )?;
  991. Err(Box::new(error))
  992. }
  993. }
  994. }
  995. fn run_turn_with_output(
  996. &mut self,
  997. input: &str,
  998. output_format: CliOutputFormat,
  999. ) -> Result<(), Box<dyn std::error::Error>> {
  1000. match output_format {
  1001. CliOutputFormat::Text => self.run_turn(input),
  1002. CliOutputFormat::Json => self.run_prompt_json(input),
  1003. }
  1004. }
  1005. fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
  1006. let session = self.runtime.session().clone();
  1007. let mut runtime = build_runtime(
  1008. session,
  1009. self.model.clone(),
  1010. self.system_prompt.clone(),
  1011. true,
  1012. false,
  1013. self.allowed_tools.clone(),
  1014. self.permission_mode,
  1015. )?;
  1016. let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
  1017. let summary = runtime.run_turn(input, Some(&mut permission_prompter))?;
  1018. self.runtime = runtime;
  1019. self.persist_session()?;
  1020. println!(
  1021. "{}",
  1022. json!({
  1023. "message": final_assistant_text(&summary),
  1024. "model": self.model,
  1025. "iterations": summary.iterations,
  1026. "auto_compaction": summary.auto_compaction.map(|event| json!({
  1027. "removed_messages": event.removed_message_count,
  1028. "notice": format_auto_compaction_notice(event.removed_message_count),
  1029. })),
  1030. "tool_uses": collect_tool_uses(&summary),
  1031. "tool_results": collect_tool_results(&summary),
  1032. "usage": {
  1033. "input_tokens": summary.usage.input_tokens,
  1034. "output_tokens": summary.usage.output_tokens,
  1035. "cache_creation_input_tokens": summary.usage.cache_creation_input_tokens,
  1036. "cache_read_input_tokens": summary.usage.cache_read_input_tokens,
  1037. }
  1038. })
  1039. );
  1040. Ok(())
  1041. }
  1042. fn handle_repl_command(
  1043. &mut self,
  1044. command: SlashCommand,
  1045. ) -> Result<bool, Box<dyn std::error::Error>> {
  1046. Ok(match command {
  1047. SlashCommand::Help => {
  1048. println!("{}", render_repl_help());
  1049. false
  1050. }
  1051. SlashCommand::Status => {
  1052. self.print_status();
  1053. false
  1054. }
  1055. SlashCommand::Bughunter { scope } => {
  1056. self.run_bughunter(scope.as_deref())?;
  1057. false
  1058. }
  1059. SlashCommand::Commit => {
  1060. self.run_commit()?;
  1061. true
  1062. }
  1063. SlashCommand::Pr { context } => {
  1064. self.run_pr(context.as_deref())?;
  1065. false
  1066. }
  1067. SlashCommand::Issue { context } => {
  1068. self.run_issue(context.as_deref())?;
  1069. false
  1070. }
  1071. SlashCommand::Ultraplan { task } => {
  1072. self.run_ultraplan(task.as_deref())?;
  1073. false
  1074. }
  1075. SlashCommand::Teleport { target } => {
  1076. self.run_teleport(target.as_deref())?;
  1077. false
  1078. }
  1079. SlashCommand::DebugToolCall => {
  1080. self.run_debug_tool_call()?;
  1081. false
  1082. }
  1083. SlashCommand::Compact => {
  1084. self.compact()?;
  1085. false
  1086. }
  1087. SlashCommand::Model { model } => self.set_model(model)?,
  1088. SlashCommand::Permissions { mode } => self.set_permissions(mode)?,
  1089. SlashCommand::Clear { confirm } => self.clear_session(confirm)?,
  1090. SlashCommand::Cost => {
  1091. self.print_cost();
  1092. false
  1093. }
  1094. SlashCommand::Resume { session_path } => self.resume_session(session_path)?,
  1095. SlashCommand::Config { section } => {
  1096. Self::print_config(section.as_deref())?;
  1097. false
  1098. }
  1099. SlashCommand::Memory => {
  1100. Self::print_memory()?;
  1101. false
  1102. }
  1103. SlashCommand::Init => {
  1104. run_init()?;
  1105. false
  1106. }
  1107. SlashCommand::Diff => {
  1108. Self::print_diff()?;
  1109. false
  1110. }
  1111. SlashCommand::Version => {
  1112. Self::print_version();
  1113. false
  1114. }
  1115. SlashCommand::Export { path } => {
  1116. self.export_session(path.as_deref())?;
  1117. false
  1118. }
  1119. SlashCommand::Session { action, target } => {
  1120. self.handle_session_command(action.as_deref(), target.as_deref())?
  1121. }
  1122. SlashCommand::Plugins { action, target } => {
  1123. self.handle_plugins_command(action.as_deref(), target.as_deref())?
  1124. }
  1125. SlashCommand::Unknown(name) => {
  1126. eprintln!("unknown slash command: /{name}");
  1127. false
  1128. }
  1129. })
  1130. }
  1131. fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
  1132. self.runtime.session().save_to_path(&self.session.path)?;
  1133. Ok(())
  1134. }
  1135. fn print_status(&self) {
  1136. let cumulative = self.runtime.usage().cumulative_usage();
  1137. let latest = self.runtime.usage().current_turn_usage();
  1138. println!(
  1139. "{}",
  1140. format_status_report(
  1141. &self.model,
  1142. StatusUsage {
  1143. message_count: self.runtime.session().messages.len(),
  1144. turns: self.runtime.usage().turns(),
  1145. latest,
  1146. cumulative,
  1147. estimated_tokens: self.runtime.estimated_tokens(),
  1148. },
  1149. self.permission_mode.as_str(),
  1150. &status_context(Some(&self.session.path)).expect("status context should load"),
  1151. )
  1152. );
  1153. }
  1154. fn set_model(&mut self, model: Option<String>) -> Result<bool, Box<dyn std::error::Error>> {
  1155. let Some(model) = model else {
  1156. println!(
  1157. "{}",
  1158. format_model_report(
  1159. &self.model,
  1160. self.runtime.session().messages.len(),
  1161. self.runtime.usage().turns(),
  1162. )
  1163. );
  1164. return Ok(false);
  1165. };
  1166. let model = resolve_model_alias(&model).to_string();
  1167. if model == self.model {
  1168. println!(
  1169. "{}",
  1170. format_model_report(
  1171. &self.model,
  1172. self.runtime.session().messages.len(),
  1173. self.runtime.usage().turns(),
  1174. )
  1175. );
  1176. return Ok(false);
  1177. }
  1178. let previous = self.model.clone();
  1179. let session = self.runtime.session().clone();
  1180. let message_count = session.messages.len();
  1181. self.runtime = build_runtime(
  1182. session,
  1183. model.clone(),
  1184. self.system_prompt.clone(),
  1185. true,
  1186. true,
  1187. self.allowed_tools.clone(),
  1188. self.permission_mode,
  1189. )?;
  1190. self.model.clone_from(&model);
  1191. println!(
  1192. "{}",
  1193. format_model_switch_report(&previous, &model, message_count)
  1194. );
  1195. Ok(true)
  1196. }
  1197. fn set_permissions(
  1198. &mut self,
  1199. mode: Option<String>,
  1200. ) -> Result<bool, Box<dyn std::error::Error>> {
  1201. let Some(mode) = mode else {
  1202. println!(
  1203. "{}",
  1204. format_permissions_report(self.permission_mode.as_str())
  1205. );
  1206. return Ok(false);
  1207. };
  1208. let normalized = normalize_permission_mode(&mode).ok_or_else(|| {
  1209. format!(
  1210. "unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access."
  1211. )
  1212. })?;
  1213. if normalized == self.permission_mode.as_str() {
  1214. println!("{}", format_permissions_report(normalized));
  1215. return Ok(false);
  1216. }
  1217. let previous = self.permission_mode.as_str().to_string();
  1218. let session = self.runtime.session().clone();
  1219. self.permission_mode = permission_mode_from_label(normalized);
  1220. self.runtime = build_runtime(
  1221. session,
  1222. self.model.clone(),
  1223. self.system_prompt.clone(),
  1224. true,
  1225. true,
  1226. self.allowed_tools.clone(),
  1227. self.permission_mode,
  1228. )?;
  1229. println!(
  1230. "{}",
  1231. format_permissions_switch_report(&previous, normalized)
  1232. );
  1233. Ok(true)
  1234. }
  1235. fn clear_session(&mut self, confirm: bool) -> Result<bool, Box<dyn std::error::Error>> {
  1236. if !confirm {
  1237. println!(
  1238. "clear: confirmation required; run /clear --confirm to start a fresh session."
  1239. );
  1240. return Ok(false);
  1241. }
  1242. self.session = create_managed_session_handle()?;
  1243. self.runtime = build_runtime(
  1244. Session::new(),
  1245. self.model.clone(),
  1246. self.system_prompt.clone(),
  1247. true,
  1248. true,
  1249. self.allowed_tools.clone(),
  1250. self.permission_mode,
  1251. )?;
  1252. println!(
  1253. "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}",
  1254. self.model,
  1255. self.permission_mode.as_str(),
  1256. self.session.id,
  1257. );
  1258. Ok(true)
  1259. }
  1260. fn print_cost(&self) {
  1261. let cumulative = self.runtime.usage().cumulative_usage();
  1262. println!("{}", format_cost_report(cumulative));
  1263. }
  1264. fn resume_session(
  1265. &mut self,
  1266. session_path: Option<String>,
  1267. ) -> Result<bool, Box<dyn std::error::Error>> {
  1268. let Some(session_ref) = session_path else {
  1269. println!("Usage: /resume <session-path>");
  1270. return Ok(false);
  1271. };
  1272. let handle = resolve_session_reference(&session_ref)?;
  1273. let session = Session::load_from_path(&handle.path)?;
  1274. let message_count = session.messages.len();
  1275. self.runtime = build_runtime(
  1276. session,
  1277. self.model.clone(),
  1278. self.system_prompt.clone(),
  1279. true,
  1280. true,
  1281. self.allowed_tools.clone(),
  1282. self.permission_mode,
  1283. )?;
  1284. self.session = handle;
  1285. println!(
  1286. "{}",
  1287. format_resume_report(
  1288. &self.session.path.display().to_string(),
  1289. message_count,
  1290. self.runtime.usage().turns(),
  1291. )
  1292. );
  1293. Ok(true)
  1294. }
  1295. fn print_config(section: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
  1296. println!("{}", render_config_report(section)?);
  1297. Ok(())
  1298. }
  1299. fn print_memory() -> Result<(), Box<dyn std::error::Error>> {
  1300. println!("{}", render_memory_report()?);
  1301. Ok(())
  1302. }
  1303. fn print_diff() -> Result<(), Box<dyn std::error::Error>> {
  1304. println!("{}", render_diff_report()?);
  1305. Ok(())
  1306. }
  1307. fn print_version() {
  1308. println!("{}", render_version_report());
  1309. }
  1310. fn export_session(
  1311. &self,
  1312. requested_path: Option<&str>,
  1313. ) -> Result<(), Box<dyn std::error::Error>> {
  1314. let export_path = resolve_export_path(requested_path, self.runtime.session())?;
  1315. fs::write(&export_path, render_export_text(self.runtime.session()))?;
  1316. println!(
  1317. "Export\n Result wrote transcript\n File {}\n Messages {}",
  1318. export_path.display(),
  1319. self.runtime.session().messages.len(),
  1320. );
  1321. Ok(())
  1322. }
  1323. fn handle_session_command(
  1324. &mut self,
  1325. action: Option<&str>,
  1326. target: Option<&str>,
  1327. ) -> Result<bool, Box<dyn std::error::Error>> {
  1328. match action {
  1329. None | Some("list") => {
  1330. println!("{}", render_session_list(&self.session.id)?);
  1331. Ok(false)
  1332. }
  1333. Some("switch") => {
  1334. let Some(target) = target else {
  1335. println!("Usage: /session switch <session-id>");
  1336. return Ok(false);
  1337. };
  1338. let handle = resolve_session_reference(target)?;
  1339. let session = Session::load_from_path(&handle.path)?;
  1340. let message_count = session.messages.len();
  1341. self.runtime = build_runtime(
  1342. session,
  1343. self.model.clone(),
  1344. self.system_prompt.clone(),
  1345. true,
  1346. true,
  1347. self.allowed_tools.clone(),
  1348. self.permission_mode,
  1349. )?;
  1350. self.session = handle;
  1351. println!(
  1352. "Session switched\n Active session {}\n File {}\n Messages {}",
  1353. self.session.id,
  1354. self.session.path.display(),
  1355. message_count,
  1356. );
  1357. Ok(true)
  1358. }
  1359. Some(other) => {
  1360. println!("Unknown /session action '{other}'. Use /session list or /session switch <session-id>.");
  1361. Ok(false)
  1362. }
  1363. }
  1364. }
  1365. fn handle_plugins_command(
  1366. &mut self,
  1367. action: Option<&str>,
  1368. target: Option<&str>,
  1369. ) -> Result<bool, Box<dyn std::error::Error>> {
  1370. let cwd = env::current_dir()?;
  1371. let loader = ConfigLoader::default_for(&cwd);
  1372. let runtime_config = loader.load()?;
  1373. let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config);
  1374. let result = handle_plugins_slash_command(action, target, &mut manager)?;
  1375. println!("{}", result.message);
  1376. if result.reload_runtime {
  1377. self.reload_runtime_features()?;
  1378. }
  1379. Ok(false)
  1380. }
  1381. fn reload_runtime_features(&mut self) -> Result<(), Box<dyn std::error::Error>> {
  1382. self.runtime = build_runtime(
  1383. self.runtime.session().clone(),
  1384. self.model.clone(),
  1385. self.system_prompt.clone(),
  1386. true,
  1387. true,
  1388. self.allowed_tools.clone(),
  1389. self.permission_mode,
  1390. )?;
  1391. self.persist_session()
  1392. }
  1393. fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
  1394. let result = self.runtime.compact(CompactionConfig::default());
  1395. let removed = result.removed_message_count;
  1396. let kept = result.compacted_session.messages.len();
  1397. let skipped = removed == 0;
  1398. self.runtime = build_runtime(
  1399. result.compacted_session,
  1400. self.model.clone(),
  1401. self.system_prompt.clone(),
  1402. true,
  1403. true,
  1404. self.allowed_tools.clone(),
  1405. self.permission_mode,
  1406. )?;
  1407. self.persist_session()?;
  1408. println!("{}", format_compact_report(removed, kept, skipped));
  1409. Ok(())
  1410. }
  1411. fn run_internal_prompt_text(
  1412. &self,
  1413. prompt: &str,
  1414. enable_tools: bool,
  1415. ) -> Result<String, Box<dyn std::error::Error>> {
  1416. let session = self.runtime.session().clone();
  1417. let mut runtime = build_runtime(
  1418. session,
  1419. self.model.clone(),
  1420. self.system_prompt.clone(),
  1421. enable_tools,
  1422. false,
  1423. self.allowed_tools.clone(),
  1424. self.permission_mode,
  1425. )?;
  1426. let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
  1427. let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?;
  1428. Ok(final_assistant_text(&summary).trim().to_string())
  1429. }
  1430. fn run_bughunter(&self, scope: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
  1431. let scope = scope.unwrap_or("the current repository");
  1432. let prompt = format!(
  1433. "You are /bughunter. Inspect {scope} and identify the most likely bugs or correctness issues. Prioritize concrete findings with file paths, severity, and suggested fixes. Use tools if needed."
  1434. );
  1435. println!("{}", self.run_internal_prompt_text(&prompt, true)?);
  1436. Ok(())
  1437. }
  1438. fn run_ultraplan(&self, task: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
  1439. let task = task.unwrap_or("the current repo work");
  1440. let prompt = format!(
  1441. "You are /ultraplan. Produce a deep multi-step execution plan for {task}. Include goals, risks, implementation sequence, verification steps, and rollback considerations. Use tools if needed."
  1442. );
  1443. println!("{}", self.run_internal_prompt_text(&prompt, true)?);
  1444. Ok(())
  1445. }
  1446. #[allow(clippy::unused_self)]
  1447. fn run_teleport(&self, target: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
  1448. let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else {
  1449. println!("Usage: /teleport <symbol-or-path>");
  1450. return Ok(());
  1451. };
  1452. println!("{}", render_teleport_report(target)?);
  1453. Ok(())
  1454. }
  1455. fn run_debug_tool_call(&self) -> Result<(), Box<dyn std::error::Error>> {
  1456. println!("{}", render_last_tool_debug_report(self.runtime.session())?);
  1457. Ok(())
  1458. }
  1459. fn run_commit(&mut self) -> Result<(), Box<dyn std::error::Error>> {
  1460. let status = git_output(&["status", "--short"])?;
  1461. if status.trim().is_empty() {
  1462. println!("Commit\n Result skipped\n Reason no workspace changes");
  1463. return Ok(());
  1464. }
  1465. git_status_ok(&["add", "-A"])?;
  1466. let staged_stat = git_output(&["diff", "--cached", "--stat"])?;
  1467. let prompt = format!(
  1468. "Generate a git commit message in plain text Lore format only. Base it on this staged diff summary:\n\n{}\n\nRecent conversation context:\n{}",
  1469. truncate_for_prompt(&staged_stat, 8_000),
  1470. recent_user_context(self.runtime.session(), 6)
  1471. );
  1472. let message = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?);
  1473. if message.trim().is_empty() {
  1474. return Err("generated commit message was empty".into());
  1475. }
  1476. let path = write_temp_text_file("claw-commit-message.txt", &message)?;
  1477. let output = Command::new("git")
  1478. .args(["commit", "--file"])
  1479. .arg(&path)
  1480. .current_dir(env::current_dir()?)
  1481. .output()?;
  1482. if !output.status.success() {
  1483. let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
  1484. return Err(format!("git commit failed: {stderr}").into());
  1485. }
  1486. println!(
  1487. "Commit\n Result created\n Message file {}\n\n{}",
  1488. path.display(),
  1489. message.trim()
  1490. );
  1491. Ok(())
  1492. }
  1493. fn run_pr(&self, context: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
  1494. let staged = git_output(&["diff", "--stat"])?;
  1495. let prompt = format!(
  1496. "Generate a pull request title and body from this conversation and diff summary. Output plain text in this format exactly:\nTITLE: <title>\nBODY:\n<body markdown>\n\nContext hint: {}\n\nDiff summary:\n{}",
  1497. context.unwrap_or("none"),
  1498. truncate_for_prompt(&staged, 10_000)
  1499. );
  1500. let draft = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?);
  1501. let (title, body) = parse_titled_body(&draft)
  1502. .ok_or_else(|| "failed to parse generated PR title/body".to_string())?;
  1503. if command_exists("gh") {
  1504. let body_path = write_temp_text_file("claw-pr-body.md", &body)?;
  1505. let output = Command::new("gh")
  1506. .args(["pr", "create", "--title", &title, "--body-file"])
  1507. .arg(&body_path)
  1508. .current_dir(env::current_dir()?)
  1509. .output()?;
  1510. if output.status.success() {
  1511. let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
  1512. println!(
  1513. "PR\n Result created\n Title {title}\n URL {}",
  1514. if stdout.is_empty() { "<unknown>" } else { &stdout }
  1515. );
  1516. return Ok(());
  1517. }
  1518. }
  1519. println!("PR draft\n Title {title}\n\n{body}");
  1520. Ok(())
  1521. }
  1522. fn run_issue(&self, context: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
  1523. let prompt = format!(
  1524. "Generate a GitHub issue title and body from this conversation. Output plain text in this format exactly:\nTITLE: <title>\nBODY:\n<body markdown>\n\nContext hint: {}\n\nConversation context:\n{}",
  1525. context.unwrap_or("none"),
  1526. truncate_for_prompt(&recent_user_context(self.runtime.session(), 10), 10_000)
  1527. );
  1528. let draft = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?);
  1529. let (title, body) = parse_titled_body(&draft)
  1530. .ok_or_else(|| "failed to parse generated issue title/body".to_string())?;
  1531. if command_exists("gh") {
  1532. let body_path = write_temp_text_file("claw-issue-body.md", &body)?;
  1533. let output = Command::new("gh")
  1534. .args(["issue", "create", "--title", &title, "--body-file"])
  1535. .arg(&body_path)
  1536. .current_dir(env::current_dir()?)
  1537. .output()?;
  1538. if output.status.success() {
  1539. let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
  1540. println!(
  1541. "Issue\n Result created\n Title {title}\n URL {}",
  1542. if stdout.is_empty() { "<unknown>" } else { &stdout }
  1543. );
  1544. return Ok(());
  1545. }
  1546. }
  1547. println!("Issue draft\n Title {title}\n\n{body}");
  1548. Ok(())
  1549. }
  1550. }
  1551. fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
  1552. let cwd = env::current_dir()?;
  1553. let path = cwd.join(".claude").join("sessions");
  1554. fs::create_dir_all(&path)?;
  1555. Ok(path)
  1556. }
  1557. fn create_managed_session_handle() -> Result<SessionHandle, Box<dyn std::error::Error>> {
  1558. let id = generate_session_id();
  1559. let path = sessions_dir()?.join(format!("{id}.json"));
  1560. Ok(SessionHandle { id, path })
  1561. }
  1562. fn generate_session_id() -> String {
  1563. let millis = SystemTime::now()
  1564. .duration_since(UNIX_EPOCH)
  1565. .map(|duration| duration.as_millis())
  1566. .unwrap_or_default();
  1567. format!("session-{millis}")
  1568. }
  1569. fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> {
  1570. let direct = PathBuf::from(reference);
  1571. let path = if direct.exists() {
  1572. direct
  1573. } else {
  1574. sessions_dir()?.join(format!("{reference}.json"))
  1575. };
  1576. if !path.exists() {
  1577. return Err(format!("session not found: {reference}").into());
  1578. }
  1579. let id = path
  1580. .file_stem()
  1581. .and_then(|value| value.to_str())
  1582. .unwrap_or(reference)
  1583. .to_string();
  1584. Ok(SessionHandle { id, path })
  1585. }
  1586. fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> {
  1587. let mut sessions = Vec::new();
  1588. for entry in fs::read_dir(sessions_dir()?)? {
  1589. let entry = entry?;
  1590. let path = entry.path();
  1591. if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
  1592. continue;
  1593. }
  1594. let metadata = entry.metadata()?;
  1595. let modified_epoch_secs = metadata
  1596. .modified()
  1597. .ok()
  1598. .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
  1599. .map(|duration| duration.as_secs())
  1600. .unwrap_or_default();
  1601. let message_count = Session::load_from_path(&path)
  1602. .map(|session| session.messages.len())
  1603. .unwrap_or_default();
  1604. let id = path
  1605. .file_stem()
  1606. .and_then(|value| value.to_str())
  1607. .unwrap_or("unknown")
  1608. .to_string();
  1609. sessions.push(ManagedSessionSummary {
  1610. id,
  1611. path,
  1612. modified_epoch_secs,
  1613. message_count,
  1614. });
  1615. }
  1616. sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs));
  1617. Ok(sessions)
  1618. }
  1619. fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::error::Error>> {
  1620. let sessions = list_managed_sessions()?;
  1621. let mut lines = vec![
  1622. "Sessions".to_string(),
  1623. format!(" Directory {}", sessions_dir()?.display()),
  1624. ];
  1625. if sessions.is_empty() {
  1626. lines.push(" No managed sessions saved yet.".to_string());
  1627. return Ok(lines.join("\n"));
  1628. }
  1629. for session in sessions {
  1630. let marker = if session.id == active_session_id {
  1631. "● current"
  1632. } else {
  1633. "○ saved"
  1634. };
  1635. lines.push(format!(
  1636. " {id:<20} {marker:<10} msgs={msgs:<4} modified={modified} path={path}",
  1637. id = session.id,
  1638. msgs = session.message_count,
  1639. modified = session.modified_epoch_secs,
  1640. path = session.path.display(),
  1641. ));
  1642. }
  1643. Ok(lines.join("\n"))
  1644. }
  1645. fn render_repl_help() -> String {
  1646. [
  1647. "REPL".to_string(),
  1648. " /exit Quit the REPL".to_string(),
  1649. " /quit Quit the REPL".to_string(),
  1650. " Up/Down Navigate prompt history".to_string(),
  1651. " Tab Complete slash commands".to_string(),
  1652. " Ctrl-C Clear input (or exit on empty prompt)".to_string(),
  1653. " Shift+Enter/Ctrl+J Insert a newline".to_string(),
  1654. String::new(),
  1655. render_slash_command_help(),
  1656. ]
  1657. .join(
  1658. "
  1659. ",
  1660. )
  1661. }
  1662. fn status_context(
  1663. session_path: Option<&Path>,
  1664. ) -> Result<StatusContext, Box<dyn std::error::Error>> {
  1665. let cwd = env::current_dir()?;
  1666. let loader = ConfigLoader::default_for(&cwd);
  1667. let discovered_config_files = loader.discover().len();
  1668. let runtime_config = loader.load()?;
  1669. let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
  1670. let (project_root, git_branch) =
  1671. parse_git_status_metadata(project_context.git_status.as_deref());
  1672. Ok(StatusContext {
  1673. cwd,
  1674. session_path: session_path.map(Path::to_path_buf),
  1675. loaded_config_files: runtime_config.loaded_entries().len(),
  1676. discovered_config_files,
  1677. memory_file_count: project_context.instruction_files.len(),
  1678. project_root,
  1679. git_branch,
  1680. })
  1681. }
  1682. fn format_status_report(
  1683. model: &str,
  1684. usage: StatusUsage,
  1685. permission_mode: &str,
  1686. context: &StatusContext,
  1687. ) -> String {
  1688. [
  1689. format!(
  1690. "Status
  1691. Model {model}
  1692. Permission mode {permission_mode}
  1693. Messages {}
  1694. Turns {}
  1695. Estimated tokens {}",
  1696. usage.message_count, usage.turns, usage.estimated_tokens,
  1697. ),
  1698. format!(
  1699. "Usage
  1700. Latest total {}
  1701. Cumulative input {}
  1702. Cumulative output {}
  1703. Cumulative total {}",
  1704. usage.latest.total_tokens(),
  1705. usage.cumulative.input_tokens,
  1706. usage.cumulative.output_tokens,
  1707. usage.cumulative.total_tokens(),
  1708. ),
  1709. format!(
  1710. "Workspace
  1711. Cwd {}
  1712. Project root {}
  1713. Git branch {}
  1714. Session {}
  1715. Config files loaded {}/{}
  1716. Memory files {}",
  1717. context.cwd.display(),
  1718. context
  1719. .project_root
  1720. .as_ref()
  1721. .map_or_else(|| "unknown".to_string(), |path| path.display().to_string()),
  1722. context.git_branch.as_deref().unwrap_or("unknown"),
  1723. context.session_path.as_ref().map_or_else(
  1724. || "live-repl".to_string(),
  1725. |path| path.display().to_string()
  1726. ),
  1727. context.loaded_config_files,
  1728. context.discovered_config_files,
  1729. context.memory_file_count,
  1730. ),
  1731. ]
  1732. .join(
  1733. "
  1734. ",
  1735. )
  1736. }
  1737. fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::error::Error>> {
  1738. let cwd = env::current_dir()?;
  1739. let loader = ConfigLoader::default_for(&cwd);
  1740. let discovered = loader.discover();
  1741. let runtime_config = loader.load()?;
  1742. let mut lines = vec![
  1743. format!(
  1744. "Config
  1745. Working directory {}
  1746. Loaded files {}
  1747. Merged keys {}",
  1748. cwd.display(),
  1749. runtime_config.loaded_entries().len(),
  1750. runtime_config.merged().len()
  1751. ),
  1752. "Discovered files".to_string(),
  1753. ];
  1754. for entry in discovered {
  1755. let source = match entry.source {
  1756. ConfigSource::User => "user",
  1757. ConfigSource::Project => "project",
  1758. ConfigSource::Local => "local",
  1759. };
  1760. let status = if runtime_config
  1761. .loaded_entries()
  1762. .iter()
  1763. .any(|loaded_entry| loaded_entry.path == entry.path)
  1764. {
  1765. "loaded"
  1766. } else {
  1767. "missing"
  1768. };
  1769. lines.push(format!(
  1770. " {source:<7} {status:<7} {}",
  1771. entry.path.display()
  1772. ));
  1773. }
  1774. if let Some(section) = section {
  1775. lines.push(format!("Merged section: {section}"));
  1776. let value = match section {
  1777. "env" => runtime_config.get("env"),
  1778. "hooks" => runtime_config.get("hooks"),
  1779. "model" => runtime_config.get("model"),
  1780. "plugins" => runtime_config
  1781. .get("plugins")
  1782. .or_else(|| runtime_config.get("enabledPlugins")),
  1783. other => {
  1784. lines.push(format!(
  1785. " Unsupported config section '{other}'. Use env, hooks, model, or plugins."
  1786. ));
  1787. return Ok(lines.join(
  1788. "
  1789. ",
  1790. ));
  1791. }
  1792. };
  1793. lines.push(format!(
  1794. " {}",
  1795. match value {
  1796. Some(value) => value.render(),
  1797. None => "<unset>".to_string(),
  1798. }
  1799. ));
  1800. return Ok(lines.join(
  1801. "
  1802. ",
  1803. ));
  1804. }
  1805. lines.push("Merged JSON".to_string());
  1806. lines.push(format!(" {}", runtime_config.as_json().render()));
  1807. Ok(lines.join(
  1808. "
  1809. ",
  1810. ))
  1811. }
  1812. fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
  1813. let cwd = env::current_dir()?;
  1814. let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
  1815. let mut lines = vec![format!(
  1816. "Memory
  1817. Working directory {}
  1818. Instruction files {}",
  1819. cwd.display(),
  1820. project_context.instruction_files.len()
  1821. )];
  1822. if project_context.instruction_files.is_empty() {
  1823. lines.push("Discovered files".to_string());
  1824. lines.push(
  1825. " No CLAUDE instruction files discovered in the current directory ancestry."
  1826. .to_string(),
  1827. );
  1828. } else {
  1829. lines.push("Discovered files".to_string());
  1830. for (index, file) in project_context.instruction_files.iter().enumerate() {
  1831. let preview = file.content.lines().next().unwrap_or("").trim();
  1832. let preview = if preview.is_empty() {
  1833. "<empty>"
  1834. } else {
  1835. preview
  1836. };
  1837. lines.push(format!(" {}. {}", index + 1, file.path.display(),));
  1838. lines.push(format!(
  1839. " lines={} preview={}",
  1840. file.content.lines().count(),
  1841. preview
  1842. ));
  1843. }
  1844. }
  1845. Ok(lines.join(
  1846. "
  1847. ",
  1848. ))
  1849. }
  1850. fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
  1851. let cwd = env::current_dir()?;
  1852. Ok(initialize_repo(&cwd)?.render())
  1853. }
  1854. fn run_init() -> Result<(), Box<dyn std::error::Error>> {
  1855. println!("{}", init_claude_md()?);
  1856. Ok(())
  1857. }
  1858. fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
  1859. match mode.trim() {
  1860. "read-only" => Some("read-only"),
  1861. "workspace-write" => Some("workspace-write"),
  1862. "danger-full-access" => Some("danger-full-access"),
  1863. _ => None,
  1864. }
  1865. }
  1866. fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
  1867. let output = std::process::Command::new("git")
  1868. .args(["diff", "--", ":(exclude).omx"])
  1869. .current_dir(env::current_dir()?)
  1870. .output()?;
  1871. if !output.status.success() {
  1872. let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
  1873. return Err(format!("git diff failed: {stderr}").into());
  1874. }
  1875. let diff = String::from_utf8(output.stdout)?;
  1876. if diff.trim().is_empty() {
  1877. return Ok(
  1878. "Diff\n Result clean working tree\n Detail no current changes"
  1879. .to_string(),
  1880. );
  1881. }
  1882. Ok(format!("Diff\n\n{}", diff.trim_end()))
  1883. }
  1884. fn render_teleport_report(target: &str) -> Result<String, Box<dyn std::error::Error>> {
  1885. let cwd = env::current_dir()?;
  1886. let file_list = Command::new("rg")
  1887. .args(["--files"])
  1888. .current_dir(&cwd)
  1889. .output()?;
  1890. let file_matches = if file_list.status.success() {
  1891. String::from_utf8(file_list.stdout)?
  1892. .lines()
  1893. .filter(|line| line.contains(target))
  1894. .take(10)
  1895. .map(ToOwned::to_owned)
  1896. .collect::<Vec<_>>()
  1897. } else {
  1898. Vec::new()
  1899. };
  1900. let content_output = Command::new("rg")
  1901. .args(["-n", "-S", "--color", "never", target, "."])
  1902. .current_dir(&cwd)
  1903. .output()?;
  1904. let mut lines = vec![format!("Teleport\n Target {target}")];
  1905. if !file_matches.is_empty() {
  1906. lines.push(String::new());
  1907. lines.push("File matches".to_string());
  1908. lines.extend(file_matches.into_iter().map(|path| format!(" {path}")));
  1909. }
  1910. if content_output.status.success() {
  1911. let matches = String::from_utf8(content_output.stdout)?;
  1912. if !matches.trim().is_empty() {
  1913. lines.push(String::new());
  1914. lines.push("Content matches".to_string());
  1915. lines.push(truncate_for_prompt(&matches, 4_000));
  1916. }
  1917. }
  1918. if lines.len() == 1 {
  1919. lines.push(" Result no matches found".to_string());
  1920. }
  1921. Ok(lines.join("\n"))
  1922. }
  1923. fn render_last_tool_debug_report(session: &Session) -> Result<String, Box<dyn std::error::Error>> {
  1924. let last_tool_use = session
  1925. .messages
  1926. .iter()
  1927. .rev()
  1928. .find_map(|message| {
  1929. message.blocks.iter().rev().find_map(|block| match block {
  1930. ContentBlock::ToolUse { id, name, input } => {
  1931. Some((id.clone(), name.clone(), input.clone()))
  1932. }
  1933. _ => None,
  1934. })
  1935. })
  1936. .ok_or_else(|| "no prior tool call found in session".to_string())?;
  1937. let tool_result = session.messages.iter().rev().find_map(|message| {
  1938. message.blocks.iter().rev().find_map(|block| match block {
  1939. ContentBlock::ToolResult {
  1940. tool_use_id,
  1941. tool_name,
  1942. output,
  1943. is_error,
  1944. } if tool_use_id == &last_tool_use.0 => {
  1945. Some((tool_name.clone(), output.clone(), *is_error))
  1946. }
  1947. _ => None,
  1948. })
  1949. });
  1950. let mut lines = vec![
  1951. "Debug tool call".to_string(),
  1952. format!(" Tool id {}", last_tool_use.0),
  1953. format!(" Tool name {}", last_tool_use.1),
  1954. " Input".to_string(),
  1955. indent_block(&last_tool_use.2, 4),
  1956. ];
  1957. match tool_result {
  1958. Some((tool_name, output, is_error)) => {
  1959. lines.push(" Result".to_string());
  1960. lines.push(format!(" name {tool_name}"));
  1961. lines.push(format!(
  1962. " status {}",
  1963. if is_error { "error" } else { "ok" }
  1964. ));
  1965. lines.push(indent_block(&output, 4));
  1966. }
  1967. None => lines.push(" Result missing tool result".to_string()),
  1968. }
  1969. Ok(lines.join("\n"))
  1970. }
  1971. fn indent_block(value: &str, spaces: usize) -> String {
  1972. let indent = " ".repeat(spaces);
  1973. value
  1974. .lines()
  1975. .map(|line| format!("{indent}{line}"))
  1976. .collect::<Vec<_>>()
  1977. .join("\n")
  1978. }
  1979. fn git_output(args: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
  1980. let output = Command::new("git")
  1981. .args(args)
  1982. .current_dir(env::current_dir()?)
  1983. .output()?;
  1984. if !output.status.success() {
  1985. let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
  1986. return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
  1987. }
  1988. Ok(String::from_utf8(output.stdout)?)
  1989. }
  1990. fn git_status_ok(args: &[&str]) -> Result<(), Box<dyn std::error::Error>> {
  1991. let output = Command::new("git")
  1992. .args(args)
  1993. .current_dir(env::current_dir()?)
  1994. .output()?;
  1995. if !output.status.success() {
  1996. let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
  1997. return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
  1998. }
  1999. Ok(())
  2000. }
  2001. fn command_exists(name: &str) -> bool {
  2002. Command::new("which")
  2003. .arg(name)
  2004. .output()
  2005. .map(|output| output.status.success())
  2006. .unwrap_or(false)
  2007. }
  2008. fn write_temp_text_file(
  2009. filename: &str,
  2010. contents: &str,
  2011. ) -> Result<PathBuf, Box<dyn std::error::Error>> {
  2012. let path = env::temp_dir().join(filename);
  2013. fs::write(&path, contents)?;
  2014. Ok(path)
  2015. }
  2016. fn recent_user_context(session: &Session, limit: usize) -> String {
  2017. let requests = session
  2018. .messages
  2019. .iter()
  2020. .filter(|message| message.role == MessageRole::User)
  2021. .filter_map(|message| {
  2022. message.blocks.iter().find_map(|block| match block {
  2023. ContentBlock::Text { text } => Some(text.trim().to_string()),
  2024. _ => None,
  2025. })
  2026. })
  2027. .rev()
  2028. .take(limit)
  2029. .collect::<Vec<_>>();
  2030. if requests.is_empty() {
  2031. "<no prior user messages>".to_string()
  2032. } else {
  2033. requests
  2034. .into_iter()
  2035. .rev()
  2036. .enumerate()
  2037. .map(|(index, text)| format!("{}. {}", index + 1, text))
  2038. .collect::<Vec<_>>()
  2039. .join("\n")
  2040. }
  2041. }
  2042. fn truncate_for_prompt(value: &str, limit: usize) -> String {
  2043. if value.chars().count() <= limit {
  2044. value.trim().to_string()
  2045. } else {
  2046. let truncated = value.chars().take(limit).collect::<String>();
  2047. format!("{}\n…[truncated]", truncated.trim_end())
  2048. }
  2049. }
  2050. fn sanitize_generated_message(value: &str) -> String {
  2051. value.trim().trim_matches('`').trim().replace("\r\n", "\n")
  2052. }
  2053. fn parse_titled_body(value: &str) -> Option<(String, String)> {
  2054. let normalized = sanitize_generated_message(value);
  2055. let title = normalized
  2056. .lines()
  2057. .find_map(|line| line.strip_prefix("TITLE:").map(str::trim))?;
  2058. let body_start = normalized.find("BODY:")?;
  2059. let body = normalized[body_start + "BODY:".len()..].trim();
  2060. Some((title.to_string(), body.to_string()))
  2061. }
  2062. fn render_version_report() -> String {
  2063. let git_sha = GIT_SHA.unwrap_or("unknown");
  2064. let target = BUILD_TARGET.unwrap_or("unknown");
  2065. format!(
  2066. "Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}"
  2067. )
  2068. }
  2069. fn render_export_text(session: &Session) -> String {
  2070. let mut lines = vec!["# Conversation Export".to_string(), String::new()];
  2071. for (index, message) in session.messages.iter().enumerate() {
  2072. let role = match message.role {
  2073. MessageRole::System => "system",
  2074. MessageRole::User => "user",
  2075. MessageRole::Assistant => "assistant",
  2076. MessageRole::Tool => "tool",
  2077. };
  2078. lines.push(format!("## {}. {role}", index + 1));
  2079. for block in &message.blocks {
  2080. match block {
  2081. ContentBlock::Text { text } => lines.push(text.clone()),
  2082. ContentBlock::ToolUse { id, name, input } => {
  2083. lines.push(format!("[tool_use id={id} name={name}] {input}"));
  2084. }
  2085. ContentBlock::ToolResult {
  2086. tool_use_id,
  2087. tool_name,
  2088. output,
  2089. is_error,
  2090. } => {
  2091. lines.push(format!(
  2092. "[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}"
  2093. ));
  2094. }
  2095. }
  2096. }
  2097. lines.push(String::new());
  2098. }
  2099. lines.join("\n")
  2100. }
  2101. fn default_export_filename(session: &Session) -> String {
  2102. let stem = session
  2103. .messages
  2104. .iter()
  2105. .find_map(|message| match message.role {
  2106. MessageRole::User => message.blocks.iter().find_map(|block| match block {
  2107. ContentBlock::Text { text } => Some(text.as_str()),
  2108. _ => None,
  2109. }),
  2110. _ => None,
  2111. })
  2112. .map_or("conversation", |text| {
  2113. text.lines().next().unwrap_or("conversation")
  2114. })
  2115. .chars()
  2116. .map(|ch| {
  2117. if ch.is_ascii_alphanumeric() {
  2118. ch.to_ascii_lowercase()
  2119. } else {
  2120. '-'
  2121. }
  2122. })
  2123. .collect::<String>()
  2124. .split('-')
  2125. .filter(|part| !part.is_empty())
  2126. .take(8)
  2127. .collect::<Vec<_>>()
  2128. .join("-");
  2129. let fallback = if stem.is_empty() {
  2130. "conversation"
  2131. } else {
  2132. &stem
  2133. };
  2134. format!("{fallback}.txt")
  2135. }
  2136. fn resolve_export_path(
  2137. requested_path: Option<&str>,
  2138. session: &Session,
  2139. ) -> Result<PathBuf, Box<dyn std::error::Error>> {
  2140. let cwd = env::current_dir()?;
  2141. let file_name =
  2142. requested_path.map_or_else(|| default_export_filename(session), ToOwned::to_owned);
  2143. let final_name = if Path::new(&file_name)
  2144. .extension()
  2145. .is_some_and(|ext| ext.eq_ignore_ascii_case("txt"))
  2146. {
  2147. file_name
  2148. } else {
  2149. format!("{file_name}.txt")
  2150. };
  2151. Ok(cwd.join(final_name))
  2152. }
  2153. fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
  2154. Ok(load_system_prompt(
  2155. env::current_dir()?,
  2156. DEFAULT_DATE,
  2157. env::consts::OS,
  2158. "unknown",
  2159. )?)
  2160. }
  2161. fn build_runtime_plugin_state() -> Result<
  2162. (
  2163. runtime::RuntimeFeatureConfig,
  2164. PluginRegistry,
  2165. GlobalToolRegistry,
  2166. ),
  2167. Box<dyn std::error::Error>,
  2168. > {
  2169. let cwd = env::current_dir()?;
  2170. let loader = ConfigLoader::default_for(&cwd);
  2171. let runtime_config = loader.load()?;
  2172. let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
  2173. let plugin_registry = plugin_manager.plugin_registry()?;
  2174. let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_registry.aggregated_tools()?)?;
  2175. Ok((
  2176. runtime_config.feature_config().clone(),
  2177. plugin_registry,
  2178. tool_registry,
  2179. ))
  2180. }
  2181. fn build_plugin_manager(
  2182. cwd: &Path,
  2183. loader: &ConfigLoader,
  2184. runtime_config: &runtime::RuntimeConfig,
  2185. ) -> PluginManager {
  2186. let plugin_settings = runtime_config.plugins();
  2187. let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf());
  2188. plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone();
  2189. plugin_config.external_dirs = plugin_settings
  2190. .external_directories()
  2191. .iter()
  2192. .map(|path| resolve_plugin_path(cwd, loader.config_home(), path))
  2193. .collect();
  2194. plugin_config.install_root = plugin_settings
  2195. .install_root()
  2196. .map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
  2197. plugin_config.registry_path = plugin_settings
  2198. .registry_path()
  2199. .map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
  2200. plugin_config.bundled_root = plugin_settings
  2201. .bundled_root()
  2202. .map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
  2203. PluginManager::new(plugin_config)
  2204. }
  2205. fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf {
  2206. let path = PathBuf::from(value);
  2207. if path.is_absolute() {
  2208. path
  2209. } else if value.starts_with('.') {
  2210. cwd.join(path)
  2211. } else {
  2212. config_home.join(path)
  2213. }
  2214. }
  2215. #[allow(clippy::needless_pass_by_value)]
  2216. fn build_runtime(
  2217. session: Session,
  2218. model: String,
  2219. system_prompt: Vec<String>,
  2220. enable_tools: bool,
  2221. emit_output: bool,
  2222. allowed_tools: Option<AllowedToolSet>,
  2223. permission_mode: PermissionMode,
  2224. ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
  2225. {
  2226. let (feature_config, plugin_registry, tool_registry) = build_runtime_plugin_state()?;
  2227. Ok(ConversationRuntime::new_with_plugins(
  2228. session,
  2229. AnthropicRuntimeClient::new(
  2230. model,
  2231. enable_tools,
  2232. emit_output,
  2233. allowed_tools.clone(),
  2234. tool_registry.clone(),
  2235. )?,
  2236. CliToolExecutor::new(allowed_tools.clone(), emit_output, tool_registry.clone()),
  2237. permission_policy(permission_mode, &tool_registry),
  2238. system_prompt,
  2239. feature_config,
  2240. plugin_registry,
  2241. )?)
  2242. }
  2243. struct CliPermissionPrompter {
  2244. current_mode: PermissionMode,
  2245. }
  2246. impl CliPermissionPrompter {
  2247. fn new(current_mode: PermissionMode) -> Self {
  2248. Self { current_mode }
  2249. }
  2250. }
  2251. impl runtime::PermissionPrompter for CliPermissionPrompter {
  2252. fn decide(
  2253. &mut self,
  2254. request: &runtime::PermissionRequest,
  2255. ) -> runtime::PermissionPromptDecision {
  2256. println!();
  2257. println!("Permission approval required");
  2258. println!(" Tool {}", request.tool_name);
  2259. println!(" Current mode {}", self.current_mode.as_str());
  2260. println!(" Required mode {}", request.required_mode.as_str());
  2261. println!(" Input {}", request.input);
  2262. print!("Approve this tool call? [y/N]: ");
  2263. let _ = io::stdout().flush();
  2264. let mut response = String::new();
  2265. match io::stdin().read_line(&mut response) {
  2266. Ok(_) => {
  2267. let normalized = response.trim().to_ascii_lowercase();
  2268. if matches!(normalized.as_str(), "y" | "yes") {
  2269. runtime::PermissionPromptDecision::Allow
  2270. } else {
  2271. runtime::PermissionPromptDecision::Deny {
  2272. reason: format!(
  2273. "tool '{}' denied by user approval prompt",
  2274. request.tool_name
  2275. ),
  2276. }
  2277. }
  2278. }
  2279. Err(error) => runtime::PermissionPromptDecision::Deny {
  2280. reason: format!("permission approval failed: {error}"),
  2281. },
  2282. }
  2283. }
  2284. }
  2285. struct AnthropicRuntimeClient {
  2286. runtime: tokio::runtime::Runtime,
  2287. client: AnthropicClient,
  2288. model: String,
  2289. enable_tools: bool,
  2290. emit_output: bool,
  2291. allowed_tools: Option<AllowedToolSet>,
  2292. tool_registry: GlobalToolRegistry,
  2293. }
  2294. impl AnthropicRuntimeClient {
  2295. fn new(
  2296. model: String,
  2297. enable_tools: bool,
  2298. emit_output: bool,
  2299. allowed_tools: Option<AllowedToolSet>,
  2300. tool_registry: GlobalToolRegistry,
  2301. ) -> Result<Self, Box<dyn std::error::Error>> {
  2302. Ok(Self {
  2303. runtime: tokio::runtime::Runtime::new()?,
  2304. client: AnthropicClient::from_auth(resolve_cli_auth_source()?)
  2305. .with_base_url(api::read_base_url()),
  2306. model,
  2307. enable_tools,
  2308. emit_output,
  2309. allowed_tools,
  2310. tool_registry,
  2311. })
  2312. }
  2313. }
  2314. fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
  2315. Ok(resolve_startup_auth_source(|| {
  2316. let cwd = env::current_dir().map_err(api::ApiError::from)?;
  2317. let config = ConfigLoader::default_for(&cwd).load().map_err(|error| {
  2318. api::ApiError::Auth(format!("failed to load runtime OAuth config: {error}"))
  2319. })?;
  2320. Ok(config.oauth().cloned())
  2321. })?)
  2322. }
  2323. impl ApiClient for AnthropicRuntimeClient {
  2324. #[allow(clippy::too_many_lines)]
  2325. fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
  2326. let message_request = MessageRequest {
  2327. model: self.model.clone(),
  2328. max_tokens: max_tokens_for_model(&self.model),
  2329. messages: convert_messages(&request.messages),
  2330. system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
  2331. tools: self
  2332. .enable_tools
  2333. .then(|| filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref())),
  2334. tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
  2335. stream: true,
  2336. };
  2337. self.runtime.block_on(async {
  2338. let mut stream = self
  2339. .client
  2340. .stream_message(&message_request)
  2341. .await
  2342. .map_err(|error| RuntimeError::new(error.to_string()))?;
  2343. let mut stdout = io::stdout();
  2344. let mut sink = io::sink();
  2345. let out: &mut dyn Write = if self.emit_output {
  2346. &mut stdout
  2347. } else {
  2348. &mut sink
  2349. };
  2350. let renderer = TerminalRenderer::new();
  2351. let mut markdown_stream = MarkdownStreamState::default();
  2352. let mut events = Vec::new();
  2353. let mut pending_tool: Option<(String, String, String)> = None;
  2354. let mut saw_stop = false;
  2355. while let Some(event) = stream
  2356. .next_event()
  2357. .await
  2358. .map_err(|error| RuntimeError::new(error.to_string()))?
  2359. {
  2360. match event {
  2361. ApiStreamEvent::MessageStart(start) => {
  2362. for block in start.message.content {
  2363. push_output_block(block, out, &mut events, &mut pending_tool, true)?;
  2364. }
  2365. }
  2366. ApiStreamEvent::ContentBlockStart(start) => {
  2367. push_output_block(
  2368. start.content_block,
  2369. out,
  2370. &mut events,
  2371. &mut pending_tool,
  2372. true,
  2373. )?;
  2374. }
  2375. ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
  2376. ContentBlockDelta::TextDelta { text } => {
  2377. if !text.is_empty() {
  2378. if let Some(rendered) = markdown_stream.push(&renderer, &text) {
  2379. write!(out, "{rendered}")
  2380. .and_then(|()| out.flush())
  2381. .map_err(|error| RuntimeError::new(error.to_string()))?;
  2382. }
  2383. events.push(AssistantEvent::TextDelta(text));
  2384. }
  2385. }
  2386. ContentBlockDelta::InputJsonDelta { partial_json } => {
  2387. if let Some((_, _, input)) = &mut pending_tool {
  2388. input.push_str(&partial_json);
  2389. }
  2390. }
  2391. ContentBlockDelta::ThinkingDelta { .. }
  2392. | ContentBlockDelta::SignatureDelta { .. } => {}
  2393. },
  2394. ApiStreamEvent::ContentBlockStop(_) => {
  2395. if let Some(rendered) = markdown_stream.flush(&renderer) {
  2396. write!(out, "{rendered}")
  2397. .and_then(|()| out.flush())
  2398. .map_err(|error| RuntimeError::new(error.to_string()))?;
  2399. }
  2400. if let Some((id, name, input)) = pending_tool.take() {
  2401. // Display tool call now that input is fully accumulated
  2402. writeln!(out, "\n{}", format_tool_call_start(&name, &input))
  2403. .and_then(|()| out.flush())
  2404. .map_err(|error| RuntimeError::new(error.to_string()))?;
  2405. events.push(AssistantEvent::ToolUse { id, name, input });
  2406. }
  2407. }
  2408. ApiStreamEvent::MessageDelta(delta) => {
  2409. events.push(AssistantEvent::Usage(TokenUsage {
  2410. input_tokens: delta.usage.input_tokens,
  2411. output_tokens: delta.usage.output_tokens,
  2412. cache_creation_input_tokens: 0,
  2413. cache_read_input_tokens: 0,
  2414. }));
  2415. }
  2416. ApiStreamEvent::MessageStop(_) => {
  2417. saw_stop = true;
  2418. if let Some(rendered) = markdown_stream.flush(&renderer) {
  2419. write!(out, "{rendered}")
  2420. .and_then(|()| out.flush())
  2421. .map_err(|error| RuntimeError::new(error.to_string()))?;
  2422. }
  2423. events.push(AssistantEvent::MessageStop);
  2424. }
  2425. }
  2426. }
  2427. if !saw_stop
  2428. && events.iter().any(|event| {
  2429. matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
  2430. || matches!(event, AssistantEvent::ToolUse { .. })
  2431. })
  2432. {
  2433. events.push(AssistantEvent::MessageStop);
  2434. }
  2435. if events
  2436. .iter()
  2437. .any(|event| matches!(event, AssistantEvent::MessageStop))
  2438. {
  2439. return Ok(events);
  2440. }
  2441. let response = self
  2442. .client
  2443. .send_message(&MessageRequest {
  2444. stream: false,
  2445. ..message_request.clone()
  2446. })
  2447. .await
  2448. .map_err(|error| RuntimeError::new(error.to_string()))?;
  2449. response_to_events(response, out)
  2450. })
  2451. }
  2452. }
  2453. fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
  2454. summary
  2455. .assistant_messages
  2456. .last()
  2457. .map(|message| {
  2458. message
  2459. .blocks
  2460. .iter()
  2461. .filter_map(|block| match block {
  2462. ContentBlock::Text { text } => Some(text.as_str()),
  2463. _ => None,
  2464. })
  2465. .collect::<Vec<_>>()
  2466. .join("")
  2467. })
  2468. .unwrap_or_default()
  2469. }
  2470. fn collect_tool_uses(summary: &runtime::TurnSummary) -> Vec<serde_json::Value> {
  2471. summary
  2472. .assistant_messages
  2473. .iter()
  2474. .flat_map(|message| message.blocks.iter())
  2475. .filter_map(|block| match block {
  2476. ContentBlock::ToolUse { id, name, input } => Some(json!({
  2477. "id": id,
  2478. "name": name,
  2479. "input": input,
  2480. })),
  2481. _ => None,
  2482. })
  2483. .collect()
  2484. }
  2485. fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec<serde_json::Value> {
  2486. summary
  2487. .tool_results
  2488. .iter()
  2489. .flat_map(|message| message.blocks.iter())
  2490. .filter_map(|block| match block {
  2491. ContentBlock::ToolResult {
  2492. tool_use_id,
  2493. tool_name,
  2494. output,
  2495. is_error,
  2496. } => Some(json!({
  2497. "tool_use_id": tool_use_id,
  2498. "tool_name": tool_name,
  2499. "output": output,
  2500. "is_error": is_error,
  2501. })),
  2502. _ => None,
  2503. })
  2504. .collect()
  2505. }
  2506. fn slash_command_completion_candidates() -> Vec<String> {
  2507. slash_command_specs()
  2508. .iter()
  2509. .map(|spec| format!("/{}", spec.name))
  2510. .collect()
  2511. }
  2512. fn format_tool_call_start(name: &str, input: &str) -> String {
  2513. let parsed: serde_json::Value =
  2514. serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string()));
  2515. let detail = match name {
  2516. "bash" | "Bash" => format_bash_call(&parsed),
  2517. "read_file" | "Read" => {
  2518. let path = extract_tool_path(&parsed);
  2519. format!("\x1b[2m📄 Reading {path}…\x1b[0m")
  2520. }
  2521. "write_file" | "Write" => {
  2522. let path = extract_tool_path(&parsed);
  2523. let lines = parsed
  2524. .get("content")
  2525. .and_then(|value| value.as_str())
  2526. .map_or(0, |content| content.lines().count());
  2527. format!("\x1b[1;32m✏️ Writing {path}\x1b[0m \x1b[2m({lines} lines)\x1b[0m")
  2528. }
  2529. "edit_file" | "Edit" => {
  2530. let path = extract_tool_path(&parsed);
  2531. let old_value = parsed
  2532. .get("old_string")
  2533. .or_else(|| parsed.get("oldString"))
  2534. .and_then(|value| value.as_str())
  2535. .unwrap_or_default();
  2536. let new_value = parsed
  2537. .get("new_string")
  2538. .or_else(|| parsed.get("newString"))
  2539. .and_then(|value| value.as_str())
  2540. .unwrap_or_default();
  2541. format!(
  2542. "\x1b[1;33m📝 Editing {path}\x1b[0m{}",
  2543. format_patch_preview(old_value, new_value)
  2544. .map(|preview| format!("\n{preview}"))
  2545. .unwrap_or_default()
  2546. )
  2547. }
  2548. "glob_search" | "Glob" => format_search_start("🔎 Glob", &parsed),
  2549. "grep_search" | "Grep" => format_search_start("🔎 Grep", &parsed),
  2550. "web_search" | "WebSearch" => parsed
  2551. .get("query")
  2552. .and_then(|value| value.as_str())
  2553. .unwrap_or("?")
  2554. .to_string(),
  2555. _ => summarize_tool_payload(input),
  2556. };
  2557. let border = "─".repeat(name.len() + 8);
  2558. format!(
  2559. "\x1b[38;5;245m╭─ \x1b[1;36m{name}\x1b[0;38;5;245m ─╮\x1b[0m\n\x1b[38;5;245m│\x1b[0m {detail}\n\x1b[38;5;245m╰{border}╯\x1b[0m"
  2560. )
  2561. }
  2562. fn format_tool_result(name: &str, output: &str, is_error: bool) -> String {
  2563. let icon = if is_error {
  2564. "\x1b[1;31m✗\x1b[0m"
  2565. } else {
  2566. "\x1b[1;32m✓\x1b[0m"
  2567. };
  2568. if is_error {
  2569. let summary = truncate_for_summary(output.trim(), 160);
  2570. return if summary.is_empty() {
  2571. format!("{icon} \x1b[38;5;245m{name}\x1b[0m")
  2572. } else {
  2573. format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n\x1b[38;5;203m{summary}\x1b[0m")
  2574. };
  2575. }
  2576. let parsed: serde_json::Value =
  2577. serde_json::from_str(output).unwrap_or(serde_json::Value::String(output.to_string()));
  2578. match name {
  2579. "bash" | "Bash" => format_bash_result(icon, &parsed),
  2580. "read_file" | "Read" => format_read_result(icon, &parsed),
  2581. "write_file" | "Write" => format_write_result(icon, &parsed),
  2582. "edit_file" | "Edit" => format_edit_result(icon, &parsed),
  2583. "glob_search" | "Glob" => format_glob_result(icon, &parsed),
  2584. "grep_search" | "Grep" => format_grep_result(icon, &parsed),
  2585. _ => format_generic_tool_result(icon, name, &parsed),
  2586. }
  2587. }
  2588. const DISPLAY_TRUNCATION_NOTICE: &str =
  2589. "\x1b[2m… output truncated for display; full result preserved in session.\x1b[0m";
  2590. const READ_DISPLAY_MAX_LINES: usize = 80;
  2591. const READ_DISPLAY_MAX_CHARS: usize = 6_000;
  2592. const TOOL_OUTPUT_DISPLAY_MAX_LINES: usize = 60;
  2593. const TOOL_OUTPUT_DISPLAY_MAX_CHARS: usize = 4_000;
  2594. fn extract_tool_path(parsed: &serde_json::Value) -> String {
  2595. parsed
  2596. .get("file_path")
  2597. .or_else(|| parsed.get("filePath"))
  2598. .or_else(|| parsed.get("path"))
  2599. .and_then(|value| value.as_str())
  2600. .unwrap_or("?")
  2601. .to_string()
  2602. }
  2603. fn format_search_start(label: &str, parsed: &serde_json::Value) -> String {
  2604. let pattern = parsed
  2605. .get("pattern")
  2606. .and_then(|value| value.as_str())
  2607. .unwrap_or("?");
  2608. let scope = parsed
  2609. .get("path")
  2610. .and_then(|value| value.as_str())
  2611. .unwrap_or(".");
  2612. format!("{label} {pattern}\n\x1b[2min {scope}\x1b[0m")
  2613. }
  2614. fn format_patch_preview(old_value: &str, new_value: &str) -> Option<String> {
  2615. if old_value.is_empty() && new_value.is_empty() {
  2616. return None;
  2617. }
  2618. Some(format!(
  2619. "\x1b[38;5;203m- {}\x1b[0m\n\x1b[38;5;70m+ {}\x1b[0m",
  2620. truncate_for_summary(first_visible_line(old_value), 72),
  2621. truncate_for_summary(first_visible_line(new_value), 72)
  2622. ))
  2623. }
  2624. fn format_bash_call(parsed: &serde_json::Value) -> String {
  2625. let command = parsed
  2626. .get("command")
  2627. .and_then(|value| value.as_str())
  2628. .unwrap_or_default();
  2629. if command.is_empty() {
  2630. String::new()
  2631. } else {
  2632. format!(
  2633. "\x1b[48;5;236;38;5;255m $ {} \x1b[0m",
  2634. truncate_for_summary(command, 160)
  2635. )
  2636. }
  2637. }
  2638. fn first_visible_line(text: &str) -> &str {
  2639. text.lines()
  2640. .find(|line| !line.trim().is_empty())
  2641. .unwrap_or(text)
  2642. }
  2643. fn format_bash_result(icon: &str, parsed: &serde_json::Value) -> String {
  2644. let mut lines = vec![format!("{icon} \x1b[38;5;245mbash\x1b[0m")];
  2645. if let Some(task_id) = parsed
  2646. .get("backgroundTaskId")
  2647. .and_then(|value| value.as_str())
  2648. {
  2649. write!(&mut lines[0], " backgrounded ({task_id})").expect("write to string");
  2650. } else if let Some(status) = parsed
  2651. .get("returnCodeInterpretation")
  2652. .and_then(|value| value.as_str())
  2653. .filter(|status| !status.is_empty())
  2654. {
  2655. write!(&mut lines[0], " {status}").expect("write to string");
  2656. }
  2657. if let Some(stdout) = parsed.get("stdout").and_then(|value| value.as_str()) {
  2658. if !stdout.trim().is_empty() {
  2659. lines.push(truncate_output_for_display(
  2660. stdout,
  2661. TOOL_OUTPUT_DISPLAY_MAX_LINES,
  2662. TOOL_OUTPUT_DISPLAY_MAX_CHARS,
  2663. ));
  2664. }
  2665. }
  2666. if let Some(stderr) = parsed.get("stderr").and_then(|value| value.as_str()) {
  2667. if !stderr.trim().is_empty() {
  2668. lines.push(format!(
  2669. "\x1b[38;5;203m{}\x1b[0m",
  2670. truncate_output_for_display(
  2671. stderr,
  2672. TOOL_OUTPUT_DISPLAY_MAX_LINES,
  2673. TOOL_OUTPUT_DISPLAY_MAX_CHARS,
  2674. )
  2675. ));
  2676. }
  2677. }
  2678. lines.join("\n\n")
  2679. }
  2680. fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String {
  2681. let file = parsed.get("file").unwrap_or(parsed);
  2682. let path = extract_tool_path(file);
  2683. let start_line = file
  2684. .get("startLine")
  2685. .and_then(serde_json::Value::as_u64)
  2686. .unwrap_or(1);
  2687. let num_lines = file
  2688. .get("numLines")
  2689. .and_then(serde_json::Value::as_u64)
  2690. .unwrap_or(0);
  2691. let total_lines = file
  2692. .get("totalLines")
  2693. .and_then(serde_json::Value::as_u64)
  2694. .unwrap_or(num_lines);
  2695. let content = file
  2696. .get("content")
  2697. .and_then(|value| value.as_str())
  2698. .unwrap_or_default();
  2699. let end_line = start_line.saturating_add(num_lines.saturating_sub(1));
  2700. format!(
  2701. "{icon} \x1b[2m📄 Read {path} (lines {}-{} of {})\x1b[0m\n{}",
  2702. start_line,
  2703. end_line.max(start_line),
  2704. total_lines,
  2705. truncate_output_for_display(content, READ_DISPLAY_MAX_LINES, READ_DISPLAY_MAX_CHARS)
  2706. )
  2707. }
  2708. fn format_write_result(icon: &str, parsed: &serde_json::Value) -> String {
  2709. let path = extract_tool_path(parsed);
  2710. let kind = parsed
  2711. .get("type")
  2712. .and_then(|value| value.as_str())
  2713. .unwrap_or("write");
  2714. let line_count = parsed
  2715. .get("content")
  2716. .and_then(|value| value.as_str())
  2717. .map_or(0, |content| content.lines().count());
  2718. format!(
  2719. "{icon} \x1b[1;32m✏️ {} {path}\x1b[0m \x1b[2m({line_count} lines)\x1b[0m",
  2720. if kind == "create" { "Wrote" } else { "Updated" },
  2721. )
  2722. }
  2723. fn format_structured_patch_preview(parsed: &serde_json::Value) -> Option<String> {
  2724. let hunks = parsed.get("structuredPatch")?.as_array()?;
  2725. let mut preview = Vec::new();
  2726. for hunk in hunks.iter().take(2) {
  2727. let lines = hunk.get("lines")?.as_array()?;
  2728. for line in lines.iter().filter_map(|value| value.as_str()).take(6) {
  2729. match line.chars().next() {
  2730. Some('+') => preview.push(format!("\x1b[38;5;70m{line}\x1b[0m")),
  2731. Some('-') => preview.push(format!("\x1b[38;5;203m{line}\x1b[0m")),
  2732. _ => preview.push(line.to_string()),
  2733. }
  2734. }
  2735. }
  2736. if preview.is_empty() {
  2737. None
  2738. } else {
  2739. Some(preview.join("\n"))
  2740. }
  2741. }
  2742. fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String {
  2743. let path = extract_tool_path(parsed);
  2744. let suffix = if parsed
  2745. .get("replaceAll")
  2746. .and_then(serde_json::Value::as_bool)
  2747. .unwrap_or(false)
  2748. {
  2749. " (replace all)"
  2750. } else {
  2751. ""
  2752. };
  2753. let preview = format_structured_patch_preview(parsed).or_else(|| {
  2754. let old_value = parsed
  2755. .get("oldString")
  2756. .and_then(|value| value.as_str())
  2757. .unwrap_or_default();
  2758. let new_value = parsed
  2759. .get("newString")
  2760. .and_then(|value| value.as_str())
  2761. .unwrap_or_default();
  2762. format_patch_preview(old_value, new_value)
  2763. });
  2764. match preview {
  2765. Some(preview) => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m\n{preview}"),
  2766. None => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m"),
  2767. }
  2768. }
  2769. fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String {
  2770. let num_files = parsed
  2771. .get("numFiles")
  2772. .and_then(serde_json::Value::as_u64)
  2773. .unwrap_or(0);
  2774. let filenames = parsed
  2775. .get("filenames")
  2776. .and_then(|value| value.as_array())
  2777. .map(|files| {
  2778. files
  2779. .iter()
  2780. .filter_map(|value| value.as_str())
  2781. .take(8)
  2782. .collect::<Vec<_>>()
  2783. .join("\n")
  2784. })
  2785. .unwrap_or_default();
  2786. if filenames.is_empty() {
  2787. format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files")
  2788. } else {
  2789. format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files\n{filenames}")
  2790. }
  2791. }
  2792. fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String {
  2793. let num_matches = parsed
  2794. .get("numMatches")
  2795. .and_then(serde_json::Value::as_u64)
  2796. .unwrap_or(0);
  2797. let num_files = parsed
  2798. .get("numFiles")
  2799. .and_then(serde_json::Value::as_u64)
  2800. .unwrap_or(0);
  2801. let content = parsed
  2802. .get("content")
  2803. .and_then(|value| value.as_str())
  2804. .unwrap_or_default();
  2805. let filenames = parsed
  2806. .get("filenames")
  2807. .and_then(|value| value.as_array())
  2808. .map(|files| {
  2809. files
  2810. .iter()
  2811. .filter_map(|value| value.as_str())
  2812. .take(8)
  2813. .collect::<Vec<_>>()
  2814. .join("\n")
  2815. })
  2816. .unwrap_or_default();
  2817. let summary = format!(
  2818. "{icon} \x1b[38;5;245mgrep_search\x1b[0m {num_matches} matches across {num_files} files"
  2819. );
  2820. if !content.trim().is_empty() {
  2821. format!(
  2822. "{summary}\n{}",
  2823. truncate_output_for_display(
  2824. content,
  2825. TOOL_OUTPUT_DISPLAY_MAX_LINES,
  2826. TOOL_OUTPUT_DISPLAY_MAX_CHARS,
  2827. )
  2828. )
  2829. } else if !filenames.is_empty() {
  2830. format!("{summary}\n{filenames}")
  2831. } else {
  2832. summary
  2833. }
  2834. }
  2835. fn format_generic_tool_result(icon: &str, name: &str, parsed: &serde_json::Value) -> String {
  2836. let rendered_output = match parsed {
  2837. serde_json::Value::String(text) => text.clone(),
  2838. serde_json::Value::Null => String::new(),
  2839. serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
  2840. serde_json::to_string_pretty(parsed).unwrap_or_else(|_| parsed.to_string())
  2841. }
  2842. _ => parsed.to_string(),
  2843. };
  2844. let preview = truncate_output_for_display(
  2845. &rendered_output,
  2846. TOOL_OUTPUT_DISPLAY_MAX_LINES,
  2847. TOOL_OUTPUT_DISPLAY_MAX_CHARS,
  2848. );
  2849. if preview.is_empty() {
  2850. format!("{icon} \x1b[38;5;245m{name}\x1b[0m")
  2851. } else if preview.contains('\n') {
  2852. format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n{preview}")
  2853. } else {
  2854. format!("{icon} \x1b[38;5;245m{name}:\x1b[0m {preview}")
  2855. }
  2856. }
  2857. fn summarize_tool_payload(payload: &str) -> String {
  2858. let compact = match serde_json::from_str::<serde_json::Value>(payload) {
  2859. Ok(value) => value.to_string(),
  2860. Err(_) => payload.trim().to_string(),
  2861. };
  2862. truncate_for_summary(&compact, 96)
  2863. }
  2864. fn truncate_for_summary(value: &str, limit: usize) -> String {
  2865. let mut chars = value.chars();
  2866. let truncated = chars.by_ref().take(limit).collect::<String>();
  2867. if chars.next().is_some() {
  2868. format!("{truncated}…")
  2869. } else {
  2870. truncated
  2871. }
  2872. }
  2873. fn truncate_output_for_display(content: &str, max_lines: usize, max_chars: usize) -> String {
  2874. let original = content.trim_end_matches('\n');
  2875. if original.is_empty() {
  2876. return String::new();
  2877. }
  2878. let mut preview_lines = Vec::new();
  2879. let mut used_chars = 0usize;
  2880. let mut truncated = false;
  2881. for (index, line) in original.lines().enumerate() {
  2882. if index >= max_lines {
  2883. truncated = true;
  2884. break;
  2885. }
  2886. let newline_cost = usize::from(!preview_lines.is_empty());
  2887. let available = max_chars.saturating_sub(used_chars + newline_cost);
  2888. if available == 0 {
  2889. truncated = true;
  2890. break;
  2891. }
  2892. let line_chars = line.chars().count();
  2893. if line_chars > available {
  2894. preview_lines.push(line.chars().take(available).collect::<String>());
  2895. truncated = true;
  2896. break;
  2897. }
  2898. preview_lines.push(line.to_string());
  2899. used_chars += newline_cost + line_chars;
  2900. }
  2901. let mut preview = preview_lines.join("\n");
  2902. if truncated {
  2903. if !preview.is_empty() {
  2904. preview.push('\n');
  2905. }
  2906. preview.push_str(DISPLAY_TRUNCATION_NOTICE);
  2907. }
  2908. preview
  2909. }
  2910. fn push_output_block(
  2911. block: OutputContentBlock,
  2912. out: &mut (impl Write + ?Sized),
  2913. events: &mut Vec<AssistantEvent>,
  2914. pending_tool: &mut Option<(String, String, String)>,
  2915. streaming_tool_input: bool,
  2916. ) -> Result<(), RuntimeError> {
  2917. match block {
  2918. OutputContentBlock::Text { text } => {
  2919. if !text.is_empty() {
  2920. let rendered = TerminalRenderer::new().markdown_to_ansi(&text);
  2921. write!(out, "{rendered}")
  2922. .and_then(|()| out.flush())
  2923. .map_err(|error| RuntimeError::new(error.to_string()))?;
  2924. events.push(AssistantEvent::TextDelta(text));
  2925. }
  2926. }
  2927. OutputContentBlock::ToolUse { id, name, input } => {
  2928. // During streaming, the initial content_block_start has an empty input ({}).
  2929. // The real input arrives via input_json_delta events. In
  2930. // non-streaming responses, preserve a legitimate empty object.
  2931. let initial_input = if streaming_tool_input
  2932. && input.is_object()
  2933. && input.as_object().is_some_and(serde_json::Map::is_empty)
  2934. {
  2935. String::new()
  2936. } else {
  2937. input.to_string()
  2938. };
  2939. *pending_tool = Some((id, name, initial_input));
  2940. }
  2941. OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => {}
  2942. }
  2943. Ok(())
  2944. }
  2945. fn response_to_events(
  2946. response: MessageResponse,
  2947. out: &mut (impl Write + ?Sized),
  2948. ) -> Result<Vec<AssistantEvent>, RuntimeError> {
  2949. let mut events = Vec::new();
  2950. let mut pending_tool = None;
  2951. for block in response.content {
  2952. push_output_block(block, out, &mut events, &mut pending_tool, false)?;
  2953. if let Some((id, name, input)) = pending_tool.take() {
  2954. events.push(AssistantEvent::ToolUse { id, name, input });
  2955. }
  2956. }
  2957. events.push(AssistantEvent::Usage(TokenUsage {
  2958. input_tokens: response.usage.input_tokens,
  2959. output_tokens: response.usage.output_tokens,
  2960. cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
  2961. cache_read_input_tokens: response.usage.cache_read_input_tokens,
  2962. }));
  2963. events.push(AssistantEvent::MessageStop);
  2964. Ok(events)
  2965. }
  2966. struct CliToolExecutor {
  2967. renderer: TerminalRenderer,
  2968. emit_output: bool,
  2969. allowed_tools: Option<AllowedToolSet>,
  2970. tool_registry: GlobalToolRegistry,
  2971. }
  2972. impl CliToolExecutor {
  2973. fn new(
  2974. allowed_tools: Option<AllowedToolSet>,
  2975. emit_output: bool,
  2976. tool_registry: GlobalToolRegistry,
  2977. ) -> Self {
  2978. Self {
  2979. renderer: TerminalRenderer::new(),
  2980. emit_output,
  2981. allowed_tools,
  2982. tool_registry,
  2983. }
  2984. }
  2985. }
  2986. impl ToolExecutor for CliToolExecutor {
  2987. fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
  2988. if self
  2989. .allowed_tools
  2990. .as_ref()
  2991. .is_some_and(|allowed| !allowed.contains(tool_name))
  2992. {
  2993. return Err(ToolError::new(format!(
  2994. "tool `{tool_name}` is not enabled by the current --allowedTools setting"
  2995. )));
  2996. }
  2997. let value = serde_json::from_str(input)
  2998. .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
  2999. match self.tool_registry.execute(tool_name, &value) {
  3000. Ok(output) => {
  3001. if self.emit_output {
  3002. let markdown = format_tool_result(tool_name, &output, false);
  3003. self.renderer
  3004. .stream_markdown(&markdown, &mut io::stdout())
  3005. .map_err(|error| ToolError::new(error.to_string()))?;
  3006. }
  3007. Ok(output)
  3008. }
  3009. Err(error) => {
  3010. if self.emit_output {
  3011. let markdown = format_tool_result(tool_name, &error, true);
  3012. self.renderer
  3013. .stream_markdown(&markdown, &mut io::stdout())
  3014. .map_err(|stream_error| ToolError::new(stream_error.to_string()))?;
  3015. }
  3016. Err(ToolError::new(error))
  3017. }
  3018. }
  3019. }
  3020. }
  3021. fn permission_policy(mode: PermissionMode, tool_registry: &GlobalToolRegistry) -> PermissionPolicy {
  3022. tool_registry.permission_specs(None).into_iter().fold(
  3023. PermissionPolicy::new(mode),
  3024. |policy, (name, required_permission)| {
  3025. policy.with_tool_requirement(name, required_permission)
  3026. },
  3027. )
  3028. }
  3029. fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
  3030. messages
  3031. .iter()
  3032. .filter_map(|message| {
  3033. let role = match message.role {
  3034. MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
  3035. MessageRole::Assistant => "assistant",
  3036. };
  3037. let content = message
  3038. .blocks
  3039. .iter()
  3040. .map(|block| match block {
  3041. ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
  3042. ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
  3043. id: id.clone(),
  3044. name: name.clone(),
  3045. input: serde_json::from_str(input)
  3046. .unwrap_or_else(|_| serde_json::json!({ "raw": input })),
  3047. },
  3048. ContentBlock::ToolResult {
  3049. tool_use_id,
  3050. output,
  3051. is_error,
  3052. ..
  3053. } => InputContentBlock::ToolResult {
  3054. tool_use_id: tool_use_id.clone(),
  3055. content: vec![ToolResultContentBlock::Text {
  3056. text: output.clone(),
  3057. }],
  3058. is_error: *is_error,
  3059. },
  3060. })
  3061. .collect::<Vec<_>>();
  3062. (!content.is_empty()).then(|| InputMessage {
  3063. role: role.to_string(),
  3064. content,
  3065. })
  3066. })
  3067. .collect()
  3068. }
  3069. fn print_help_to(out: &mut impl Write) -> io::Result<()> {
  3070. writeln!(out, "claw v{VERSION}")?;
  3071. writeln!(out)?;
  3072. writeln!(out, "Usage:")?;
  3073. writeln!(
  3074. out,
  3075. " claw [--model MODEL] [--allowedTools TOOL[,TOOL...]]"
  3076. )?;
  3077. writeln!(out, " Start the interactive REPL")?;
  3078. writeln!(
  3079. out,
  3080. " claw [--model MODEL] [--output-format text|json] prompt TEXT"
  3081. )?;
  3082. writeln!(out, " Send one prompt and exit")?;
  3083. writeln!(
  3084. out,
  3085. " claw [--model MODEL] [--output-format text|json] TEXT"
  3086. )?;
  3087. writeln!(out, " Shorthand non-interactive prompt mode")?;
  3088. writeln!(
  3089. out,
  3090. " claw --resume SESSION.json [/status] [/compact] [...]"
  3091. )?;
  3092. writeln!(
  3093. out,
  3094. " Inspect or maintain a saved session without entering the REPL"
  3095. )?;
  3096. writeln!(out, " claw dump-manifests")?;
  3097. writeln!(out, " claw bootstrap-plan")?;
  3098. writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?;
  3099. writeln!(out, " claw login")?;
  3100. writeln!(out, " claw logout")?;
  3101. writeln!(out, " claw init")?;
  3102. writeln!(out)?;
  3103. writeln!(out, "Flags:")?;
  3104. writeln!(
  3105. out,
  3106. " --model MODEL Override the active model"
  3107. )?;
  3108. writeln!(
  3109. out,
  3110. " --output-format FORMAT Non-interactive output format: text or json"
  3111. )?;
  3112. writeln!(
  3113. out,
  3114. " --permission-mode MODE Set read-only, workspace-write, or danger-full-access"
  3115. )?;
  3116. writeln!(
  3117. out,
  3118. " --dangerously-skip-permissions Skip all permission checks"
  3119. )?;
  3120. writeln!(out, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)")?;
  3121. writeln!(
  3122. out,
  3123. " --version, -V Print version and build information locally"
  3124. )?;
  3125. writeln!(out)?;
  3126. writeln!(out, "Interactive slash commands:")?;
  3127. writeln!(out, "{}", render_slash_command_help())?;
  3128. writeln!(out)?;
  3129. let resume_commands = resume_supported_slash_commands()
  3130. .into_iter()
  3131. .map(|spec| match spec.argument_hint {
  3132. Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
  3133. None => format!("/{}", spec.name),
  3134. })
  3135. .collect::<Vec<_>>()
  3136. .join(", ");
  3137. writeln!(out, "Resume-safe commands: {resume_commands}")?;
  3138. writeln!(out, "Examples:")?;
  3139. writeln!(out, " claw --model claude-opus \"summarize this repo\"")?;
  3140. writeln!(
  3141. out,
  3142. " claw --output-format json prompt \"explain src/main.rs\""
  3143. )?;
  3144. writeln!(
  3145. out,
  3146. " claw --allowedTools read,glob \"summarize Cargo.toml\""
  3147. )?;
  3148. writeln!(
  3149. out,
  3150. " claw --resume session.json /status /diff /export notes.txt"
  3151. )?;
  3152. writeln!(out, " claw login")?;
  3153. writeln!(out, " claw init")?;
  3154. Ok(())
  3155. }
  3156. fn print_help() {
  3157. let _ = print_help_to(&mut io::stdout());
  3158. }
  3159. #[cfg(test)]
  3160. mod tests {
  3161. use super::{
  3162. filter_tool_specs, format_compact_report, format_cost_report, format_model_report,
  3163. format_model_switch_report, format_permissions_report, format_permissions_switch_report,
  3164. format_resume_report, format_status_report, format_tool_call_start, format_tool_result,
  3165. normalize_permission_mode, parse_args, parse_git_status_metadata, permission_policy,
  3166. print_help_to, push_output_block, render_config_report, render_memory_report,
  3167. render_repl_help, resolve_model_alias, response_to_events, resume_supported_slash_commands,
  3168. status_context, CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL,
  3169. };
  3170. use api::{MessageResponse, OutputContentBlock, Usage};
  3171. use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
  3172. use runtime::{AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode};
  3173. use serde_json::json;
  3174. use std::path::PathBuf;
  3175. use tools::GlobalToolRegistry;
  3176. fn registry_with_plugin_tool() -> GlobalToolRegistry {
  3177. GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
  3178. "plugin-demo@external",
  3179. "plugin-demo",
  3180. PluginToolDefinition {
  3181. name: "plugin_echo".to_string(),
  3182. description: Some("Echo plugin payload".to_string()),
  3183. input_schema: json!({
  3184. "type": "object",
  3185. "properties": {
  3186. "message": { "type": "string" }
  3187. },
  3188. "required": ["message"],
  3189. "additionalProperties": false
  3190. }),
  3191. },
  3192. "echo".to_string(),
  3193. Vec::new(),
  3194. PluginToolPermission::WorkspaceWrite,
  3195. None,
  3196. )])
  3197. .expect("plugin tool registry should build")
  3198. }
  3199. #[test]
  3200. fn defaults_to_repl_when_no_args() {
  3201. assert_eq!(
  3202. parse_args(&[]).expect("args should parse"),
  3203. CliAction::Repl {
  3204. model: DEFAULT_MODEL.to_string(),
  3205. allowed_tools: None,
  3206. permission_mode: PermissionMode::DangerFullAccess,
  3207. }
  3208. );
  3209. }
  3210. #[test]
  3211. fn parses_prompt_subcommand() {
  3212. let args = vec![
  3213. "prompt".to_string(),
  3214. "hello".to_string(),
  3215. "world".to_string(),
  3216. ];
  3217. assert_eq!(
  3218. parse_args(&args).expect("args should parse"),
  3219. CliAction::Prompt {
  3220. prompt: "hello world".to_string(),
  3221. model: DEFAULT_MODEL.to_string(),
  3222. output_format: CliOutputFormat::Text,
  3223. allowed_tools: None,
  3224. permission_mode: PermissionMode::DangerFullAccess,
  3225. }
  3226. );
  3227. }
  3228. #[test]
  3229. fn parses_bare_prompt_and_json_output_flag() {
  3230. let args = vec![
  3231. "--output-format=json".to_string(),
  3232. "--model".to_string(),
  3233. "claude-opus".to_string(),
  3234. "explain".to_string(),
  3235. "this".to_string(),
  3236. ];
  3237. assert_eq!(
  3238. parse_args(&args).expect("args should parse"),
  3239. CliAction::Prompt {
  3240. prompt: "explain this".to_string(),
  3241. model: "claude-opus".to_string(),
  3242. output_format: CliOutputFormat::Json,
  3243. allowed_tools: None,
  3244. permission_mode: PermissionMode::DangerFullAccess,
  3245. }
  3246. );
  3247. }
  3248. #[test]
  3249. fn resolves_model_aliases_in_args() {
  3250. let args = vec![
  3251. "--model".to_string(),
  3252. "opus".to_string(),
  3253. "explain".to_string(),
  3254. "this".to_string(),
  3255. ];
  3256. assert_eq!(
  3257. parse_args(&args).expect("args should parse"),
  3258. CliAction::Prompt {
  3259. prompt: "explain this".to_string(),
  3260. model: "claude-opus-4-6".to_string(),
  3261. output_format: CliOutputFormat::Text,
  3262. allowed_tools: None,
  3263. permission_mode: PermissionMode::DangerFullAccess,
  3264. }
  3265. );
  3266. }
  3267. #[test]
  3268. fn resolves_known_model_aliases() {
  3269. assert_eq!(resolve_model_alias("opus"), "claude-opus-4-6");
  3270. assert_eq!(resolve_model_alias("sonnet"), "claude-sonnet-4-6");
  3271. assert_eq!(resolve_model_alias("haiku"), "claude-haiku-4-5-20251213");
  3272. assert_eq!(resolve_model_alias("claude-opus"), "claude-opus");
  3273. }
  3274. #[test]
  3275. fn parses_version_flags_without_initializing_prompt_mode() {
  3276. assert_eq!(
  3277. parse_args(&["--version".to_string()]).expect("args should parse"),
  3278. CliAction::Version
  3279. );
  3280. assert_eq!(
  3281. parse_args(&["-V".to_string()]).expect("args should parse"),
  3282. CliAction::Version
  3283. );
  3284. }
  3285. #[test]
  3286. fn parses_permission_mode_flag() {
  3287. let args = vec!["--permission-mode=read-only".to_string()];
  3288. assert_eq!(
  3289. parse_args(&args).expect("args should parse"),
  3290. CliAction::Repl {
  3291. model: DEFAULT_MODEL.to_string(),
  3292. allowed_tools: None,
  3293. permission_mode: PermissionMode::ReadOnly,
  3294. }
  3295. );
  3296. }
  3297. #[test]
  3298. fn parses_allowed_tools_flags_with_aliases_and_lists() {
  3299. let args = vec![
  3300. "--allowedTools".to_string(),
  3301. "read,glob".to_string(),
  3302. "--allowed-tools=write_file".to_string(),
  3303. ];
  3304. assert_eq!(
  3305. parse_args(&args).expect("args should parse"),
  3306. CliAction::Repl {
  3307. model: DEFAULT_MODEL.to_string(),
  3308. allowed_tools: Some(
  3309. ["glob_search", "read_file", "write_file"]
  3310. .into_iter()
  3311. .map(str::to_string)
  3312. .collect()
  3313. ),
  3314. permission_mode: PermissionMode::DangerFullAccess,
  3315. }
  3316. );
  3317. }
  3318. #[test]
  3319. fn rejects_unknown_allowed_tools() {
  3320. let error = parse_args(&["--allowedTools".to_string(), "teleport".to_string()])
  3321. .expect_err("tool should be rejected");
  3322. assert!(error.contains("unsupported tool in --allowedTools: teleport"));
  3323. }
  3324. #[test]
  3325. fn parses_system_prompt_options() {
  3326. let args = vec![
  3327. "system-prompt".to_string(),
  3328. "--cwd".to_string(),
  3329. "/tmp/project".to_string(),
  3330. "--date".to_string(),
  3331. "2026-04-01".to_string(),
  3332. ];
  3333. assert_eq!(
  3334. parse_args(&args).expect("args should parse"),
  3335. CliAction::PrintSystemPrompt {
  3336. cwd: PathBuf::from("/tmp/project"),
  3337. date: "2026-04-01".to_string(),
  3338. }
  3339. );
  3340. }
  3341. #[test]
  3342. fn parses_login_and_logout_subcommands() {
  3343. assert_eq!(
  3344. parse_args(&["login".to_string()]).expect("login should parse"),
  3345. CliAction::Login
  3346. );
  3347. assert_eq!(
  3348. parse_args(&["logout".to_string()]).expect("logout should parse"),
  3349. CliAction::Logout
  3350. );
  3351. assert_eq!(
  3352. parse_args(&["init".to_string()]).expect("init should parse"),
  3353. CliAction::Init
  3354. );
  3355. }
  3356. #[test]
  3357. fn parses_resume_flag_with_slash_command() {
  3358. let args = vec![
  3359. "--resume".to_string(),
  3360. "session.json".to_string(),
  3361. "/compact".to_string(),
  3362. ];
  3363. assert_eq!(
  3364. parse_args(&args).expect("args should parse"),
  3365. CliAction::ResumeSession {
  3366. session_path: PathBuf::from("session.json"),
  3367. commands: vec!["/compact".to_string()],
  3368. }
  3369. );
  3370. }
  3371. #[test]
  3372. fn parses_resume_flag_with_multiple_slash_commands() {
  3373. let args = vec![
  3374. "--resume".to_string(),
  3375. "session.json".to_string(),
  3376. "/status".to_string(),
  3377. "/compact".to_string(),
  3378. "/cost".to_string(),
  3379. ];
  3380. assert_eq!(
  3381. parse_args(&args).expect("args should parse"),
  3382. CliAction::ResumeSession {
  3383. session_path: PathBuf::from("session.json"),
  3384. commands: vec![
  3385. "/status".to_string(),
  3386. "/compact".to_string(),
  3387. "/cost".to_string(),
  3388. ],
  3389. }
  3390. );
  3391. }
  3392. #[test]
  3393. fn filtered_tool_specs_respect_allowlist() {
  3394. let allowed = ["read_file", "grep_search"]
  3395. .into_iter()
  3396. .map(str::to_string)
  3397. .collect();
  3398. let filtered = filter_tool_specs(&GlobalToolRegistry::builtin(), Some(&allowed));
  3399. let names = filtered
  3400. .into_iter()
  3401. .map(|spec| spec.name)
  3402. .collect::<Vec<_>>();
  3403. assert_eq!(names, vec!["read_file", "grep_search"]);
  3404. }
  3405. #[test]
  3406. fn filtered_tool_specs_include_plugin_tools() {
  3407. let filtered = filter_tool_specs(&registry_with_plugin_tool(), None);
  3408. let names = filtered
  3409. .into_iter()
  3410. .map(|definition| definition.name)
  3411. .collect::<Vec<_>>();
  3412. assert!(names.contains(&"bash".to_string()));
  3413. assert!(names.contains(&"plugin_echo".to_string()));
  3414. }
  3415. #[test]
  3416. fn permission_policy_uses_plugin_tool_permissions() {
  3417. let policy = permission_policy(PermissionMode::ReadOnly, &registry_with_plugin_tool());
  3418. let required = policy.required_mode_for("plugin_echo");
  3419. assert_eq!(required, PermissionMode::WorkspaceWrite);
  3420. }
  3421. #[test]
  3422. fn shared_help_uses_resume_annotation_copy() {
  3423. let help = commands::render_slash_command_help();
  3424. assert!(help.contains("Slash commands"));
  3425. assert!(help.contains("works with --resume SESSION.json"));
  3426. }
  3427. #[test]
  3428. fn repl_help_includes_shared_commands_and_exit() {
  3429. let help = render_repl_help();
  3430. assert!(help.contains("REPL"));
  3431. assert!(help.contains("/help"));
  3432. assert!(help.contains("/status"));
  3433. assert!(help.contains("/model [model]"));
  3434. assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
  3435. assert!(help.contains("/clear [--confirm]"));
  3436. assert!(help.contains("/cost"));
  3437. assert!(help.contains("/resume <session-path>"));
  3438. assert!(help.contains("/config [env|hooks|model|plugins]"));
  3439. assert!(help.contains("/memory"));
  3440. assert!(help.contains("/init"));
  3441. assert!(help.contains("/diff"));
  3442. assert!(help.contains("/version"));
  3443. assert!(help.contains("/export [file]"));
  3444. assert!(help.contains("/session [list|switch <session-id>]"));
  3445. assert!(help.contains(
  3446. "/plugins [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
  3447. ));
  3448. assert!(help.contains("/exit"));
  3449. }
  3450. #[test]
  3451. fn resume_supported_command_list_matches_expected_surface() {
  3452. let names = resume_supported_slash_commands()
  3453. .into_iter()
  3454. .map(|spec| spec.name)
  3455. .collect::<Vec<_>>();
  3456. assert_eq!(
  3457. names,
  3458. vec![
  3459. "help", "status", "compact", "clear", "cost", "config", "memory", "init", "diff",
  3460. "version", "export",
  3461. ]
  3462. );
  3463. }
  3464. #[test]
  3465. fn resume_report_uses_sectioned_layout() {
  3466. let report = format_resume_report("session.json", 14, 6);
  3467. assert!(report.contains("Session resumed"));
  3468. assert!(report.contains("Session file session.json"));
  3469. assert!(report.contains("Messages 14"));
  3470. assert!(report.contains("Turns 6"));
  3471. }
  3472. #[test]
  3473. fn compact_report_uses_structured_output() {
  3474. let compacted = format_compact_report(8, 5, false);
  3475. assert!(compacted.contains("Compact"));
  3476. assert!(compacted.contains("Result compacted"));
  3477. assert!(compacted.contains("Messages removed 8"));
  3478. let skipped = format_compact_report(0, 3, true);
  3479. assert!(skipped.contains("Result skipped"));
  3480. }
  3481. #[test]
  3482. fn cost_report_uses_sectioned_layout() {
  3483. let report = format_cost_report(runtime::TokenUsage {
  3484. input_tokens: 20,
  3485. output_tokens: 8,
  3486. cache_creation_input_tokens: 3,
  3487. cache_read_input_tokens: 1,
  3488. });
  3489. assert!(report.contains("Cost"));
  3490. assert!(report.contains("Input tokens 20"));
  3491. assert!(report.contains("Output tokens 8"));
  3492. assert!(report.contains("Cache create 3"));
  3493. assert!(report.contains("Cache read 1"));
  3494. assert!(report.contains("Total tokens 32"));
  3495. }
  3496. #[test]
  3497. fn permissions_report_uses_sectioned_layout() {
  3498. let report = format_permissions_report("workspace-write");
  3499. assert!(report.contains("Permissions"));
  3500. assert!(report.contains("Active mode workspace-write"));
  3501. assert!(report.contains("Modes"));
  3502. assert!(report.contains("read-only ○ available Read/search tools only"));
  3503. assert!(report.contains("workspace-write ● current Edit files inside the workspace"));
  3504. assert!(report.contains("danger-full-access ○ available Unrestricted tool access"));
  3505. }
  3506. #[test]
  3507. fn permissions_switch_report_is_structured() {
  3508. let report = format_permissions_switch_report("read-only", "workspace-write");
  3509. assert!(report.contains("Permissions updated"));
  3510. assert!(report.contains("Result mode switched"));
  3511. assert!(report.contains("Previous mode read-only"));
  3512. assert!(report.contains("Active mode workspace-write"));
  3513. assert!(report.contains("Applies to subsequent tool calls"));
  3514. }
  3515. #[test]
  3516. fn init_help_mentions_direct_subcommand() {
  3517. let mut help = Vec::new();
  3518. print_help_to(&mut help).expect("help should render");
  3519. let help = String::from_utf8(help).expect("help should be utf8");
  3520. assert!(help.contains("claw init"));
  3521. }
  3522. #[test]
  3523. fn model_report_uses_sectioned_layout() {
  3524. let report = format_model_report("claude-sonnet", 12, 4);
  3525. assert!(report.contains("Model"));
  3526. assert!(report.contains("Current model claude-sonnet"));
  3527. assert!(report.contains("Session messages 12"));
  3528. assert!(report.contains("Switch models with /model <name>"));
  3529. }
  3530. #[test]
  3531. fn model_switch_report_preserves_context_summary() {
  3532. let report = format_model_switch_report("claude-sonnet", "claude-opus", 9);
  3533. assert!(report.contains("Model updated"));
  3534. assert!(report.contains("Previous claude-sonnet"));
  3535. assert!(report.contains("Current claude-opus"));
  3536. assert!(report.contains("Preserved msgs 9"));
  3537. }
  3538. #[test]
  3539. fn status_line_reports_model_and_token_totals() {
  3540. let status = format_status_report(
  3541. "claude-sonnet",
  3542. StatusUsage {
  3543. message_count: 7,
  3544. turns: 3,
  3545. latest: runtime::TokenUsage {
  3546. input_tokens: 5,
  3547. output_tokens: 4,
  3548. cache_creation_input_tokens: 1,
  3549. cache_read_input_tokens: 0,
  3550. },
  3551. cumulative: runtime::TokenUsage {
  3552. input_tokens: 20,
  3553. output_tokens: 8,
  3554. cache_creation_input_tokens: 2,
  3555. cache_read_input_tokens: 1,
  3556. },
  3557. estimated_tokens: 128,
  3558. },
  3559. "workspace-write",
  3560. &super::StatusContext {
  3561. cwd: PathBuf::from("/tmp/project"),
  3562. session_path: Some(PathBuf::from("session.json")),
  3563. loaded_config_files: 2,
  3564. discovered_config_files: 3,
  3565. memory_file_count: 4,
  3566. project_root: Some(PathBuf::from("/tmp")),
  3567. git_branch: Some("main".to_string()),
  3568. },
  3569. );
  3570. assert!(status.contains("Status"));
  3571. assert!(status.contains("Model claude-sonnet"));
  3572. assert!(status.contains("Permission mode workspace-write"));
  3573. assert!(status.contains("Messages 7"));
  3574. assert!(status.contains("Latest total 10"));
  3575. assert!(status.contains("Cumulative total 31"));
  3576. assert!(status.contains("Cwd /tmp/project"));
  3577. assert!(status.contains("Project root /tmp"));
  3578. assert!(status.contains("Git branch main"));
  3579. assert!(status.contains("Session session.json"));
  3580. assert!(status.contains("Config files loaded 2/3"));
  3581. assert!(status.contains("Memory files 4"));
  3582. }
  3583. #[test]
  3584. fn config_report_supports_section_views() {
  3585. let report = render_config_report(Some("env")).expect("config report should render");
  3586. assert!(report.contains("Merged section: env"));
  3587. let plugins_report =
  3588. render_config_report(Some("plugins")).expect("plugins config report should render");
  3589. assert!(plugins_report.contains("Merged section: plugins"));
  3590. }
  3591. #[test]
  3592. fn memory_report_uses_sectioned_layout() {
  3593. let report = render_memory_report().expect("memory report should render");
  3594. assert!(report.contains("Memory"));
  3595. assert!(report.contains("Working directory"));
  3596. assert!(report.contains("Instruction files"));
  3597. assert!(report.contains("Discovered files"));
  3598. }
  3599. #[test]
  3600. fn config_report_uses_sectioned_layout() {
  3601. let report = render_config_report(None).expect("config report should render");
  3602. assert!(report.contains("Config"));
  3603. assert!(report.contains("Discovered files"));
  3604. assert!(report.contains("Merged JSON"));
  3605. }
  3606. #[test]
  3607. fn parses_git_status_metadata() {
  3608. let (root, branch) = parse_git_status_metadata(Some(
  3609. "## rcc/cli...origin/rcc/cli
  3610. M src/main.rs",
  3611. ));
  3612. assert_eq!(branch.as_deref(), Some("rcc/cli"));
  3613. let _ = root;
  3614. }
  3615. #[test]
  3616. fn status_context_reads_real_workspace_metadata() {
  3617. let context = status_context(None).expect("status context should load");
  3618. assert!(context.cwd.is_absolute());
  3619. assert_eq!(context.discovered_config_files, 5);
  3620. assert!(context.loaded_config_files <= context.discovered_config_files);
  3621. }
  3622. #[test]
  3623. fn normalizes_supported_permission_modes() {
  3624. assert_eq!(normalize_permission_mode("read-only"), Some("read-only"));
  3625. assert_eq!(
  3626. normalize_permission_mode("workspace-write"),
  3627. Some("workspace-write")
  3628. );
  3629. assert_eq!(
  3630. normalize_permission_mode("danger-full-access"),
  3631. Some("danger-full-access")
  3632. );
  3633. assert_eq!(normalize_permission_mode("unknown"), None);
  3634. }
  3635. #[test]
  3636. fn clear_command_requires_explicit_confirmation_flag() {
  3637. assert_eq!(
  3638. SlashCommand::parse("/clear"),
  3639. Some(SlashCommand::Clear { confirm: false })
  3640. );
  3641. assert_eq!(
  3642. SlashCommand::parse("/clear --confirm"),
  3643. Some(SlashCommand::Clear { confirm: true })
  3644. );
  3645. }
  3646. #[test]
  3647. fn parses_resume_and_config_slash_commands() {
  3648. assert_eq!(
  3649. SlashCommand::parse("/resume saved-session.json"),
  3650. Some(SlashCommand::Resume {
  3651. session_path: Some("saved-session.json".to_string())
  3652. })
  3653. );
  3654. assert_eq!(
  3655. SlashCommand::parse("/clear --confirm"),
  3656. Some(SlashCommand::Clear { confirm: true })
  3657. );
  3658. assert_eq!(
  3659. SlashCommand::parse("/config"),
  3660. Some(SlashCommand::Config { section: None })
  3661. );
  3662. assert_eq!(
  3663. SlashCommand::parse("/config env"),
  3664. Some(SlashCommand::Config {
  3665. section: Some("env".to_string())
  3666. })
  3667. );
  3668. assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
  3669. assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
  3670. }
  3671. #[test]
  3672. fn init_template_mentions_detected_rust_workspace() {
  3673. let rendered = crate::init::render_init_claude_md(std::path::Path::new("."));
  3674. assert!(rendered.contains("# CLAUDE.md"));
  3675. assert!(rendered.contains("cargo clippy --workspace --all-targets -- -D warnings"));
  3676. }
  3677. #[test]
  3678. fn converts_tool_roundtrip_messages() {
  3679. let messages = vec![
  3680. ConversationMessage::user_text("hello"),
  3681. ConversationMessage::assistant(vec![ContentBlock::ToolUse {
  3682. id: "tool-1".to_string(),
  3683. name: "bash".to_string(),
  3684. input: "{\"command\":\"pwd\"}".to_string(),
  3685. }]),
  3686. ConversationMessage {
  3687. role: MessageRole::Tool,
  3688. blocks: vec![ContentBlock::ToolResult {
  3689. tool_use_id: "tool-1".to_string(),
  3690. tool_name: "bash".to_string(),
  3691. output: "ok".to_string(),
  3692. is_error: false,
  3693. }],
  3694. usage: None,
  3695. },
  3696. ];
  3697. let converted = super::convert_messages(&messages);
  3698. assert_eq!(converted.len(), 3);
  3699. assert_eq!(converted[1].role, "assistant");
  3700. assert_eq!(converted[2].role, "user");
  3701. }
  3702. #[test]
  3703. fn repl_help_mentions_history_completion_and_multiline() {
  3704. let help = render_repl_help();
  3705. assert!(help.contains("Up/Down"));
  3706. assert!(help.contains("Tab"));
  3707. assert!(help.contains("Shift+Enter/Ctrl+J"));
  3708. }
  3709. #[test]
  3710. fn tool_rendering_helpers_compact_output() {
  3711. let start = format_tool_call_start("read_file", r#"{"path":"src/main.rs"}"#);
  3712. assert!(start.contains("read_file"));
  3713. assert!(start.contains("src/main.rs"));
  3714. let done = format_tool_result(
  3715. "read_file",
  3716. r#"{"file":{"filePath":"src/main.rs","content":"hello","numLines":1,"startLine":1,"totalLines":1}}"#,
  3717. false,
  3718. );
  3719. assert!(done.contains("📄 Read src/main.rs"));
  3720. assert!(done.contains("hello"));
  3721. }
  3722. #[test]
  3723. fn tool_rendering_truncates_large_read_output_for_display_only() {
  3724. let content = (0..200)
  3725. .map(|index| format!("line {index:03}"))
  3726. .collect::<Vec<_>>()
  3727. .join("\n");
  3728. let output = json!({
  3729. "file": {
  3730. "filePath": "src/main.rs",
  3731. "content": content,
  3732. "numLines": 200,
  3733. "startLine": 1,
  3734. "totalLines": 200
  3735. }
  3736. })
  3737. .to_string();
  3738. let rendered = format_tool_result("read_file", &output, false);
  3739. assert!(rendered.contains("line 000"));
  3740. assert!(rendered.contains("line 079"));
  3741. assert!(!rendered.contains("line 199"));
  3742. assert!(rendered.contains("full result preserved in session"));
  3743. assert!(output.contains("line 199"));
  3744. }
  3745. #[test]
  3746. fn tool_rendering_truncates_large_bash_output_for_display_only() {
  3747. let stdout = (0..120)
  3748. .map(|index| format!("stdout {index:03}"))
  3749. .collect::<Vec<_>>()
  3750. .join("\n");
  3751. let output = json!({
  3752. "stdout": stdout,
  3753. "stderr": "",
  3754. "returnCodeInterpretation": "completed successfully"
  3755. })
  3756. .to_string();
  3757. let rendered = format_tool_result("bash", &output, false);
  3758. assert!(rendered.contains("stdout 000"));
  3759. assert!(rendered.contains("stdout 059"));
  3760. assert!(!rendered.contains("stdout 119"));
  3761. assert!(rendered.contains("full result preserved in session"));
  3762. assert!(output.contains("stdout 119"));
  3763. }
  3764. #[test]
  3765. fn tool_rendering_truncates_generic_long_output_for_display_only() {
  3766. let items = (0..120)
  3767. .map(|index| format!("payload {index:03}"))
  3768. .collect::<Vec<_>>();
  3769. let output = json!({
  3770. "summary": "plugin payload",
  3771. "items": items,
  3772. })
  3773. .to_string();
  3774. let rendered = format_tool_result("plugin_echo", &output, false);
  3775. assert!(rendered.contains("plugin_echo"));
  3776. assert!(rendered.contains("payload 000"));
  3777. assert!(rendered.contains("payload 040"));
  3778. assert!(!rendered.contains("payload 080"));
  3779. assert!(!rendered.contains("payload 119"));
  3780. assert!(rendered.contains("full result preserved in session"));
  3781. assert!(output.contains("payload 119"));
  3782. }
  3783. #[test]
  3784. fn tool_rendering_truncates_raw_generic_output_for_display_only() {
  3785. let output = (0..120)
  3786. .map(|index| format!("raw {index:03}"))
  3787. .collect::<Vec<_>>()
  3788. .join("\n");
  3789. let rendered = format_tool_result("plugin_echo", &output, false);
  3790. assert!(rendered.contains("plugin_echo"));
  3791. assert!(rendered.contains("raw 000"));
  3792. assert!(rendered.contains("raw 059"));
  3793. assert!(!rendered.contains("raw 119"));
  3794. assert!(rendered.contains("full result preserved in session"));
  3795. assert!(output.contains("raw 119"));
  3796. }
  3797. #[test]
  3798. fn push_output_block_renders_markdown_text() {
  3799. let mut out = Vec::new();
  3800. let mut events = Vec::new();
  3801. let mut pending_tool = None;
  3802. push_output_block(
  3803. OutputContentBlock::Text {
  3804. text: "# Heading".to_string(),
  3805. },
  3806. &mut out,
  3807. &mut events,
  3808. &mut pending_tool,
  3809. false,
  3810. )
  3811. .expect("text block should render");
  3812. let rendered = String::from_utf8(out).expect("utf8");
  3813. assert!(rendered.contains("Heading"));
  3814. assert!(rendered.contains('\u{1b}'));
  3815. }
  3816. #[test]
  3817. fn push_output_block_skips_empty_object_prefix_for_tool_streams() {
  3818. let mut out = Vec::new();
  3819. let mut events = Vec::new();
  3820. let mut pending_tool = None;
  3821. push_output_block(
  3822. OutputContentBlock::ToolUse {
  3823. id: "tool-1".to_string(),
  3824. name: "read_file".to_string(),
  3825. input: json!({}),
  3826. },
  3827. &mut out,
  3828. &mut events,
  3829. &mut pending_tool,
  3830. true,
  3831. )
  3832. .expect("tool block should accumulate");
  3833. assert!(events.is_empty());
  3834. assert_eq!(
  3835. pending_tool,
  3836. Some(("tool-1".to_string(), "read_file".to_string(), String::new(),))
  3837. );
  3838. }
  3839. #[test]
  3840. fn response_to_events_preserves_empty_object_json_input_outside_streaming() {
  3841. let mut out = Vec::new();
  3842. let events = response_to_events(
  3843. MessageResponse {
  3844. id: "msg-1".to_string(),
  3845. kind: "message".to_string(),
  3846. model: "claude-opus-4-6".to_string(),
  3847. role: "assistant".to_string(),
  3848. content: vec![OutputContentBlock::ToolUse {
  3849. id: "tool-1".to_string(),
  3850. name: "read_file".to_string(),
  3851. input: json!({}),
  3852. }],
  3853. stop_reason: Some("tool_use".to_string()),
  3854. stop_sequence: None,
  3855. usage: Usage {
  3856. input_tokens: 1,
  3857. output_tokens: 1,
  3858. cache_creation_input_tokens: 0,
  3859. cache_read_input_tokens: 0,
  3860. },
  3861. request_id: None,
  3862. },
  3863. &mut out,
  3864. )
  3865. .expect("response conversion should succeed");
  3866. assert!(matches!(
  3867. &events[0],
  3868. AssistantEvent::ToolUse { name, input, .. }
  3869. if name == "read_file" && input == "{}"
  3870. ));
  3871. }
  3872. #[test]
  3873. fn response_to_events_preserves_non_empty_json_input_outside_streaming() {
  3874. let mut out = Vec::new();
  3875. let events = response_to_events(
  3876. MessageResponse {
  3877. id: "msg-2".to_string(),
  3878. kind: "message".to_string(),
  3879. model: "claude-opus-4-6".to_string(),
  3880. role: "assistant".to_string(),
  3881. content: vec![OutputContentBlock::ToolUse {
  3882. id: "tool-2".to_string(),
  3883. name: "read_file".to_string(),
  3884. input: json!({ "path": "rust/Cargo.toml" }),
  3885. }],
  3886. stop_reason: Some("tool_use".to_string()),
  3887. stop_sequence: None,
  3888. usage: Usage {
  3889. input_tokens: 1,
  3890. output_tokens: 1,
  3891. cache_creation_input_tokens: 0,
  3892. cache_read_input_tokens: 0,
  3893. },
  3894. request_id: None,
  3895. },
  3896. &mut out,
  3897. )
  3898. .expect("response conversion should succeed");
  3899. assert!(matches!(
  3900. &events[0],
  3901. AssistantEvent::ToolUse { name, input, .. }
  3902. if name == "read_file" && input == "{\"path\":\"rust/Cargo.toml\"}"
  3903. ));
  3904. }
  3905. #[test]
  3906. fn response_to_events_ignores_thinking_blocks() {
  3907. let mut out = Vec::new();
  3908. let events = response_to_events(
  3909. MessageResponse {
  3910. id: "msg-3".to_string(),
  3911. kind: "message".to_string(),
  3912. model: "claude-opus-4-6".to_string(),
  3913. role: "assistant".to_string(),
  3914. content: vec![
  3915. OutputContentBlock::Thinking {
  3916. thinking: "step 1".to_string(),
  3917. signature: Some("sig_123".to_string()),
  3918. },
  3919. OutputContentBlock::Text {
  3920. text: "Final answer".to_string(),
  3921. },
  3922. ],
  3923. stop_reason: Some("end_turn".to_string()),
  3924. stop_sequence: None,
  3925. usage: Usage {
  3926. input_tokens: 1,
  3927. output_tokens: 1,
  3928. cache_creation_input_tokens: 0,
  3929. cache_read_input_tokens: 0,
  3930. },
  3931. request_id: None,
  3932. },
  3933. &mut out,
  3934. )
  3935. .expect("response conversion should succeed");
  3936. assert!(matches!(
  3937. &events[0],
  3938. AssistantEvent::TextDelta(text) if text == "Final answer"
  3939. ));
  3940. assert!(!String::from_utf8(out).expect("utf8").contains("step 1"));
  3941. }
  3942. }