main.rs 187 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517
  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, Receiver, RecvTimeoutError, Sender};
  13. use std::sync::{Arc, Mutex};
  14. use std::thread::{self, JoinHandle};
  15. use std::time::{Duration, Instant, UNIX_EPOCH};
  16. use api::{
  17. resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock,
  18. InputMessage, MessageRequest, MessageResponse, OutputContentBlock, PromptCache,
  19. SessionTracer, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
  20. ToolResultContentBlock,
  21. };
  22. use commands::{
  23. handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command,
  24. render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand,
  25. };
  26. use compat_harness::{extract_manifest, UpstreamPaths};
  27. use init::initialize_repo;
  28. use plugins::{PluginManager, PluginManagerConfig};
  29. use render::{MarkdownStreamState, Spinner, TerminalRenderer};
  30. use runtime::{
  31. clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
  32. parse_oauth_callback_request_target, resolve_sandbox_status, save_oauth_credentials,
  33. ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource,
  34. ContentBlock, ConversationMessage, ConversationRuntime, MessageRole, PromptCacheEvent,
  35. OAuthAuthorizationRequest, OAuthConfig,
  36. OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
  37. Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
  38. };
  39. use serde_json::json;
  40. use tools::GlobalToolRegistry;
  41. const DEFAULT_MODEL: &str = "claude-opus-4-6";
  42. fn max_tokens_for_model(model: &str) -> u32 {
  43. if model.contains("opus") {
  44. 32_000
  45. } else {
  46. 64_000
  47. }
  48. }
  49. const DEFAULT_DATE: &str = "2026-03-31";
  50. const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
  51. const TELEMETRY_LOG_PATH_ENV: &str = "CLAW_TELEMETRY_LOG_PATH";
  52. const VERSION: &str = env!("CARGO_PKG_VERSION");
  53. const BUILD_TARGET: Option<&str> = option_env!("TARGET");
  54. const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
  55. const INTERNAL_PROGRESS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(3);
  56. const PRIMARY_SESSION_EXTENSION: &str = "jsonl";
  57. const LEGACY_SESSION_EXTENSION: &str = "json";
  58. type AllowedToolSet = BTreeSet<String>;
  59. fn main() {
  60. if let Err(error) = run() {
  61. eprintln!(
  62. "error: {error}
  63. Run `claw --help` for usage."
  64. );
  65. std::process::exit(1);
  66. }
  67. }
  68. fn run() -> Result<(), Box<dyn std::error::Error>> {
  69. let args: Vec<String> = env::args().skip(1).collect();
  70. match parse_args(&args)? {
  71. CliAction::DumpManifests => dump_manifests(),
  72. CliAction::BootstrapPlan => print_bootstrap_plan(),
  73. CliAction::Agents { args } => LiveCli::print_agents(args.as_deref())?,
  74. CliAction::Skills { args } => LiveCli::print_skills(args.as_deref())?,
  75. CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
  76. CliAction::Version => print_version(),
  77. CliAction::ResumeSession {
  78. session_path,
  79. commands,
  80. } => resume_session(&session_path, &commands),
  81. CliAction::Prompt {
  82. prompt,
  83. model,
  84. output_format,
  85. allowed_tools,
  86. permission_mode,
  87. } => LiveCli::new(model, true, allowed_tools, permission_mode)?
  88. .run_turn_with_output(&prompt, output_format)?,
  89. CliAction::Login => run_login()?,
  90. CliAction::Logout => run_logout()?,
  91. CliAction::Init => run_init()?,
  92. CliAction::Repl {
  93. model,
  94. allowed_tools,
  95. permission_mode,
  96. } => run_repl(model, allowed_tools, permission_mode)?,
  97. CliAction::Help => print_help(),
  98. }
  99. Ok(())
  100. }
  101. #[derive(Debug, Clone, PartialEq, Eq)]
  102. enum CliAction {
  103. DumpManifests,
  104. BootstrapPlan,
  105. Agents {
  106. args: Option<String>,
  107. },
  108. Skills {
  109. args: Option<String>,
  110. },
  111. PrintSystemPrompt {
  112. cwd: PathBuf,
  113. date: String,
  114. },
  115. Version,
  116. ResumeSession {
  117. session_path: PathBuf,
  118. commands: Vec<String>,
  119. },
  120. Prompt {
  121. prompt: String,
  122. model: String,
  123. output_format: CliOutputFormat,
  124. allowed_tools: Option<AllowedToolSet>,
  125. permission_mode: PermissionMode,
  126. },
  127. Login,
  128. Logout,
  129. Init,
  130. Repl {
  131. model: String,
  132. allowed_tools: Option<AllowedToolSet>,
  133. permission_mode: PermissionMode,
  134. },
  135. // prompt-mode formatting is only supported for non-interactive runs
  136. Help,
  137. }
  138. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  139. enum CliOutputFormat {
  140. Text,
  141. Json,
  142. }
  143. impl CliOutputFormat {
  144. fn parse(value: &str) -> Result<Self, String> {
  145. match value {
  146. "text" => Ok(Self::Text),
  147. "json" => Ok(Self::Json),
  148. other => Err(format!(
  149. "unsupported value for --output-format: {other} (expected text or json)"
  150. )),
  151. }
  152. }
  153. }
  154. #[allow(clippy::too_many_lines)]
  155. fn parse_args(args: &[String]) -> Result<CliAction, String> {
  156. let mut model = DEFAULT_MODEL.to_string();
  157. let mut output_format = CliOutputFormat::Text;
  158. let mut permission_mode = default_permission_mode();
  159. let mut wants_version = false;
  160. let mut allowed_tool_values = Vec::new();
  161. let mut rest = Vec::new();
  162. let mut index = 0;
  163. while index < args.len() {
  164. match args[index].as_str() {
  165. "--version" | "-V" => {
  166. wants_version = true;
  167. index += 1;
  168. }
  169. "--model" => {
  170. let value = args
  171. .get(index + 1)
  172. .ok_or_else(|| "missing value for --model".to_string())?;
  173. model = resolve_model_alias(value).to_string();
  174. index += 2;
  175. }
  176. flag if flag.starts_with("--model=") => {
  177. model = resolve_model_alias(&flag[8..]).to_string();
  178. index += 1;
  179. }
  180. "--output-format" => {
  181. let value = args
  182. .get(index + 1)
  183. .ok_or_else(|| "missing value for --output-format".to_string())?;
  184. output_format = CliOutputFormat::parse(value)?;
  185. index += 2;
  186. }
  187. "--permission-mode" => {
  188. let value = args
  189. .get(index + 1)
  190. .ok_or_else(|| "missing value for --permission-mode".to_string())?;
  191. permission_mode = parse_permission_mode_arg(value)?;
  192. index += 2;
  193. }
  194. flag if flag.starts_with("--output-format=") => {
  195. output_format = CliOutputFormat::parse(&flag[16..])?;
  196. index += 1;
  197. }
  198. flag if flag.starts_with("--permission-mode=") => {
  199. permission_mode = parse_permission_mode_arg(&flag[18..])?;
  200. index += 1;
  201. }
  202. "--dangerously-skip-permissions" => {
  203. permission_mode = PermissionMode::DangerFullAccess;
  204. index += 1;
  205. }
  206. "-p" => {
  207. // Claw Code compat: -p "prompt" = one-shot prompt
  208. let prompt = args[index + 1..].join(" ");
  209. if prompt.trim().is_empty() {
  210. return Err("-p requires a prompt string".to_string());
  211. }
  212. return Ok(CliAction::Prompt {
  213. prompt,
  214. model: resolve_model_alias(&model).to_string(),
  215. output_format,
  216. allowed_tools: normalize_allowed_tools(&allowed_tool_values)?,
  217. permission_mode,
  218. });
  219. }
  220. "--print" => {
  221. // Claw Code compat: --print makes output non-interactive
  222. output_format = CliOutputFormat::Text;
  223. index += 1;
  224. }
  225. "--allowedTools" | "--allowed-tools" => {
  226. let value = args
  227. .get(index + 1)
  228. .ok_or_else(|| "missing value for --allowedTools".to_string())?;
  229. allowed_tool_values.push(value.clone());
  230. index += 2;
  231. }
  232. flag if flag.starts_with("--allowedTools=") => {
  233. allowed_tool_values.push(flag[15..].to_string());
  234. index += 1;
  235. }
  236. flag if flag.starts_with("--allowed-tools=") => {
  237. allowed_tool_values.push(flag[16..].to_string());
  238. index += 1;
  239. }
  240. other => {
  241. rest.push(other.to_string());
  242. index += 1;
  243. }
  244. }
  245. }
  246. if wants_version {
  247. return Ok(CliAction::Version);
  248. }
  249. let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?;
  250. if rest.is_empty() {
  251. return Ok(CliAction::Repl {
  252. model,
  253. allowed_tools,
  254. permission_mode,
  255. });
  256. }
  257. if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
  258. return Ok(CliAction::Help);
  259. }
  260. if rest.first().map(String::as_str) == Some("--resume") {
  261. return parse_resume_args(&rest[1..]);
  262. }
  263. match rest[0].as_str() {
  264. "dump-manifests" => Ok(CliAction::DumpManifests),
  265. "bootstrap-plan" => Ok(CliAction::BootstrapPlan),
  266. "agents" => Ok(CliAction::Agents {
  267. args: join_optional_args(&rest[1..]),
  268. }),
  269. "skills" => Ok(CliAction::Skills {
  270. args: join_optional_args(&rest[1..]),
  271. }),
  272. "system-prompt" => parse_system_prompt_args(&rest[1..]),
  273. "login" => Ok(CliAction::Login),
  274. "logout" => Ok(CliAction::Logout),
  275. "init" => Ok(CliAction::Init),
  276. "prompt" => {
  277. let prompt = rest[1..].join(" ");
  278. if prompt.trim().is_empty() {
  279. return Err("prompt subcommand requires a prompt string".to_string());
  280. }
  281. Ok(CliAction::Prompt {
  282. prompt,
  283. model,
  284. output_format,
  285. allowed_tools,
  286. permission_mode,
  287. })
  288. }
  289. other if other.starts_with('/') => parse_direct_slash_cli_action(&rest),
  290. _other => Ok(CliAction::Prompt {
  291. prompt: rest.join(" "),
  292. model,
  293. output_format,
  294. allowed_tools,
  295. permission_mode,
  296. }),
  297. }
  298. }
  299. fn join_optional_args(args: &[String]) -> Option<String> {
  300. let joined = args.join(" ");
  301. let trimmed = joined.trim();
  302. (!trimmed.is_empty()).then(|| trimmed.to_string())
  303. }
  304. fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> {
  305. let raw = rest.join(" ");
  306. match SlashCommand::parse(&raw) {
  307. Some(SlashCommand::Help) => Ok(CliAction::Help),
  308. Some(SlashCommand::Agents { args }) => Ok(CliAction::Agents { args }),
  309. Some(SlashCommand::Skills { args }) => Ok(CliAction::Skills { args }),
  310. Some(command) => Err(format!(
  311. "unsupported direct slash command outside the REPL: {command_name}",
  312. command_name = match command {
  313. SlashCommand::Unknown(name) => format!("/{name}"),
  314. _ => rest[0].clone(),
  315. }
  316. )),
  317. None => Err(format!("unknown subcommand: {}", rest[0])),
  318. }
  319. }
  320. fn resolve_model_alias(model: &str) -> &str {
  321. match model {
  322. "opus" => "claude-opus-4-6",
  323. "sonnet" => "claude-sonnet-4-6",
  324. "haiku" => "claude-haiku-4-5-20251213",
  325. _ => model,
  326. }
  327. }
  328. fn normalize_allowed_tools(values: &[String]) -> Result<Option<AllowedToolSet>, String> {
  329. current_tool_registry()?.normalize_allowed_tools(values)
  330. }
  331. fn current_tool_registry() -> Result<GlobalToolRegistry, String> {
  332. let cwd = env::current_dir().map_err(|error| error.to_string())?;
  333. let loader = ConfigLoader::default_for(&cwd);
  334. let runtime_config = loader.load().map_err(|error| error.to_string())?;
  335. let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
  336. let plugin_tools = plugin_manager
  337. .aggregated_tools()
  338. .map_err(|error| error.to_string())?;
  339. GlobalToolRegistry::with_plugin_tools(plugin_tools)
  340. }
  341. fn parse_permission_mode_arg(value: &str) -> Result<PermissionMode, String> {
  342. normalize_permission_mode(value)
  343. .ok_or_else(|| {
  344. format!(
  345. "unsupported permission mode '{value}'. Use read-only, workspace-write, or danger-full-access."
  346. )
  347. })
  348. .map(permission_mode_from_label)
  349. }
  350. fn permission_mode_from_label(mode: &str) -> PermissionMode {
  351. match mode {
  352. "read-only" => PermissionMode::ReadOnly,
  353. "workspace-write" => PermissionMode::WorkspaceWrite,
  354. "danger-full-access" => PermissionMode::DangerFullAccess,
  355. other => panic!("unsupported permission mode label: {other}"),
  356. }
  357. }
  358. fn default_permission_mode() -> PermissionMode {
  359. env::var("RUSTY_CLAUDE_PERMISSION_MODE")
  360. .ok()
  361. .as_deref()
  362. .and_then(normalize_permission_mode)
  363. .map_or(PermissionMode::DangerFullAccess, permission_mode_from_label)
  364. }
  365. fn filter_tool_specs(
  366. tool_registry: &GlobalToolRegistry,
  367. allowed_tools: Option<&AllowedToolSet>,
  368. ) -> Vec<ToolDefinition> {
  369. tool_registry.definitions(allowed_tools)
  370. }
  371. fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
  372. let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
  373. let mut date = DEFAULT_DATE.to_string();
  374. let mut index = 0;
  375. while index < args.len() {
  376. match args[index].as_str() {
  377. "--cwd" => {
  378. let value = args
  379. .get(index + 1)
  380. .ok_or_else(|| "missing value for --cwd".to_string())?;
  381. cwd = PathBuf::from(value);
  382. index += 2;
  383. }
  384. "--date" => {
  385. let value = args
  386. .get(index + 1)
  387. .ok_or_else(|| "missing value for --date".to_string())?;
  388. date.clone_from(value);
  389. index += 2;
  390. }
  391. other => return Err(format!("unknown system-prompt option: {other}")),
  392. }
  393. }
  394. Ok(CliAction::PrintSystemPrompt { cwd, date })
  395. }
  396. fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
  397. let session_path = args
  398. .first()
  399. .ok_or_else(|| "missing session path for --resume".to_string())
  400. .map(PathBuf::from)?;
  401. let commands = args[1..].to_vec();
  402. if commands
  403. .iter()
  404. .any(|command| !command.trim_start().starts_with('/'))
  405. {
  406. return Err("--resume trailing arguments must be slash commands".to_string());
  407. }
  408. Ok(CliAction::ResumeSession {
  409. session_path,
  410. commands,
  411. })
  412. }
  413. fn dump_manifests() {
  414. let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
  415. let paths = UpstreamPaths::from_workspace_dir(&workspace_dir);
  416. match extract_manifest(&paths) {
  417. Ok(manifest) => {
  418. println!("commands: {}", manifest.commands.entries().len());
  419. println!("tools: {}", manifest.tools.entries().len());
  420. println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
  421. }
  422. Err(error) => {
  423. eprintln!("failed to extract manifests: {error}");
  424. std::process::exit(1);
  425. }
  426. }
  427. }
  428. fn print_bootstrap_plan() {
  429. for phase in runtime::BootstrapPlan::claude_code_default().phases() {
  430. println!("- {phase:?}");
  431. }
  432. }
  433. fn default_oauth_config() -> OAuthConfig {
  434. OAuthConfig {
  435. client_id: String::from("9d1c250a-e61b-44d9-88ed-5944d1962f5e"),
  436. authorize_url: String::from("https://platform.claude.com/oauth/authorize"),
  437. token_url: String::from("https://platform.claude.com/v1/oauth/token"),
  438. callback_port: None,
  439. manual_redirect_url: None,
  440. scopes: vec![
  441. String::from("user:profile"),
  442. String::from("user:inference"),
  443. String::from("user:sessions:claude_code"),
  444. ],
  445. }
  446. }
  447. fn run_login() -> Result<(), Box<dyn std::error::Error>> {
  448. let cwd = env::current_dir()?;
  449. let config = ConfigLoader::default_for(&cwd).load()?;
  450. let default_oauth = default_oauth_config();
  451. let oauth = config.oauth().unwrap_or(&default_oauth);
  452. let callback_port = oauth.callback_port.unwrap_or(DEFAULT_OAUTH_CALLBACK_PORT);
  453. let redirect_uri = runtime::loopback_redirect_uri(callback_port);
  454. let pkce = generate_pkce_pair()?;
  455. let state = generate_state()?;
  456. let authorize_url =
  457. OAuthAuthorizationRequest::from_config(oauth, redirect_uri.clone(), state.clone(), &pkce)
  458. .build_url();
  459. println!("Starting Claude OAuth login...");
  460. println!("Listening for callback on {redirect_uri}");
  461. if let Err(error) = open_browser(&authorize_url) {
  462. eprintln!("warning: failed to open browser automatically: {error}");
  463. println!("Open this URL manually:\n{authorize_url}");
  464. }
  465. let callback = wait_for_oauth_callback(callback_port)?;
  466. if let Some(error) = callback.error {
  467. let description = callback
  468. .error_description
  469. .unwrap_or_else(|| "authorization failed".to_string());
  470. return Err(io::Error::other(format!("{error}: {description}")).into());
  471. }
  472. let code = callback.code.ok_or_else(|| {
  473. io::Error::new(io::ErrorKind::InvalidData, "callback did not include code")
  474. })?;
  475. let returned_state = callback.state.ok_or_else(|| {
  476. io::Error::new(io::ErrorKind::InvalidData, "callback did not include state")
  477. })?;
  478. if returned_state != state {
  479. return Err(io::Error::new(io::ErrorKind::InvalidData, "oauth state mismatch").into());
  480. }
  481. let client = AnthropicClient::from_auth(AuthSource::None).with_base_url(api::read_base_url());
  482. let exchange_request =
  483. OAuthTokenExchangeRequest::from_config(oauth, code, state, pkce.verifier, redirect_uri);
  484. let runtime = tokio::runtime::Runtime::new()?;
  485. let token_set = runtime.block_on(client.exchange_oauth_code(oauth, &exchange_request))?;
  486. save_oauth_credentials(&runtime::OAuthTokenSet {
  487. access_token: token_set.access_token,
  488. refresh_token: token_set.refresh_token,
  489. expires_at: token_set.expires_at,
  490. scopes: token_set.scopes,
  491. })?;
  492. println!("Claude OAuth login complete.");
  493. Ok(())
  494. }
  495. fn run_logout() -> Result<(), Box<dyn std::error::Error>> {
  496. clear_oauth_credentials()?;
  497. println!("Claude OAuth credentials cleared.");
  498. Ok(())
  499. }
  500. fn open_browser(url: &str) -> io::Result<()> {
  501. let commands = if cfg!(target_os = "macos") {
  502. vec![("open", vec![url])]
  503. } else if cfg!(target_os = "windows") {
  504. vec![("cmd", vec!["/C", "start", "", url])]
  505. } else {
  506. vec![("xdg-open", vec![url])]
  507. };
  508. for (program, args) in commands {
  509. match Command::new(program).args(args).spawn() {
  510. Ok(_) => return Ok(()),
  511. Err(error) if error.kind() == io::ErrorKind::NotFound => {}
  512. Err(error) => return Err(error),
  513. }
  514. }
  515. Err(io::Error::new(
  516. io::ErrorKind::NotFound,
  517. "no supported browser opener command found",
  518. ))
  519. }
  520. fn wait_for_oauth_callback(
  521. port: u16,
  522. ) -> Result<runtime::OAuthCallbackParams, Box<dyn std::error::Error>> {
  523. let listener = TcpListener::bind(("127.0.0.1", port))?;
  524. let (mut stream, _) = listener.accept()?;
  525. let mut buffer = [0_u8; 4096];
  526. let bytes_read = stream.read(&mut buffer)?;
  527. let request = String::from_utf8_lossy(&buffer[..bytes_read]);
  528. let request_line = request.lines().next().ok_or_else(|| {
  529. io::Error::new(io::ErrorKind::InvalidData, "missing callback request line")
  530. })?;
  531. let target = request_line.split_whitespace().nth(1).ok_or_else(|| {
  532. io::Error::new(
  533. io::ErrorKind::InvalidData,
  534. "missing callback request target",
  535. )
  536. })?;
  537. let callback = parse_oauth_callback_request_target(target)
  538. .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
  539. let body = if callback.error.is_some() {
  540. "Claude OAuth login failed. You can close this window."
  541. } else {
  542. "Claude OAuth login succeeded. You can close this window."
  543. };
  544. let response = format!(
  545. "HTTP/1.1 200 OK\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
  546. body.len(),
  547. body
  548. );
  549. stream.write_all(response.as_bytes())?;
  550. Ok(callback)
  551. }
  552. fn print_system_prompt(cwd: PathBuf, date: String) {
  553. match load_system_prompt(cwd, date, env::consts::OS, "unknown") {
  554. Ok(sections) => println!("{}", sections.join("\n\n")),
  555. Err(error) => {
  556. eprintln!("failed to build system prompt: {error}");
  557. std::process::exit(1);
  558. }
  559. }
  560. }
  561. fn print_version() {
  562. println!("{}", render_version_report());
  563. }
  564. fn resume_session(session_path: &Path, commands: &[String]) {
  565. let resolved_path = if session_path.exists() {
  566. session_path.to_path_buf()
  567. } else {
  568. match resolve_session_reference(&session_path.display().to_string()) {
  569. Ok(handle) => handle.path,
  570. Err(error) => {
  571. eprintln!("failed to restore session: {error}");
  572. std::process::exit(1);
  573. }
  574. }
  575. };
  576. let session = match Session::load_from_path(&resolved_path) {
  577. Ok(session) => session,
  578. Err(error) => {
  579. eprintln!("failed to restore session: {error}");
  580. std::process::exit(1);
  581. }
  582. };
  583. if commands.is_empty() {
  584. println!(
  585. "Restored session from {} ({} messages).",
  586. resolved_path.display(),
  587. session.messages.len()
  588. );
  589. return;
  590. }
  591. let mut session = session;
  592. for raw_command in commands {
  593. let Some(command) = SlashCommand::parse(raw_command) else {
  594. eprintln!("unsupported resumed command: {raw_command}");
  595. std::process::exit(2);
  596. };
  597. match run_resume_command(&resolved_path, &session, &command) {
  598. Ok(ResumeCommandOutcome {
  599. session: next_session,
  600. message,
  601. }) => {
  602. session = next_session;
  603. if let Some(message) = message {
  604. println!("{message}");
  605. }
  606. }
  607. Err(error) => {
  608. eprintln!("{error}");
  609. std::process::exit(2);
  610. }
  611. }
  612. }
  613. }
  614. #[derive(Debug, Clone)]
  615. struct ResumeCommandOutcome {
  616. session: Session,
  617. message: Option<String>,
  618. }
  619. #[derive(Debug, Clone)]
  620. struct StatusContext {
  621. cwd: PathBuf,
  622. session_path: Option<PathBuf>,
  623. loaded_config_files: usize,
  624. discovered_config_files: usize,
  625. memory_file_count: usize,
  626. project_root: Option<PathBuf>,
  627. git_branch: Option<String>,
  628. sandbox_status: runtime::SandboxStatus,
  629. }
  630. #[derive(Debug, Clone, Copy)]
  631. struct StatusUsage {
  632. message_count: usize,
  633. turns: u32,
  634. latest: TokenUsage,
  635. cumulative: TokenUsage,
  636. estimated_tokens: usize,
  637. }
  638. fn format_model_report(model: &str, message_count: usize, turns: u32) -> String {
  639. format!(
  640. "Model
  641. Current model {model}
  642. Session messages {message_count}
  643. Session turns {turns}
  644. Usage
  645. Inspect current model with /model
  646. Switch models with /model <name>"
  647. )
  648. }
  649. fn format_model_switch_report(previous: &str, next: &str, message_count: usize) -> String {
  650. format!(
  651. "Model updated
  652. Previous {previous}
  653. Current {next}
  654. Preserved msgs {message_count}"
  655. )
  656. }
  657. fn format_permissions_report(mode: &str) -> String {
  658. let modes = [
  659. ("read-only", "Read/search tools only", mode == "read-only"),
  660. (
  661. "workspace-write",
  662. "Edit files inside the workspace",
  663. mode == "workspace-write",
  664. ),
  665. (
  666. "danger-full-access",
  667. "Unrestricted tool access",
  668. mode == "danger-full-access",
  669. ),
  670. ]
  671. .into_iter()
  672. .map(|(name, description, is_current)| {
  673. let marker = if is_current {
  674. "● current"
  675. } else {
  676. "○ available"
  677. };
  678. format!(" {name:<18} {marker:<11} {description}")
  679. })
  680. .collect::<Vec<_>>()
  681. .join(
  682. "
  683. ",
  684. );
  685. format!(
  686. "Permissions
  687. Active mode {mode}
  688. Mode status live session default
  689. Modes
  690. {modes}
  691. Usage
  692. Inspect current mode with /permissions
  693. Switch modes with /permissions <mode>"
  694. )
  695. }
  696. fn format_permissions_switch_report(previous: &str, next: &str) -> String {
  697. format!(
  698. "Permissions updated
  699. Result mode switched
  700. Previous mode {previous}
  701. Active mode {next}
  702. Applies to subsequent tool calls
  703. Usage /permissions to inspect current mode"
  704. )
  705. }
  706. fn format_cost_report(usage: TokenUsage) -> String {
  707. format!(
  708. "Cost
  709. Input tokens {}
  710. Output tokens {}
  711. Cache create {}
  712. Cache read {}
  713. Total tokens {}",
  714. usage.input_tokens,
  715. usage.output_tokens,
  716. usage.cache_creation_input_tokens,
  717. usage.cache_read_input_tokens,
  718. usage.total_tokens(),
  719. )
  720. }
  721. fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String {
  722. format!(
  723. "Session resumed
  724. Session file {session_path}
  725. Messages {message_count}
  726. Turns {turns}"
  727. )
  728. }
  729. fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String {
  730. if skipped {
  731. format!(
  732. "Compact
  733. Result skipped
  734. Reason session below compaction threshold
  735. Messages kept {resulting_messages}"
  736. )
  737. } else {
  738. format!(
  739. "Compact
  740. Result compacted
  741. Messages removed {removed}
  742. Messages kept {resulting_messages}"
  743. )
  744. }
  745. }
  746. fn format_auto_compaction_notice(removed: usize) -> String {
  747. format!("[auto-compacted: removed {removed} messages]")
  748. }
  749. fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<String>) {
  750. parse_git_status_metadata_for(
  751. &env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
  752. status,
  753. )
  754. }
  755. fn parse_git_status_branch(status: Option<&str>) -> Option<String> {
  756. let status = status?;
  757. let first_line = status.lines().next()?;
  758. let line = first_line.strip_prefix("## ")?;
  759. if line.starts_with("HEAD") {
  760. return Some("detached HEAD".to_string());
  761. }
  762. let branch = line.split(['.', ' ']).next().unwrap_or_default().trim();
  763. if branch.is_empty() {
  764. None
  765. } else {
  766. Some(branch.to_string())
  767. }
  768. }
  769. fn resolve_git_branch_for(cwd: &Path) -> Option<String> {
  770. let branch = run_git_capture_in(cwd, &["branch", "--show-current"])?;
  771. let branch = branch.trim();
  772. if !branch.is_empty() {
  773. return Some(branch.to_string());
  774. }
  775. let fallback = run_git_capture_in(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])?;
  776. let fallback = fallback.trim();
  777. if fallback.is_empty() {
  778. None
  779. } else if fallback == "HEAD" {
  780. Some("detached HEAD".to_string())
  781. } else {
  782. Some(fallback.to_string())
  783. }
  784. }
  785. fn run_git_capture_in(cwd: &Path, args: &[&str]) -> Option<String> {
  786. let output = std::process::Command::new("git")
  787. .args(args)
  788. .current_dir(cwd)
  789. .output()
  790. .ok()?;
  791. if !output.status.success() {
  792. return None;
  793. }
  794. String::from_utf8(output.stdout).ok()
  795. }
  796. fn find_git_root_in(cwd: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
  797. let output = std::process::Command::new("git")
  798. .args(["rev-parse", "--show-toplevel"])
  799. .current_dir(cwd)
  800. .output()?;
  801. if !output.status.success() {
  802. return Err("not a git repository".into());
  803. }
  804. let path = String::from_utf8(output.stdout)?.trim().to_string();
  805. if path.is_empty() {
  806. return Err("empty git root".into());
  807. }
  808. Ok(PathBuf::from(path))
  809. }
  810. fn parse_git_status_metadata_for(
  811. cwd: &Path,
  812. status: Option<&str>,
  813. ) -> (Option<PathBuf>, Option<String>) {
  814. let branch = resolve_git_branch_for(cwd).or_else(|| parse_git_status_branch(status));
  815. let project_root = find_git_root_in(cwd).ok();
  816. (project_root, branch)
  817. }
  818. #[allow(clippy::too_many_lines)]
  819. fn run_resume_command(
  820. session_path: &Path,
  821. session: &Session,
  822. command: &SlashCommand,
  823. ) -> Result<ResumeCommandOutcome, Box<dyn std::error::Error>> {
  824. match command {
  825. SlashCommand::Help => Ok(ResumeCommandOutcome {
  826. session: session.clone(),
  827. message: Some(render_repl_help()),
  828. }),
  829. SlashCommand::Compact => {
  830. let result = runtime::compact_session(
  831. session,
  832. CompactionConfig {
  833. max_estimated_tokens: 0,
  834. ..CompactionConfig::default()
  835. },
  836. );
  837. let removed = result.removed_message_count;
  838. let kept = result.compacted_session.messages.len();
  839. let skipped = removed == 0;
  840. result.compacted_session.save_to_path(session_path)?;
  841. Ok(ResumeCommandOutcome {
  842. session: result.compacted_session,
  843. message: Some(format_compact_report(removed, kept, skipped)),
  844. })
  845. }
  846. SlashCommand::Clear { confirm } => {
  847. if !confirm {
  848. return Ok(ResumeCommandOutcome {
  849. session: session.clone(),
  850. message: Some(
  851. "clear: confirmation required; rerun with /clear --confirm".to_string(),
  852. ),
  853. });
  854. }
  855. let cleared = Session::new();
  856. cleared.save_to_path(session_path)?;
  857. Ok(ResumeCommandOutcome {
  858. session: cleared,
  859. message: Some(format!(
  860. "Cleared resumed session file {}.",
  861. session_path.display()
  862. )),
  863. })
  864. }
  865. SlashCommand::Status => {
  866. let tracker = UsageTracker::from_session(session);
  867. let usage = tracker.cumulative_usage();
  868. Ok(ResumeCommandOutcome {
  869. session: session.clone(),
  870. message: Some(format_status_report(
  871. "restored-session",
  872. StatusUsage {
  873. message_count: session.messages.len(),
  874. turns: tracker.turns(),
  875. latest: tracker.current_turn_usage(),
  876. cumulative: usage,
  877. estimated_tokens: 0,
  878. },
  879. default_permission_mode().as_str(),
  880. &status_context(Some(session_path))?,
  881. )),
  882. })
  883. }
  884. SlashCommand::Sandbox => {
  885. let cwd = env::current_dir()?;
  886. let loader = ConfigLoader::default_for(&cwd);
  887. let runtime_config = loader.load()?;
  888. Ok(ResumeCommandOutcome {
  889. session: session.clone(),
  890. message: Some(format_sandbox_report(&resolve_sandbox_status(
  891. runtime_config.sandbox(),
  892. &cwd,
  893. ))),
  894. })
  895. }
  896. SlashCommand::Cost => {
  897. let usage = UsageTracker::from_session(session).cumulative_usage();
  898. Ok(ResumeCommandOutcome {
  899. session: session.clone(),
  900. message: Some(format_cost_report(usage)),
  901. })
  902. }
  903. SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
  904. session: session.clone(),
  905. message: Some(render_config_report(section.as_deref())?),
  906. }),
  907. SlashCommand::Memory => Ok(ResumeCommandOutcome {
  908. session: session.clone(),
  909. message: Some(render_memory_report()?),
  910. }),
  911. SlashCommand::Init => Ok(ResumeCommandOutcome {
  912. session: session.clone(),
  913. message: Some(init_claude_md()?),
  914. }),
  915. SlashCommand::Diff => Ok(ResumeCommandOutcome {
  916. session: session.clone(),
  917. message: Some(render_diff_report_for(
  918. session_path.parent().unwrap_or_else(|| Path::new(".")),
  919. )?),
  920. }),
  921. SlashCommand::Version => Ok(ResumeCommandOutcome {
  922. session: session.clone(),
  923. message: Some(render_version_report()),
  924. }),
  925. SlashCommand::Export { path } => {
  926. let export_path = resolve_export_path(path.as_deref(), session)?;
  927. fs::write(&export_path, render_export_text(session))?;
  928. Ok(ResumeCommandOutcome {
  929. session: session.clone(),
  930. message: Some(format!(
  931. "Export\n Result wrote transcript\n File {}\n Messages {}",
  932. export_path.display(),
  933. session.messages.len(),
  934. )),
  935. })
  936. }
  937. SlashCommand::Agents { args } => {
  938. let cwd = env::current_dir()?;
  939. Ok(ResumeCommandOutcome {
  940. session: session.clone(),
  941. message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?),
  942. })
  943. }
  944. SlashCommand::Skills { args } => {
  945. let cwd = env::current_dir()?;
  946. Ok(ResumeCommandOutcome {
  947. session: session.clone(),
  948. message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?),
  949. })
  950. }
  951. SlashCommand::Bughunter { .. }
  952. | SlashCommand::Commit
  953. | SlashCommand::Pr { .. }
  954. | SlashCommand::Issue { .. }
  955. | SlashCommand::Ultraplan { .. }
  956. | SlashCommand::Teleport { .. }
  957. | SlashCommand::DebugToolCall
  958. | SlashCommand::Resume { .. }
  959. | SlashCommand::Model { .. }
  960. | SlashCommand::Permissions { .. }
  961. | SlashCommand::Session { .. }
  962. | SlashCommand::Plugins { .. }
  963. | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
  964. }
  965. }
  966. fn run_repl(
  967. model: String,
  968. allowed_tools: Option<AllowedToolSet>,
  969. permission_mode: PermissionMode,
  970. ) -> Result<(), Box<dyn std::error::Error>> {
  971. let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?;
  972. let mut editor = input::LineEditor::new("> ", slash_command_completion_candidates());
  973. println!("{}", cli.startup_banner());
  974. loop {
  975. match editor.read_line()? {
  976. input::ReadOutcome::Submit(input) => {
  977. let trimmed = input.trim().to_string();
  978. if trimmed.is_empty() {
  979. continue;
  980. }
  981. if matches!(trimmed.as_str(), "/exit" | "/quit") {
  982. cli.persist_session()?;
  983. break;
  984. }
  985. if let Some(command) = SlashCommand::parse(&trimmed) {
  986. if cli.handle_repl_command(command)? {
  987. cli.persist_session()?;
  988. }
  989. continue;
  990. }
  991. editor.push_history(input);
  992. cli.run_turn(&trimmed)?;
  993. }
  994. input::ReadOutcome::Cancel => {}
  995. input::ReadOutcome::Exit => {
  996. cli.persist_session()?;
  997. break;
  998. }
  999. }
  1000. }
  1001. Ok(())
  1002. }
  1003. #[derive(Debug, Clone)]
  1004. struct SessionHandle {
  1005. id: String,
  1006. path: PathBuf,
  1007. }
  1008. #[derive(Debug, Clone)]
  1009. struct ManagedSessionSummary {
  1010. id: String,
  1011. path: PathBuf,
  1012. modified_epoch_secs: u64,
  1013. message_count: usize,
  1014. parent_session_id: Option<String>,
  1015. branch_name: Option<String>,
  1016. }
  1017. struct LiveCli {
  1018. model: String,
  1019. allowed_tools: Option<AllowedToolSet>,
  1020. permission_mode: PermissionMode,
  1021. system_prompt: Vec<String>,
  1022. runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
  1023. session: SessionHandle,
  1024. }
  1025. struct HookAbortMonitor {
  1026. stop_tx: Option<Sender<()>>,
  1027. join_handle: Option<JoinHandle<()>>,
  1028. }
  1029. impl HookAbortMonitor {
  1030. fn spawn(abort_signal: runtime::HookAbortSignal) -> Self {
  1031. Self::spawn_with_waiter(abort_signal, move |stop_rx, abort_signal| {
  1032. let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
  1033. .enable_all()
  1034. .build()
  1035. else {
  1036. return;
  1037. };
  1038. runtime.block_on(async move {
  1039. let wait_for_stop = tokio::task::spawn_blocking(move || {
  1040. let _ = stop_rx.recv();
  1041. });
  1042. tokio::select! {
  1043. result = tokio::signal::ctrl_c() => {
  1044. if result.is_ok() {
  1045. abort_signal.abort();
  1046. }
  1047. }
  1048. _ = wait_for_stop => {}
  1049. }
  1050. });
  1051. })
  1052. }
  1053. fn spawn_with_waiter<F>(abort_signal: runtime::HookAbortSignal, wait_for_interrupt: F) -> Self
  1054. where
  1055. F: FnOnce(Receiver<()>, runtime::HookAbortSignal) + Send + 'static,
  1056. {
  1057. let (stop_tx, stop_rx) = mpsc::channel();
  1058. let join_handle = thread::spawn(move || wait_for_interrupt(stop_rx, abort_signal));
  1059. Self {
  1060. stop_tx: Some(stop_tx),
  1061. join_handle: Some(join_handle),
  1062. }
  1063. }
  1064. fn stop(mut self) {
  1065. if let Some(stop_tx) = self.stop_tx.take() {
  1066. let _ = stop_tx.send(());
  1067. }
  1068. if let Some(join_handle) = self.join_handle.take() {
  1069. let _ = join_handle.join();
  1070. }
  1071. }
  1072. }
  1073. impl LiveCli {
  1074. fn new(
  1075. model: String,
  1076. enable_tools: bool,
  1077. allowed_tools: Option<AllowedToolSet>,
  1078. permission_mode: PermissionMode,
  1079. ) -> Result<Self, Box<dyn std::error::Error>> {
  1080. let system_prompt = build_system_prompt()?;
  1081. let session_state = Session::new();
  1082. let session = create_managed_session_handle(&session_state.session_id)?;
  1083. let runtime = build_runtime(
  1084. session_state.with_persistence_path(session.path.clone()),
  1085. &session.id,
  1086. model.clone(),
  1087. system_prompt.clone(),
  1088. enable_tools,
  1089. true,
  1090. allowed_tools.clone(),
  1091. permission_mode,
  1092. None,
  1093. )?;
  1094. let cli = Self {
  1095. model,
  1096. allowed_tools,
  1097. permission_mode,
  1098. system_prompt,
  1099. runtime,
  1100. session,
  1101. };
  1102. cli.persist_session()?;
  1103. Ok(cli)
  1104. }
  1105. fn startup_banner(&self) -> String {
  1106. let cwd = env::current_dir().map_or_else(
  1107. |_| "<unknown>".to_string(),
  1108. |path| path.display().to_string(),
  1109. );
  1110. format!(
  1111. "\x1b[38;5;196m\
  1112. ██████╗██╗ █████╗ ██╗ ██╗\n\
  1113. ██╔════╝██║ ██╔══██╗██║ ██║\n\
  1114. ██║ ██║ ███████║██║ █╗ ██║\n\
  1115. ██║ ██║ ██╔══██║██║███╗██║\n\
  1116. ╚██████╗███████╗██║ ██║╚███╔███╔╝\n\
  1117. ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\x1b[0m \x1b[38;5;208mCode\x1b[0m 🦞\n\n\
  1118. \x1b[2mModel\x1b[0m {}\n\
  1119. \x1b[2mPermissions\x1b[0m {}\n\
  1120. \x1b[2mDirectory\x1b[0m {}\n\
  1121. \x1b[2mSession\x1b[0m {}\n\n\
  1122. Type \x1b[1m/help\x1b[0m for commands · \x1b[2mShift+Enter\x1b[0m for newline",
  1123. self.model,
  1124. self.permission_mode.as_str(),
  1125. cwd,
  1126. self.session.id,
  1127. )
  1128. }
  1129. fn prepare_turn_runtime(
  1130. &self,
  1131. emit_output: bool,
  1132. ) -> Result<
  1133. (
  1134. ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
  1135. HookAbortMonitor,
  1136. ),
  1137. Box<dyn std::error::Error>,
  1138. > {
  1139. let hook_abort_signal = runtime::HookAbortSignal::new();
  1140. let runtime = build_runtime(
  1141. self.runtime.session().clone(),
  1142. &self.session.id,
  1143. self.model.clone(),
  1144. self.system_prompt.clone(),
  1145. true,
  1146. emit_output,
  1147. self.allowed_tools.clone(),
  1148. self.permission_mode,
  1149. None,
  1150. )?
  1151. .with_hook_abort_signal(hook_abort_signal.clone());
  1152. let hook_abort_monitor = HookAbortMonitor::spawn(hook_abort_signal);
  1153. Ok((runtime, hook_abort_monitor))
  1154. }
  1155. fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
  1156. let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?;
  1157. let mut spinner = Spinner::new();
  1158. let mut stdout = io::stdout();
  1159. spinner.tick(
  1160. "🦀 Thinking...",
  1161. TerminalRenderer::new().color_theme(),
  1162. &mut stdout,
  1163. )?;
  1164. let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
  1165. let result = runtime.run_turn(input, Some(&mut permission_prompter));
  1166. hook_abort_monitor.stop();
  1167. self.runtime = runtime;
  1168. match result {
  1169. Ok(summary) => {
  1170. spinner.finish(
  1171. "✨ Done",
  1172. TerminalRenderer::new().color_theme(),
  1173. &mut stdout,
  1174. )?;
  1175. println!();
  1176. if let Some(event) = summary.auto_compaction {
  1177. println!(
  1178. "{}",
  1179. format_auto_compaction_notice(event.removed_message_count)
  1180. );
  1181. }
  1182. self.persist_session()?;
  1183. Ok(())
  1184. }
  1185. Err(error) => {
  1186. spinner.fail(
  1187. "❌ Request failed",
  1188. TerminalRenderer::new().color_theme(),
  1189. &mut stdout,
  1190. )?;
  1191. Err(Box::new(error))
  1192. }
  1193. }
  1194. }
  1195. fn run_turn_with_output(
  1196. &mut self,
  1197. input: &str,
  1198. output_format: CliOutputFormat,
  1199. ) -> Result<(), Box<dyn std::error::Error>> {
  1200. match output_format {
  1201. CliOutputFormat::Text => self.run_turn(input),
  1202. CliOutputFormat::Json => self.run_prompt_json(input),
  1203. }
  1204. }
  1205. fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
  1206. let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
  1207. let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
  1208. let result = runtime.run_turn(input, Some(&mut permission_prompter));
  1209. hook_abort_monitor.stop();
  1210. let summary = result?;
  1211. self.runtime = runtime;
  1212. self.persist_session()?;
  1213. println!(
  1214. "{}",
  1215. json!({
  1216. "message": final_assistant_text(&summary),
  1217. "model": self.model,
  1218. "iterations": summary.iterations,
  1219. "auto_compaction": summary.auto_compaction.map(|event| json!({
  1220. "removed_messages": event.removed_message_count,
  1221. "notice": format_auto_compaction_notice(event.removed_message_count),
  1222. })),
  1223. "tool_uses": collect_tool_uses(&summary),
  1224. "tool_results": collect_tool_results(&summary),
  1225. "prompt_cache_events": collect_prompt_cache_events(&summary),
  1226. "usage": {
  1227. "input_tokens": summary.usage.input_tokens,
  1228. "output_tokens": summary.usage.output_tokens,
  1229. "cache_creation_input_tokens": summary.usage.cache_creation_input_tokens,
  1230. "cache_read_input_tokens": summary.usage.cache_read_input_tokens,
  1231. }
  1232. })
  1233. );
  1234. Ok(())
  1235. }
  1236. fn handle_repl_command(
  1237. &mut self,
  1238. command: SlashCommand,
  1239. ) -> Result<bool, Box<dyn std::error::Error>> {
  1240. Ok(match command {
  1241. SlashCommand::Help => {
  1242. println!("{}", render_repl_help());
  1243. false
  1244. }
  1245. SlashCommand::Status => {
  1246. self.print_status();
  1247. false
  1248. }
  1249. SlashCommand::Bughunter { scope } => {
  1250. self.run_bughunter(scope.as_deref())?;
  1251. false
  1252. }
  1253. SlashCommand::Commit => {
  1254. self.run_commit()?;
  1255. true
  1256. }
  1257. SlashCommand::Pr { context } => {
  1258. self.run_pr(context.as_deref())?;
  1259. false
  1260. }
  1261. SlashCommand::Issue { context } => {
  1262. self.run_issue(context.as_deref())?;
  1263. false
  1264. }
  1265. SlashCommand::Ultraplan { task } => {
  1266. self.run_ultraplan(task.as_deref())?;
  1267. false
  1268. }
  1269. SlashCommand::Teleport { target } => {
  1270. self.run_teleport(target.as_deref())?;
  1271. false
  1272. }
  1273. SlashCommand::DebugToolCall => {
  1274. self.run_debug_tool_call()?;
  1275. false
  1276. }
  1277. SlashCommand::Sandbox => {
  1278. Self::print_sandbox_status();
  1279. false
  1280. }
  1281. SlashCommand::Compact => {
  1282. self.compact()?;
  1283. false
  1284. }
  1285. SlashCommand::Model { model } => self.set_model(model)?,
  1286. SlashCommand::Permissions { mode } => self.set_permissions(mode)?,
  1287. SlashCommand::Clear { confirm } => self.clear_session(confirm)?,
  1288. SlashCommand::Cost => {
  1289. self.print_cost();
  1290. false
  1291. }
  1292. SlashCommand::Resume { session_path } => self.resume_session(session_path)?,
  1293. SlashCommand::Config { section } => {
  1294. Self::print_config(section.as_deref())?;
  1295. false
  1296. }
  1297. SlashCommand::Memory => {
  1298. Self::print_memory()?;
  1299. false
  1300. }
  1301. SlashCommand::Init => {
  1302. run_init()?;
  1303. false
  1304. }
  1305. SlashCommand::Diff => {
  1306. Self::print_diff()?;
  1307. false
  1308. }
  1309. SlashCommand::Version => {
  1310. Self::print_version();
  1311. false
  1312. }
  1313. SlashCommand::Export { path } => {
  1314. self.export_session(path.as_deref())?;
  1315. false
  1316. }
  1317. SlashCommand::Session { action, target } => {
  1318. self.handle_session_command(action.as_deref(), target.as_deref())?
  1319. }
  1320. SlashCommand::Plugins { action, target } => {
  1321. self.handle_plugins_command(action.as_deref(), target.as_deref())?
  1322. }
  1323. SlashCommand::Agents { args } => {
  1324. Self::print_agents(args.as_deref())?;
  1325. false
  1326. }
  1327. SlashCommand::Skills { args } => {
  1328. Self::print_skills(args.as_deref())?;
  1329. false
  1330. }
  1331. SlashCommand::Unknown(name) => {
  1332. eprintln!("unknown slash command: /{name}");
  1333. false
  1334. }
  1335. })
  1336. }
  1337. fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
  1338. self.runtime.session().save_to_path(&self.session.path)?;
  1339. Ok(())
  1340. }
  1341. fn print_status(&self) {
  1342. let cumulative = self.runtime.usage().cumulative_usage();
  1343. let latest = self.runtime.usage().current_turn_usage();
  1344. println!(
  1345. "{}",
  1346. format_status_report(
  1347. &self.model,
  1348. StatusUsage {
  1349. message_count: self.runtime.session().messages.len(),
  1350. turns: self.runtime.usage().turns(),
  1351. latest,
  1352. cumulative,
  1353. estimated_tokens: self.runtime.estimated_tokens(),
  1354. },
  1355. self.permission_mode.as_str(),
  1356. &status_context(Some(&self.session.path)).expect("status context should load"),
  1357. )
  1358. );
  1359. }
  1360. fn print_sandbox_status() {
  1361. let cwd = env::current_dir().expect("current dir");
  1362. let loader = ConfigLoader::default_for(&cwd);
  1363. let runtime_config = loader
  1364. .load()
  1365. .unwrap_or_else(|_| runtime::RuntimeConfig::empty());
  1366. println!(
  1367. "{}",
  1368. format_sandbox_report(&resolve_sandbox_status(runtime_config.sandbox(), &cwd))
  1369. );
  1370. }
  1371. fn set_model(&mut self, model: Option<String>) -> Result<bool, Box<dyn std::error::Error>> {
  1372. let Some(model) = model else {
  1373. println!(
  1374. "{}",
  1375. format_model_report(
  1376. &self.model,
  1377. self.runtime.session().messages.len(),
  1378. self.runtime.usage().turns(),
  1379. )
  1380. );
  1381. return Ok(false);
  1382. };
  1383. let model = resolve_model_alias(&model).to_string();
  1384. if model == self.model {
  1385. println!(
  1386. "{}",
  1387. format_model_report(
  1388. &self.model,
  1389. self.runtime.session().messages.len(),
  1390. self.runtime.usage().turns(),
  1391. )
  1392. );
  1393. return Ok(false);
  1394. }
  1395. let previous = self.model.clone();
  1396. let session = self.runtime.session().clone();
  1397. let message_count = session.messages.len();
  1398. self.runtime = build_runtime(
  1399. session,
  1400. &self.session.id,
  1401. model.clone(),
  1402. self.system_prompt.clone(),
  1403. true,
  1404. true,
  1405. self.allowed_tools.clone(),
  1406. self.permission_mode,
  1407. None,
  1408. )?;
  1409. self.model.clone_from(&model);
  1410. println!(
  1411. "{}",
  1412. format_model_switch_report(&previous, &model, message_count)
  1413. );
  1414. Ok(true)
  1415. }
  1416. fn set_permissions(
  1417. &mut self,
  1418. mode: Option<String>,
  1419. ) -> Result<bool, Box<dyn std::error::Error>> {
  1420. let Some(mode) = mode else {
  1421. println!(
  1422. "{}",
  1423. format_permissions_report(self.permission_mode.as_str())
  1424. );
  1425. return Ok(false);
  1426. };
  1427. let normalized = normalize_permission_mode(&mode).ok_or_else(|| {
  1428. format!(
  1429. "unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access."
  1430. )
  1431. })?;
  1432. if normalized == self.permission_mode.as_str() {
  1433. println!("{}", format_permissions_report(normalized));
  1434. return Ok(false);
  1435. }
  1436. let previous = self.permission_mode.as_str().to_string();
  1437. let session = self.runtime.session().clone();
  1438. self.permission_mode = permission_mode_from_label(normalized);
  1439. self.runtime = build_runtime(
  1440. session,
  1441. &self.session.id,
  1442. self.model.clone(),
  1443. self.system_prompt.clone(),
  1444. true,
  1445. true,
  1446. self.allowed_tools.clone(),
  1447. self.permission_mode,
  1448. None,
  1449. )?;
  1450. println!(
  1451. "{}",
  1452. format_permissions_switch_report(&previous, normalized)
  1453. );
  1454. Ok(true)
  1455. }
  1456. fn clear_session(&mut self, confirm: bool) -> Result<bool, Box<dyn std::error::Error>> {
  1457. if !confirm {
  1458. println!(
  1459. "clear: confirmation required; run /clear --confirm to start a fresh session."
  1460. );
  1461. return Ok(false);
  1462. }
  1463. let session_state = Session::new();
  1464. self.session = create_managed_session_handle(&session_state.session_id)?;
  1465. self.runtime = build_runtime(
  1466. session_state.with_persistence_path(self.session.path.clone()),
  1467. &self.session.id,
  1468. self.model.clone(),
  1469. self.system_prompt.clone(),
  1470. true,
  1471. true,
  1472. self.allowed_tools.clone(),
  1473. self.permission_mode,
  1474. None,
  1475. )?;
  1476. println!(
  1477. "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}",
  1478. self.model,
  1479. self.permission_mode.as_str(),
  1480. self.session.id,
  1481. );
  1482. Ok(true)
  1483. }
  1484. fn print_cost(&self) {
  1485. let cumulative = self.runtime.usage().cumulative_usage();
  1486. println!("{}", format_cost_report(cumulative));
  1487. }
  1488. fn resume_session(
  1489. &mut self,
  1490. session_path: Option<String>,
  1491. ) -> Result<bool, Box<dyn std::error::Error>> {
  1492. let Some(session_ref) = session_path else {
  1493. println!("Usage: /resume <session-path>");
  1494. return Ok(false);
  1495. };
  1496. let handle = resolve_session_reference(&session_ref)?;
  1497. let session = Session::load_from_path(&handle.path)?;
  1498. let message_count = session.messages.len();
  1499. let session_id = session.session_id.clone();
  1500. self.runtime = build_runtime(
  1501. session,
  1502. &handle.id,
  1503. self.model.clone(),
  1504. self.system_prompt.clone(),
  1505. true,
  1506. true,
  1507. self.allowed_tools.clone(),
  1508. self.permission_mode,
  1509. None,
  1510. )?;
  1511. self.session = SessionHandle {
  1512. id: session_id,
  1513. path: handle.path,
  1514. };
  1515. println!(
  1516. "{}",
  1517. format_resume_report(
  1518. &self.session.path.display().to_string(),
  1519. message_count,
  1520. self.runtime.usage().turns(),
  1521. )
  1522. );
  1523. Ok(true)
  1524. }
  1525. fn print_config(section: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
  1526. println!("{}", render_config_report(section)?);
  1527. Ok(())
  1528. }
  1529. fn print_memory() -> Result<(), Box<dyn std::error::Error>> {
  1530. println!("{}", render_memory_report()?);
  1531. Ok(())
  1532. }
  1533. fn print_agents(args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
  1534. let cwd = env::current_dir()?;
  1535. println!("{}", handle_agents_slash_command(args, &cwd)?);
  1536. Ok(())
  1537. }
  1538. fn print_skills(args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
  1539. let cwd = env::current_dir()?;
  1540. println!("{}", handle_skills_slash_command(args, &cwd)?);
  1541. Ok(())
  1542. }
  1543. fn print_diff() -> Result<(), Box<dyn std::error::Error>> {
  1544. println!("{}", render_diff_report()?);
  1545. Ok(())
  1546. }
  1547. fn print_version() {
  1548. println!("{}", render_version_report());
  1549. }
  1550. fn export_session(
  1551. &self,
  1552. requested_path: Option<&str>,
  1553. ) -> Result<(), Box<dyn std::error::Error>> {
  1554. let export_path = resolve_export_path(requested_path, self.runtime.session())?;
  1555. fs::write(&export_path, render_export_text(self.runtime.session()))?;
  1556. println!(
  1557. "Export\n Result wrote transcript\n File {}\n Messages {}",
  1558. export_path.display(),
  1559. self.runtime.session().messages.len(),
  1560. );
  1561. Ok(())
  1562. }
  1563. fn handle_session_command(
  1564. &mut self,
  1565. action: Option<&str>,
  1566. target: Option<&str>,
  1567. ) -> Result<bool, Box<dyn std::error::Error>> {
  1568. match action {
  1569. None | Some("list") => {
  1570. println!("{}", render_session_list(&self.session.id)?);
  1571. Ok(false)
  1572. }
  1573. Some("switch") => {
  1574. let Some(target) = target else {
  1575. println!("Usage: /session switch <session-id>");
  1576. return Ok(false);
  1577. };
  1578. let handle = resolve_session_reference(target)?;
  1579. let session = Session::load_from_path(&handle.path)?;
  1580. let message_count = session.messages.len();
  1581. let session_id = session.session_id.clone();
  1582. self.runtime = build_runtime(
  1583. session,
  1584. &handle.id,
  1585. self.model.clone(),
  1586. self.system_prompt.clone(),
  1587. true,
  1588. true,
  1589. self.allowed_tools.clone(),
  1590. self.permission_mode,
  1591. None,
  1592. )?;
  1593. self.session = SessionHandle {
  1594. id: session_id,
  1595. path: handle.path,
  1596. };
  1597. println!(
  1598. "Session switched\n Active session {}\n File {}\n Messages {}",
  1599. self.session.id,
  1600. self.session.path.display(),
  1601. message_count,
  1602. );
  1603. Ok(true)
  1604. }
  1605. Some("fork") => {
  1606. let forked = self.runtime.fork_session(target.map(ToOwned::to_owned));
  1607. let parent_session_id = self.session.id.clone();
  1608. let handle = create_managed_session_handle(&forked.session_id)?;
  1609. let branch_name = forked
  1610. .fork
  1611. .as_ref()
  1612. .and_then(|fork| fork.branch_name.clone());
  1613. let forked = forked.with_persistence_path(handle.path.clone());
  1614. let message_count = forked.messages.len();
  1615. forked.save_to_path(&handle.path)?;
  1616. self.runtime = build_runtime(
  1617. forked,
  1618. &handle.id,
  1619. self.model.clone(),
  1620. self.system_prompt.clone(),
  1621. true,
  1622. true,
  1623. self.allowed_tools.clone(),
  1624. self.permission_mode,
  1625. None,
  1626. )?;
  1627. self.session = handle;
  1628. println!(
  1629. "Session forked\n Parent session {}\n Active session {}\n Branch {}\n File {}\n Messages {}",
  1630. parent_session_id,
  1631. self.session.id,
  1632. branch_name.as_deref().unwrap_or("(unnamed)"),
  1633. self.session.path.display(),
  1634. message_count,
  1635. );
  1636. Ok(true)
  1637. }
  1638. Some(other) => {
  1639. println!(
  1640. "Unknown /session action '{other}'. Use /session list, /session switch <session-id>, or /session fork [branch-name]."
  1641. );
  1642. Ok(false)
  1643. }
  1644. }
  1645. }
  1646. fn handle_plugins_command(
  1647. &mut self,
  1648. action: Option<&str>,
  1649. target: Option<&str>,
  1650. ) -> Result<bool, Box<dyn std::error::Error>> {
  1651. let cwd = env::current_dir()?;
  1652. let loader = ConfigLoader::default_for(&cwd);
  1653. let runtime_config = loader.load()?;
  1654. let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config);
  1655. let result = handle_plugins_slash_command(action, target, &mut manager)?;
  1656. println!("{}", result.message);
  1657. if result.reload_runtime {
  1658. self.reload_runtime_features()?;
  1659. }
  1660. Ok(false)
  1661. }
  1662. fn reload_runtime_features(&mut self) -> Result<(), Box<dyn std::error::Error>> {
  1663. self.runtime = build_runtime(
  1664. self.runtime.session().clone(),
  1665. &self.session.id,
  1666. self.model.clone(),
  1667. self.system_prompt.clone(),
  1668. true,
  1669. true,
  1670. self.allowed_tools.clone(),
  1671. self.permission_mode,
  1672. None,
  1673. )?;
  1674. self.persist_session()
  1675. }
  1676. fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
  1677. let result = self.runtime.compact(CompactionConfig::default());
  1678. let removed = result.removed_message_count;
  1679. let kept = result.compacted_session.messages.len();
  1680. let skipped = removed == 0;
  1681. self.runtime = build_runtime(
  1682. result.compacted_session,
  1683. &self.session.id,
  1684. self.model.clone(),
  1685. self.system_prompt.clone(),
  1686. true,
  1687. true,
  1688. self.allowed_tools.clone(),
  1689. self.permission_mode,
  1690. None,
  1691. )?;
  1692. self.persist_session()?;
  1693. println!("{}", format_compact_report(removed, kept, skipped));
  1694. Ok(())
  1695. }
  1696. fn run_internal_prompt_text_with_progress(
  1697. &self,
  1698. prompt: &str,
  1699. enable_tools: bool,
  1700. progress: Option<InternalPromptProgressReporter>,
  1701. ) -> Result<String, Box<dyn std::error::Error>> {
  1702. let session = self.runtime.session().clone();
  1703. let mut runtime = build_runtime(
  1704. session,
  1705. &self.session.id,
  1706. self.model.clone(),
  1707. self.system_prompt.clone(),
  1708. enable_tools,
  1709. false,
  1710. self.allowed_tools.clone(),
  1711. self.permission_mode,
  1712. progress,
  1713. )?;
  1714. let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
  1715. let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?;
  1716. Ok(final_assistant_text(&summary).trim().to_string())
  1717. }
  1718. fn run_internal_prompt_text(
  1719. &self,
  1720. prompt: &str,
  1721. enable_tools: bool,
  1722. ) -> Result<String, Box<dyn std::error::Error>> {
  1723. self.run_internal_prompt_text_with_progress(prompt, enable_tools, None)
  1724. }
  1725. fn run_bughunter(&self, scope: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
  1726. let scope = scope.unwrap_or("the current repository");
  1727. let prompt = format!(
  1728. "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."
  1729. );
  1730. println!("{}", self.run_internal_prompt_text(&prompt, true)?);
  1731. Ok(())
  1732. }
  1733. fn run_ultraplan(&self, task: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
  1734. let task = task.unwrap_or("the current repo work");
  1735. let prompt = format!(
  1736. "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."
  1737. );
  1738. let mut progress = InternalPromptProgressRun::start_ultraplan(task);
  1739. match self.run_internal_prompt_text_with_progress(&prompt, true, Some(progress.reporter()))
  1740. {
  1741. Ok(plan) => {
  1742. progress.finish_success();
  1743. println!("{plan}");
  1744. Ok(())
  1745. }
  1746. Err(error) => {
  1747. progress.finish_failure(&error.to_string());
  1748. Err(error)
  1749. }
  1750. }
  1751. }
  1752. #[allow(clippy::unused_self)]
  1753. fn run_teleport(&self, target: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
  1754. let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else {
  1755. println!("Usage: /teleport <symbol-or-path>");
  1756. return Ok(());
  1757. };
  1758. println!("{}", render_teleport_report(target)?);
  1759. Ok(())
  1760. }
  1761. fn run_debug_tool_call(&self) -> Result<(), Box<dyn std::error::Error>> {
  1762. println!("{}", render_last_tool_debug_report(self.runtime.session())?);
  1763. Ok(())
  1764. }
  1765. fn run_commit(&mut self) -> Result<(), Box<dyn std::error::Error>> {
  1766. let status = git_output(&["status", "--short"])?;
  1767. if status.trim().is_empty() {
  1768. println!("Commit\n Result skipped\n Reason no workspace changes");
  1769. return Ok(());
  1770. }
  1771. git_status_ok(&["add", "-A"])?;
  1772. let staged_stat = git_output(&["diff", "--cached", "--stat"])?;
  1773. let prompt = format!(
  1774. "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{}",
  1775. truncate_for_prompt(&staged_stat, 8_000),
  1776. recent_user_context(self.runtime.session(), 6)
  1777. );
  1778. let message = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?);
  1779. if message.trim().is_empty() {
  1780. return Err("generated commit message was empty".into());
  1781. }
  1782. let path = write_temp_text_file("claw-commit-message.txt", &message)?;
  1783. let output = Command::new("git")
  1784. .args(["commit", "--file"])
  1785. .arg(&path)
  1786. .current_dir(env::current_dir()?)
  1787. .output()?;
  1788. if !output.status.success() {
  1789. let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
  1790. return Err(format!("git commit failed: {stderr}").into());
  1791. }
  1792. println!(
  1793. "Commit\n Result created\n Message file {}\n\n{}",
  1794. path.display(),
  1795. message.trim()
  1796. );
  1797. Ok(())
  1798. }
  1799. fn run_pr(&self, context: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
  1800. let staged = git_output(&["diff", "--stat"])?;
  1801. let prompt = format!(
  1802. "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{}",
  1803. context.unwrap_or("none"),
  1804. truncate_for_prompt(&staged, 10_000)
  1805. );
  1806. let draft = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?);
  1807. let (title, body) = parse_titled_body(&draft)
  1808. .ok_or_else(|| "failed to parse generated PR title/body".to_string())?;
  1809. if command_exists("gh") {
  1810. let body_path = write_temp_text_file("claw-pr-body.md", &body)?;
  1811. let output = Command::new("gh")
  1812. .args(["pr", "create", "--title", &title, "--body-file"])
  1813. .arg(&body_path)
  1814. .current_dir(env::current_dir()?)
  1815. .output()?;
  1816. if output.status.success() {
  1817. let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
  1818. println!(
  1819. "PR\n Result created\n Title {title}\n URL {}",
  1820. if stdout.is_empty() { "<unknown>" } else { &stdout }
  1821. );
  1822. return Ok(());
  1823. }
  1824. }
  1825. println!("PR draft\n Title {title}\n\n{body}");
  1826. Ok(())
  1827. }
  1828. fn run_issue(&self, context: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
  1829. let prompt = format!(
  1830. "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{}",
  1831. context.unwrap_or("none"),
  1832. truncate_for_prompt(&recent_user_context(self.runtime.session(), 10), 10_000)
  1833. );
  1834. let draft = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?);
  1835. let (title, body) = parse_titled_body(&draft)
  1836. .ok_or_else(|| "failed to parse generated issue title/body".to_string())?;
  1837. if command_exists("gh") {
  1838. let body_path = write_temp_text_file("claw-issue-body.md", &body)?;
  1839. let output = Command::new("gh")
  1840. .args(["issue", "create", "--title", &title, "--body-file"])
  1841. .arg(&body_path)
  1842. .current_dir(env::current_dir()?)
  1843. .output()?;
  1844. if output.status.success() {
  1845. let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
  1846. println!(
  1847. "Issue\n Result created\n Title {title}\n URL {}",
  1848. if stdout.is_empty() { "<unknown>" } else { &stdout }
  1849. );
  1850. return Ok(());
  1851. }
  1852. }
  1853. println!("Issue draft\n Title {title}\n\n{body}");
  1854. Ok(())
  1855. }
  1856. }
  1857. fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
  1858. let cwd = env::current_dir()?;
  1859. let path = cwd.join(".claw").join("sessions");
  1860. fs::create_dir_all(&path)?;
  1861. Ok(path)
  1862. }
  1863. fn create_managed_session_handle(
  1864. session_id: &str,
  1865. ) -> Result<SessionHandle, Box<dyn std::error::Error>> {
  1866. let id = session_id.to_string();
  1867. let path = sessions_dir()?.join(format!("{id}.{PRIMARY_SESSION_EXTENSION}"));
  1868. Ok(SessionHandle { id, path })
  1869. }
  1870. fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> {
  1871. let direct = PathBuf::from(reference);
  1872. let looks_like_path = direct.extension().is_some() || direct.components().count() > 1;
  1873. let path = if direct.exists() {
  1874. direct
  1875. } else if looks_like_path {
  1876. return Err(format!("session not found: {reference}").into());
  1877. } else {
  1878. resolve_managed_session_path(reference)?
  1879. };
  1880. let id = path
  1881. .file_name()
  1882. .and_then(|value| value.to_str())
  1883. .and_then(|name| {
  1884. name.strip_suffix(&format!(".{PRIMARY_SESSION_EXTENSION}"))
  1885. .or_else(|| name.strip_suffix(&format!(".{LEGACY_SESSION_EXTENSION}")))
  1886. })
  1887. .unwrap_or(reference)
  1888. .to_string();
  1889. Ok(SessionHandle { id, path })
  1890. }
  1891. fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
  1892. let directory = sessions_dir()?;
  1893. for extension in [PRIMARY_SESSION_EXTENSION, LEGACY_SESSION_EXTENSION] {
  1894. let path = directory.join(format!("{session_id}.{extension}"));
  1895. if path.exists() {
  1896. return Ok(path);
  1897. }
  1898. }
  1899. Err(format!("session not found: {session_id}").into())
  1900. }
  1901. fn is_managed_session_file(path: &Path) -> bool {
  1902. path.extension()
  1903. .and_then(|ext| ext.to_str())
  1904. .is_some_and(|extension| {
  1905. extension == PRIMARY_SESSION_EXTENSION || extension == LEGACY_SESSION_EXTENSION
  1906. })
  1907. }
  1908. fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> {
  1909. let mut sessions = Vec::new();
  1910. for entry in fs::read_dir(sessions_dir()?)? {
  1911. let entry = entry?;
  1912. let path = entry.path();
  1913. if !is_managed_session_file(&path) {
  1914. continue;
  1915. }
  1916. let metadata = entry.metadata()?;
  1917. let modified_epoch_secs = metadata
  1918. .modified()
  1919. .ok()
  1920. .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
  1921. .map(|duration| duration.as_secs())
  1922. .unwrap_or_default();
  1923. let (id, message_count, parent_session_id, branch_name) = Session::load_from_path(&path)
  1924. .map(|session| {
  1925. let parent_session_id = session
  1926. .fork
  1927. .as_ref()
  1928. .map(|fork| fork.parent_session_id.clone());
  1929. let branch_name = session
  1930. .fork
  1931. .as_ref()
  1932. .and_then(|fork| fork.branch_name.clone());
  1933. (
  1934. session.session_id,
  1935. session.messages.len(),
  1936. parent_session_id,
  1937. branch_name,
  1938. )
  1939. })
  1940. .unwrap_or_else(|_| {
  1941. (
  1942. path.file_stem()
  1943. .and_then(|value| value.to_str())
  1944. .unwrap_or("unknown")
  1945. .to_string(),
  1946. 0,
  1947. None,
  1948. None,
  1949. )
  1950. });
  1951. sessions.push(ManagedSessionSummary {
  1952. id,
  1953. path,
  1954. modified_epoch_secs,
  1955. message_count,
  1956. parent_session_id,
  1957. branch_name,
  1958. });
  1959. }
  1960. sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs));
  1961. Ok(sessions)
  1962. }
  1963. fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::error::Error>> {
  1964. let sessions = list_managed_sessions()?;
  1965. let mut lines = vec![
  1966. "Sessions".to_string(),
  1967. format!(" Directory {}", sessions_dir()?.display()),
  1968. ];
  1969. if sessions.is_empty() {
  1970. lines.push(" No managed sessions saved yet.".to_string());
  1971. return Ok(lines.join("\n"));
  1972. }
  1973. for session in sessions {
  1974. let marker = if session.id == active_session_id {
  1975. "● current"
  1976. } else {
  1977. "○ saved"
  1978. };
  1979. let lineage = match (
  1980. session.branch_name.as_deref(),
  1981. session.parent_session_id.as_deref(),
  1982. ) {
  1983. (Some(branch_name), Some(parent_session_id)) => {
  1984. format!(" branch={branch_name} from={parent_session_id}")
  1985. }
  1986. (None, Some(parent_session_id)) => format!(" from={parent_session_id}"),
  1987. (Some(branch_name), None) => format!(" branch={branch_name}"),
  1988. (None, None) => String::new(),
  1989. };
  1990. lines.push(format!(
  1991. " {id:<20} {marker:<10} msgs={msgs:<4} modified={modified}{lineage} path={path}",
  1992. id = session.id,
  1993. msgs = session.message_count,
  1994. modified = session.modified_epoch_secs,
  1995. lineage = lineage,
  1996. path = session.path.display(),
  1997. ));
  1998. }
  1999. Ok(lines.join("\n"))
  2000. }
  2001. fn render_repl_help() -> String {
  2002. [
  2003. "REPL".to_string(),
  2004. " /exit Quit the REPL".to_string(),
  2005. " /quit Quit the REPL".to_string(),
  2006. " Up/Down Navigate prompt history".to_string(),
  2007. " Tab Complete slash commands".to_string(),
  2008. " Ctrl-C Clear input (or exit on empty prompt)".to_string(),
  2009. " Shift+Enter/Ctrl+J Insert a newline".to_string(),
  2010. String::new(),
  2011. render_slash_command_help(),
  2012. ]
  2013. .join(
  2014. "
  2015. ",
  2016. )
  2017. }
  2018. fn status_context(
  2019. session_path: Option<&Path>,
  2020. ) -> Result<StatusContext, Box<dyn std::error::Error>> {
  2021. let cwd = env::current_dir()?;
  2022. let loader = ConfigLoader::default_for(&cwd);
  2023. let discovered_config_files = loader.discover().len();
  2024. let runtime_config = loader.load()?;
  2025. let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
  2026. let (project_root, git_branch) =
  2027. parse_git_status_metadata(project_context.git_status.as_deref());
  2028. let sandbox_status = resolve_sandbox_status(runtime_config.sandbox(), &cwd);
  2029. Ok(StatusContext {
  2030. cwd,
  2031. session_path: session_path.map(Path::to_path_buf),
  2032. loaded_config_files: runtime_config.loaded_entries().len(),
  2033. discovered_config_files,
  2034. memory_file_count: project_context.instruction_files.len(),
  2035. project_root,
  2036. git_branch,
  2037. sandbox_status,
  2038. })
  2039. }
  2040. fn format_status_report(
  2041. model: &str,
  2042. usage: StatusUsage,
  2043. permission_mode: &str,
  2044. context: &StatusContext,
  2045. ) -> String {
  2046. [
  2047. format!(
  2048. "Status
  2049. Model {model}
  2050. Permission mode {permission_mode}
  2051. Messages {}
  2052. Turns {}
  2053. Estimated tokens {}",
  2054. usage.message_count, usage.turns, usage.estimated_tokens,
  2055. ),
  2056. format!(
  2057. "Usage
  2058. Latest total {}
  2059. Cumulative input {}
  2060. Cumulative output {}
  2061. Cumulative total {}",
  2062. usage.latest.total_tokens(),
  2063. usage.cumulative.input_tokens,
  2064. usage.cumulative.output_tokens,
  2065. usage.cumulative.total_tokens(),
  2066. ),
  2067. format!(
  2068. "Workspace
  2069. Cwd {}
  2070. Project root {}
  2071. Git branch {}
  2072. Session {}
  2073. Config files loaded {}/{}
  2074. Memory files {}",
  2075. context.cwd.display(),
  2076. context
  2077. .project_root
  2078. .as_ref()
  2079. .map_or_else(|| "unknown".to_string(), |path| path.display().to_string()),
  2080. context.git_branch.as_deref().unwrap_or("unknown"),
  2081. context.session_path.as_ref().map_or_else(
  2082. || "live-repl".to_string(),
  2083. |path| path.display().to_string()
  2084. ),
  2085. context.loaded_config_files,
  2086. context.discovered_config_files,
  2087. context.memory_file_count,
  2088. ),
  2089. format_sandbox_report(&context.sandbox_status),
  2090. ]
  2091. .join(
  2092. "
  2093. ",
  2094. )
  2095. }
  2096. fn format_sandbox_report(status: &runtime::SandboxStatus) -> String {
  2097. format!(
  2098. "Sandbox
  2099. Enabled {}
  2100. Active {}
  2101. Supported {}
  2102. In container {}
  2103. Requested ns {}
  2104. Active ns {}
  2105. Requested net {}
  2106. Active net {}
  2107. Filesystem mode {}
  2108. Filesystem active {}
  2109. Allowed mounts {}
  2110. Markers {}
  2111. Fallback reason {}",
  2112. status.enabled,
  2113. status.active,
  2114. status.supported,
  2115. status.in_container,
  2116. status.requested.namespace_restrictions,
  2117. status.namespace_active,
  2118. status.requested.network_isolation,
  2119. status.network_active,
  2120. status.filesystem_mode.as_str(),
  2121. status.filesystem_active,
  2122. if status.allowed_mounts.is_empty() {
  2123. "<none>".to_string()
  2124. } else {
  2125. status.allowed_mounts.join(", ")
  2126. },
  2127. if status.container_markers.is_empty() {
  2128. "<none>".to_string()
  2129. } else {
  2130. status.container_markers.join(", ")
  2131. },
  2132. status
  2133. .fallback_reason
  2134. .clone()
  2135. .unwrap_or_else(|| "<none>".to_string()),
  2136. )
  2137. }
  2138. fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::error::Error>> {
  2139. let cwd = env::current_dir()?;
  2140. let loader = ConfigLoader::default_for(&cwd);
  2141. let discovered = loader.discover();
  2142. let runtime_config = loader.load()?;
  2143. let mut lines = vec![
  2144. format!(
  2145. "Config
  2146. Working directory {}
  2147. Loaded files {}
  2148. Merged keys {}",
  2149. cwd.display(),
  2150. runtime_config.loaded_entries().len(),
  2151. runtime_config.merged().len()
  2152. ),
  2153. "Discovered files".to_string(),
  2154. ];
  2155. for entry in discovered {
  2156. let source = match entry.source {
  2157. ConfigSource::User => "user",
  2158. ConfigSource::Project => "project",
  2159. ConfigSource::Local => "local",
  2160. };
  2161. let status = if runtime_config
  2162. .loaded_entries()
  2163. .iter()
  2164. .any(|loaded_entry| loaded_entry.path == entry.path)
  2165. {
  2166. "loaded"
  2167. } else {
  2168. "missing"
  2169. };
  2170. lines.push(format!(
  2171. " {source:<7} {status:<7} {}",
  2172. entry.path.display()
  2173. ));
  2174. }
  2175. if let Some(section) = section {
  2176. lines.push(format!("Merged section: {section}"));
  2177. let value = match section {
  2178. "env" => runtime_config.get("env"),
  2179. "hooks" => runtime_config.get("hooks"),
  2180. "model" => runtime_config.get("model"),
  2181. "plugins" => runtime_config
  2182. .get("plugins")
  2183. .or_else(|| runtime_config.get("enabledPlugins")),
  2184. other => {
  2185. lines.push(format!(
  2186. " Unsupported config section '{other}'. Use env, hooks, model, or plugins."
  2187. ));
  2188. return Ok(lines.join(
  2189. "
  2190. ",
  2191. ));
  2192. }
  2193. };
  2194. lines.push(format!(
  2195. " {}",
  2196. match value {
  2197. Some(value) => value.render(),
  2198. None => "<unset>".to_string(),
  2199. }
  2200. ));
  2201. return Ok(lines.join(
  2202. "
  2203. ",
  2204. ));
  2205. }
  2206. lines.push("Merged JSON".to_string());
  2207. lines.push(format!(" {}", runtime_config.as_json().render()));
  2208. Ok(lines.join(
  2209. "
  2210. ",
  2211. ))
  2212. }
  2213. fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
  2214. let cwd = env::current_dir()?;
  2215. let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
  2216. let mut lines = vec![format!(
  2217. "Memory
  2218. Working directory {}
  2219. Instruction files {}",
  2220. cwd.display(),
  2221. project_context.instruction_files.len()
  2222. )];
  2223. if project_context.instruction_files.is_empty() {
  2224. lines.push("Discovered files".to_string());
  2225. lines.push(
  2226. " No CLAUDE instruction files discovered in the current directory ancestry."
  2227. .to_string(),
  2228. );
  2229. } else {
  2230. lines.push("Discovered files".to_string());
  2231. for (index, file) in project_context.instruction_files.iter().enumerate() {
  2232. let preview = file.content.lines().next().unwrap_or("").trim();
  2233. let preview = if preview.is_empty() {
  2234. "<empty>"
  2235. } else {
  2236. preview
  2237. };
  2238. lines.push(format!(" {}. {}", index + 1, file.path.display(),));
  2239. lines.push(format!(
  2240. " lines={} preview={}",
  2241. file.content.lines().count(),
  2242. preview
  2243. ));
  2244. }
  2245. }
  2246. Ok(lines.join(
  2247. "
  2248. ",
  2249. ))
  2250. }
  2251. fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
  2252. let cwd = env::current_dir()?;
  2253. Ok(initialize_repo(&cwd)?.render())
  2254. }
  2255. fn run_init() -> Result<(), Box<dyn std::error::Error>> {
  2256. println!("{}", init_claude_md()?);
  2257. Ok(())
  2258. }
  2259. fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
  2260. match mode.trim() {
  2261. "read-only" => Some("read-only"),
  2262. "workspace-write" => Some("workspace-write"),
  2263. "danger-full-access" => Some("danger-full-access"),
  2264. _ => None,
  2265. }
  2266. }
  2267. fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
  2268. render_diff_report_for(&env::current_dir()?)
  2269. }
  2270. fn render_diff_report_for(cwd: &Path) -> Result<String, Box<dyn std::error::Error>> {
  2271. let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?;
  2272. let unstaged = run_git_diff_command_in(cwd, &["diff"])?;
  2273. if staged.trim().is_empty() && unstaged.trim().is_empty() {
  2274. return Ok(
  2275. "Diff\n Result clean working tree\n Detail no current changes"
  2276. .to_string(),
  2277. );
  2278. }
  2279. let mut sections = Vec::new();
  2280. if !staged.trim().is_empty() {
  2281. sections.push(format!("Staged changes:\n{}", staged.trim_end()));
  2282. }
  2283. if !unstaged.trim().is_empty() {
  2284. sections.push(format!("Unstaged changes:\n{}", unstaged.trim_end()));
  2285. }
  2286. Ok(format!("Diff\n\n{}", sections.join("\n\n")))
  2287. }
  2288. fn run_git_diff_command_in(
  2289. cwd: &Path,
  2290. args: &[&str],
  2291. ) -> Result<String, Box<dyn std::error::Error>> {
  2292. let output = std::process::Command::new("git")
  2293. .args(args)
  2294. .current_dir(cwd)
  2295. .output()?;
  2296. if !output.status.success() {
  2297. let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
  2298. return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
  2299. }
  2300. Ok(String::from_utf8(output.stdout)?)
  2301. }
  2302. fn render_teleport_report(target: &str) -> Result<String, Box<dyn std::error::Error>> {
  2303. let cwd = env::current_dir()?;
  2304. let file_list = Command::new("rg")
  2305. .args(["--files"])
  2306. .current_dir(&cwd)
  2307. .output()?;
  2308. let file_matches = if file_list.status.success() {
  2309. String::from_utf8(file_list.stdout)?
  2310. .lines()
  2311. .filter(|line| line.contains(target))
  2312. .take(10)
  2313. .map(ToOwned::to_owned)
  2314. .collect::<Vec<_>>()
  2315. } else {
  2316. Vec::new()
  2317. };
  2318. let content_output = Command::new("rg")
  2319. .args(["-n", "-S", "--color", "never", target, "."])
  2320. .current_dir(&cwd)
  2321. .output()?;
  2322. let mut lines = vec![format!("Teleport\n Target {target}")];
  2323. if !file_matches.is_empty() {
  2324. lines.push(String::new());
  2325. lines.push("File matches".to_string());
  2326. lines.extend(file_matches.into_iter().map(|path| format!(" {path}")));
  2327. }
  2328. if content_output.status.success() {
  2329. let matches = String::from_utf8(content_output.stdout)?;
  2330. if !matches.trim().is_empty() {
  2331. lines.push(String::new());
  2332. lines.push("Content matches".to_string());
  2333. lines.push(truncate_for_prompt(&matches, 4_000));
  2334. }
  2335. }
  2336. if lines.len() == 1 {
  2337. lines.push(" Result no matches found".to_string());
  2338. }
  2339. Ok(lines.join("\n"))
  2340. }
  2341. fn render_last_tool_debug_report(session: &Session) -> Result<String, Box<dyn std::error::Error>> {
  2342. let last_tool_use = session
  2343. .messages
  2344. .iter()
  2345. .rev()
  2346. .find_map(|message| {
  2347. message.blocks.iter().rev().find_map(|block| match block {
  2348. ContentBlock::ToolUse { id, name, input } => {
  2349. Some((id.clone(), name.clone(), input.clone()))
  2350. }
  2351. _ => None,
  2352. })
  2353. })
  2354. .ok_or_else(|| "no prior tool call found in session".to_string())?;
  2355. let tool_result = session.messages.iter().rev().find_map(|message| {
  2356. message.blocks.iter().rev().find_map(|block| match block {
  2357. ContentBlock::ToolResult {
  2358. tool_use_id,
  2359. tool_name,
  2360. output,
  2361. is_error,
  2362. } if tool_use_id == &last_tool_use.0 => {
  2363. Some((tool_name.clone(), output.clone(), *is_error))
  2364. }
  2365. _ => None,
  2366. })
  2367. });
  2368. let mut lines = vec![
  2369. "Debug tool call".to_string(),
  2370. format!(" Tool id {}", last_tool_use.0),
  2371. format!(" Tool name {}", last_tool_use.1),
  2372. " Input".to_string(),
  2373. indent_block(&last_tool_use.2, 4),
  2374. ];
  2375. match tool_result {
  2376. Some((tool_name, output, is_error)) => {
  2377. lines.push(" Result".to_string());
  2378. lines.push(format!(" name {tool_name}"));
  2379. lines.push(format!(
  2380. " status {}",
  2381. if is_error { "error" } else { "ok" }
  2382. ));
  2383. lines.push(indent_block(&output, 4));
  2384. }
  2385. None => lines.push(" Result missing tool result".to_string()),
  2386. }
  2387. Ok(lines.join("\n"))
  2388. }
  2389. fn indent_block(value: &str, spaces: usize) -> String {
  2390. let indent = " ".repeat(spaces);
  2391. value
  2392. .lines()
  2393. .map(|line| format!("{indent}{line}"))
  2394. .collect::<Vec<_>>()
  2395. .join("\n")
  2396. }
  2397. fn git_output(args: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
  2398. let output = Command::new("git")
  2399. .args(args)
  2400. .current_dir(env::current_dir()?)
  2401. .output()?;
  2402. if !output.status.success() {
  2403. let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
  2404. return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
  2405. }
  2406. Ok(String::from_utf8(output.stdout)?)
  2407. }
  2408. fn git_status_ok(args: &[&str]) -> Result<(), Box<dyn std::error::Error>> {
  2409. let output = Command::new("git")
  2410. .args(args)
  2411. .current_dir(env::current_dir()?)
  2412. .output()?;
  2413. if !output.status.success() {
  2414. let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
  2415. return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
  2416. }
  2417. Ok(())
  2418. }
  2419. fn command_exists(name: &str) -> bool {
  2420. Command::new("which")
  2421. .arg(name)
  2422. .output()
  2423. .map(|output| output.status.success())
  2424. .unwrap_or(false)
  2425. }
  2426. fn write_temp_text_file(
  2427. filename: &str,
  2428. contents: &str,
  2429. ) -> Result<PathBuf, Box<dyn std::error::Error>> {
  2430. let path = env::temp_dir().join(filename);
  2431. fs::write(&path, contents)?;
  2432. Ok(path)
  2433. }
  2434. fn recent_user_context(session: &Session, limit: usize) -> String {
  2435. let requests = session
  2436. .messages
  2437. .iter()
  2438. .filter(|message| message.role == MessageRole::User)
  2439. .filter_map(|message| {
  2440. message.blocks.iter().find_map(|block| match block {
  2441. ContentBlock::Text { text } => Some(text.trim().to_string()),
  2442. _ => None,
  2443. })
  2444. })
  2445. .rev()
  2446. .take(limit)
  2447. .collect::<Vec<_>>();
  2448. if requests.is_empty() {
  2449. "<no prior user messages>".to_string()
  2450. } else {
  2451. requests
  2452. .into_iter()
  2453. .rev()
  2454. .enumerate()
  2455. .map(|(index, text)| format!("{}. {}", index + 1, text))
  2456. .collect::<Vec<_>>()
  2457. .join("\n")
  2458. }
  2459. }
  2460. fn truncate_for_prompt(value: &str, limit: usize) -> String {
  2461. if value.chars().count() <= limit {
  2462. value.trim().to_string()
  2463. } else {
  2464. let truncated = value.chars().take(limit).collect::<String>();
  2465. format!("{}\n…[truncated]", truncated.trim_end())
  2466. }
  2467. }
  2468. fn sanitize_generated_message(value: &str) -> String {
  2469. value.trim().trim_matches('`').trim().replace("\r\n", "\n")
  2470. }
  2471. fn parse_titled_body(value: &str) -> Option<(String, String)> {
  2472. let normalized = sanitize_generated_message(value);
  2473. let title = normalized
  2474. .lines()
  2475. .find_map(|line| line.strip_prefix("TITLE:").map(str::trim))?;
  2476. let body_start = normalized.find("BODY:")?;
  2477. let body = normalized[body_start + "BODY:".len()..].trim();
  2478. Some((title.to_string(), body.to_string()))
  2479. }
  2480. fn render_version_report() -> String {
  2481. let git_sha = GIT_SHA.unwrap_or("unknown");
  2482. let target = BUILD_TARGET.unwrap_or("unknown");
  2483. format!(
  2484. "Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}"
  2485. )
  2486. }
  2487. fn render_export_text(session: &Session) -> String {
  2488. let mut lines = vec!["# Conversation Export".to_string(), String::new()];
  2489. for (index, message) in session.messages.iter().enumerate() {
  2490. let role = match message.role {
  2491. MessageRole::System => "system",
  2492. MessageRole::User => "user",
  2493. MessageRole::Assistant => "assistant",
  2494. MessageRole::Tool => "tool",
  2495. };
  2496. lines.push(format!("## {}. {role}", index + 1));
  2497. for block in &message.blocks {
  2498. match block {
  2499. ContentBlock::Text { text } => lines.push(text.clone()),
  2500. ContentBlock::ToolUse { id, name, input } => {
  2501. lines.push(format!("[tool_use id={id} name={name}] {input}"));
  2502. }
  2503. ContentBlock::ToolResult {
  2504. tool_use_id,
  2505. tool_name,
  2506. output,
  2507. is_error,
  2508. } => {
  2509. lines.push(format!(
  2510. "[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}"
  2511. ));
  2512. }
  2513. }
  2514. }
  2515. lines.push(String::new());
  2516. }
  2517. lines.join("\n")
  2518. }
  2519. fn default_export_filename(session: &Session) -> String {
  2520. let stem = session
  2521. .messages
  2522. .iter()
  2523. .find_map(|message| match message.role {
  2524. MessageRole::User => message.blocks.iter().find_map(|block| match block {
  2525. ContentBlock::Text { text } => Some(text.as_str()),
  2526. _ => None,
  2527. }),
  2528. _ => None,
  2529. })
  2530. .map_or("conversation", |text| {
  2531. text.lines().next().unwrap_or("conversation")
  2532. })
  2533. .chars()
  2534. .map(|ch| {
  2535. if ch.is_ascii_alphanumeric() {
  2536. ch.to_ascii_lowercase()
  2537. } else {
  2538. '-'
  2539. }
  2540. })
  2541. .collect::<String>()
  2542. .split('-')
  2543. .filter(|part| !part.is_empty())
  2544. .take(8)
  2545. .collect::<Vec<_>>()
  2546. .join("-");
  2547. let fallback = if stem.is_empty() {
  2548. "conversation"
  2549. } else {
  2550. &stem
  2551. };
  2552. format!("{fallback}.txt")
  2553. }
  2554. fn resolve_export_path(
  2555. requested_path: Option<&str>,
  2556. session: &Session,
  2557. ) -> Result<PathBuf, Box<dyn std::error::Error>> {
  2558. let cwd = env::current_dir()?;
  2559. let file_name =
  2560. requested_path.map_or_else(|| default_export_filename(session), ToOwned::to_owned);
  2561. let final_name = if Path::new(&file_name)
  2562. .extension()
  2563. .is_some_and(|ext| ext.eq_ignore_ascii_case("txt"))
  2564. {
  2565. file_name
  2566. } else {
  2567. format!("{file_name}.txt")
  2568. };
  2569. Ok(cwd.join(final_name))
  2570. }
  2571. fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
  2572. Ok(load_system_prompt(
  2573. env::current_dir()?,
  2574. DEFAULT_DATE,
  2575. env::consts::OS,
  2576. "unknown",
  2577. )?)
  2578. }
  2579. fn build_runtime_plugin_state(
  2580. ) -> Result<(runtime::RuntimeFeatureConfig, GlobalToolRegistry), Box<dyn std::error::Error>> {
  2581. let cwd = env::current_dir()?;
  2582. let loader = ConfigLoader::default_for(&cwd);
  2583. let runtime_config = loader.load()?;
  2584. let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
  2585. let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_manager.aggregated_tools()?)?;
  2586. Ok((runtime_config.feature_config().clone(), tool_registry))
  2587. }
  2588. fn build_plugin_manager(
  2589. cwd: &Path,
  2590. loader: &ConfigLoader,
  2591. runtime_config: &runtime::RuntimeConfig,
  2592. ) -> PluginManager {
  2593. let plugin_settings = runtime_config.plugins();
  2594. let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf());
  2595. plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone();
  2596. plugin_config.external_dirs = plugin_settings
  2597. .external_directories()
  2598. .iter()
  2599. .map(|path| resolve_plugin_path(cwd, loader.config_home(), path))
  2600. .collect();
  2601. plugin_config.install_root = plugin_settings
  2602. .install_root()
  2603. .map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
  2604. plugin_config.registry_path = plugin_settings
  2605. .registry_path()
  2606. .map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
  2607. plugin_config.bundled_root = plugin_settings
  2608. .bundled_root()
  2609. .map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
  2610. PluginManager::new(plugin_config)
  2611. }
  2612. fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf {
  2613. let path = PathBuf::from(value);
  2614. if path.is_absolute() {
  2615. path
  2616. } else if value.starts_with('.') {
  2617. cwd.join(path)
  2618. } else {
  2619. config_home.join(path)
  2620. }
  2621. }
  2622. #[derive(Debug, Clone, PartialEq, Eq)]
  2623. struct InternalPromptProgressState {
  2624. command_label: &'static str,
  2625. task_label: String,
  2626. step: usize,
  2627. phase: String,
  2628. detail: Option<String>,
  2629. saw_final_text: bool,
  2630. }
  2631. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  2632. enum InternalPromptProgressEvent {
  2633. Started,
  2634. Update,
  2635. Heartbeat,
  2636. Complete,
  2637. Failed,
  2638. }
  2639. #[derive(Debug)]
  2640. struct InternalPromptProgressShared {
  2641. state: Mutex<InternalPromptProgressState>,
  2642. output_lock: Mutex<()>,
  2643. started_at: Instant,
  2644. }
  2645. #[derive(Debug, Clone)]
  2646. struct InternalPromptProgressReporter {
  2647. shared: Arc<InternalPromptProgressShared>,
  2648. }
  2649. #[derive(Debug)]
  2650. struct InternalPromptProgressRun {
  2651. reporter: InternalPromptProgressReporter,
  2652. heartbeat_stop: Option<mpsc::Sender<()>>,
  2653. heartbeat_handle: Option<thread::JoinHandle<()>>,
  2654. }
  2655. impl InternalPromptProgressReporter {
  2656. fn ultraplan(task: &str) -> Self {
  2657. Self {
  2658. shared: Arc::new(InternalPromptProgressShared {
  2659. state: Mutex::new(InternalPromptProgressState {
  2660. command_label: "Ultraplan",
  2661. task_label: task.to_string(),
  2662. step: 0,
  2663. phase: "planning started".to_string(),
  2664. detail: Some(format!("task: {task}")),
  2665. saw_final_text: false,
  2666. }),
  2667. output_lock: Mutex::new(()),
  2668. started_at: Instant::now(),
  2669. }),
  2670. }
  2671. }
  2672. fn emit(&self, event: InternalPromptProgressEvent, error: Option<&str>) {
  2673. let snapshot = self.snapshot();
  2674. let line = format_internal_prompt_progress_line(event, &snapshot, self.elapsed(), error);
  2675. self.write_line(&line);
  2676. }
  2677. fn mark_model_phase(&self) {
  2678. let snapshot = {
  2679. let mut state = self
  2680. .shared
  2681. .state
  2682. .lock()
  2683. .expect("internal prompt progress state poisoned");
  2684. state.step += 1;
  2685. state.phase = if state.step == 1 {
  2686. "analyzing request".to_string()
  2687. } else {
  2688. "reviewing findings".to_string()
  2689. };
  2690. state.detail = Some(format!("task: {}", state.task_label));
  2691. state.clone()
  2692. };
  2693. self.write_line(&format_internal_prompt_progress_line(
  2694. InternalPromptProgressEvent::Update,
  2695. &snapshot,
  2696. self.elapsed(),
  2697. None,
  2698. ));
  2699. }
  2700. fn mark_tool_phase(&self, name: &str, input: &str) {
  2701. let detail = describe_tool_progress(name, input);
  2702. let snapshot = {
  2703. let mut state = self
  2704. .shared
  2705. .state
  2706. .lock()
  2707. .expect("internal prompt progress state poisoned");
  2708. state.step += 1;
  2709. state.phase = format!("running {name}");
  2710. state.detail = Some(detail);
  2711. state.clone()
  2712. };
  2713. self.write_line(&format_internal_prompt_progress_line(
  2714. InternalPromptProgressEvent::Update,
  2715. &snapshot,
  2716. self.elapsed(),
  2717. None,
  2718. ));
  2719. }
  2720. fn mark_text_phase(&self, text: &str) {
  2721. let trimmed = text.trim();
  2722. if trimmed.is_empty() {
  2723. return;
  2724. }
  2725. let detail = truncate_for_summary(first_visible_line(trimmed), 120);
  2726. let snapshot = {
  2727. let mut state = self
  2728. .shared
  2729. .state
  2730. .lock()
  2731. .expect("internal prompt progress state poisoned");
  2732. if state.saw_final_text {
  2733. return;
  2734. }
  2735. state.saw_final_text = true;
  2736. state.step += 1;
  2737. state.phase = "drafting final plan".to_string();
  2738. state.detail = (!detail.is_empty()).then_some(detail);
  2739. state.clone()
  2740. };
  2741. self.write_line(&format_internal_prompt_progress_line(
  2742. InternalPromptProgressEvent::Update,
  2743. &snapshot,
  2744. self.elapsed(),
  2745. None,
  2746. ));
  2747. }
  2748. fn emit_heartbeat(&self) {
  2749. let snapshot = self.snapshot();
  2750. self.write_line(&format_internal_prompt_progress_line(
  2751. InternalPromptProgressEvent::Heartbeat,
  2752. &snapshot,
  2753. self.elapsed(),
  2754. None,
  2755. ));
  2756. }
  2757. fn snapshot(&self) -> InternalPromptProgressState {
  2758. self.shared
  2759. .state
  2760. .lock()
  2761. .expect("internal prompt progress state poisoned")
  2762. .clone()
  2763. }
  2764. fn elapsed(&self) -> Duration {
  2765. self.shared.started_at.elapsed()
  2766. }
  2767. fn write_line(&self, line: &str) {
  2768. let _guard = self
  2769. .shared
  2770. .output_lock
  2771. .lock()
  2772. .expect("internal prompt progress output lock poisoned");
  2773. let mut stdout = io::stdout();
  2774. let _ = writeln!(stdout, "{line}");
  2775. let _ = stdout.flush();
  2776. }
  2777. }
  2778. impl InternalPromptProgressRun {
  2779. fn start_ultraplan(task: &str) -> Self {
  2780. let reporter = InternalPromptProgressReporter::ultraplan(task);
  2781. reporter.emit(InternalPromptProgressEvent::Started, None);
  2782. let (heartbeat_stop, heartbeat_rx) = mpsc::channel();
  2783. let heartbeat_reporter = reporter.clone();
  2784. let heartbeat_handle = thread::spawn(move || loop {
  2785. match heartbeat_rx.recv_timeout(INTERNAL_PROGRESS_HEARTBEAT_INTERVAL) {
  2786. Ok(()) | Err(RecvTimeoutError::Disconnected) => break,
  2787. Err(RecvTimeoutError::Timeout) => heartbeat_reporter.emit_heartbeat(),
  2788. }
  2789. });
  2790. Self {
  2791. reporter,
  2792. heartbeat_stop: Some(heartbeat_stop),
  2793. heartbeat_handle: Some(heartbeat_handle),
  2794. }
  2795. }
  2796. fn reporter(&self) -> InternalPromptProgressReporter {
  2797. self.reporter.clone()
  2798. }
  2799. fn finish_success(&mut self) {
  2800. self.stop_heartbeat();
  2801. self.reporter
  2802. .emit(InternalPromptProgressEvent::Complete, None);
  2803. }
  2804. fn finish_failure(&mut self, error: &str) {
  2805. self.stop_heartbeat();
  2806. self.reporter
  2807. .emit(InternalPromptProgressEvent::Failed, Some(error));
  2808. }
  2809. fn stop_heartbeat(&mut self) {
  2810. if let Some(sender) = self.heartbeat_stop.take() {
  2811. let _ = sender.send(());
  2812. }
  2813. if let Some(handle) = self.heartbeat_handle.take() {
  2814. let _ = handle.join();
  2815. }
  2816. }
  2817. }
  2818. impl Drop for InternalPromptProgressRun {
  2819. fn drop(&mut self) {
  2820. self.stop_heartbeat();
  2821. }
  2822. }
  2823. fn format_internal_prompt_progress_line(
  2824. event: InternalPromptProgressEvent,
  2825. snapshot: &InternalPromptProgressState,
  2826. elapsed: Duration,
  2827. error: Option<&str>,
  2828. ) -> String {
  2829. let elapsed_seconds = elapsed.as_secs();
  2830. let step_label = if snapshot.step == 0 {
  2831. "current step pending".to_string()
  2832. } else {
  2833. format!("current step {}", snapshot.step)
  2834. };
  2835. let mut status_bits = vec![step_label, format!("phase {}", snapshot.phase)];
  2836. if let Some(detail) = snapshot
  2837. .detail
  2838. .as_deref()
  2839. .filter(|detail| !detail.is_empty())
  2840. {
  2841. status_bits.push(detail.to_string());
  2842. }
  2843. let status = status_bits.join(" · ");
  2844. match event {
  2845. InternalPromptProgressEvent::Started => {
  2846. format!(
  2847. "🧭 {} status · planning started · {status}",
  2848. snapshot.command_label
  2849. )
  2850. }
  2851. InternalPromptProgressEvent::Update => {
  2852. format!("… {} status · {status}", snapshot.command_label)
  2853. }
  2854. InternalPromptProgressEvent::Heartbeat => format!(
  2855. "… {} heartbeat · {elapsed_seconds}s elapsed · {status}",
  2856. snapshot.command_label
  2857. ),
  2858. InternalPromptProgressEvent::Complete => format!(
  2859. "✔ {} status · completed · {elapsed_seconds}s elapsed · {} steps total",
  2860. snapshot.command_label, snapshot.step
  2861. ),
  2862. InternalPromptProgressEvent::Failed => format!(
  2863. "✘ {} status · failed · {elapsed_seconds}s elapsed · {}",
  2864. snapshot.command_label,
  2865. error.unwrap_or("unknown error")
  2866. ),
  2867. }
  2868. }
  2869. fn describe_tool_progress(name: &str, input: &str) -> String {
  2870. let parsed: serde_json::Value =
  2871. serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string()));
  2872. match name {
  2873. "bash" | "Bash" => {
  2874. let command = parsed
  2875. .get("command")
  2876. .and_then(|value| value.as_str())
  2877. .unwrap_or_default();
  2878. if command.is_empty() {
  2879. "running shell command".to_string()
  2880. } else {
  2881. format!("command {}", truncate_for_summary(command.trim(), 100))
  2882. }
  2883. }
  2884. "read_file" | "Read" => format!("reading {}", extract_tool_path(&parsed)),
  2885. "write_file" | "Write" => format!("writing {}", extract_tool_path(&parsed)),
  2886. "edit_file" | "Edit" => format!("editing {}", extract_tool_path(&parsed)),
  2887. "glob_search" | "Glob" => {
  2888. let pattern = parsed
  2889. .get("pattern")
  2890. .and_then(|value| value.as_str())
  2891. .unwrap_or("?");
  2892. let scope = parsed
  2893. .get("path")
  2894. .and_then(|value| value.as_str())
  2895. .unwrap_or(".");
  2896. format!("glob `{pattern}` in {scope}")
  2897. }
  2898. "grep_search" | "Grep" => {
  2899. let pattern = parsed
  2900. .get("pattern")
  2901. .and_then(|value| value.as_str())
  2902. .unwrap_or("?");
  2903. let scope = parsed
  2904. .get("path")
  2905. .and_then(|value| value.as_str())
  2906. .unwrap_or(".");
  2907. format!("grep `{pattern}` in {scope}")
  2908. }
  2909. "web_search" | "WebSearch" => parsed
  2910. .get("query")
  2911. .and_then(|value| value.as_str())
  2912. .map_or_else(
  2913. || "running web search".to_string(),
  2914. |query| format!("query {}", truncate_for_summary(query, 100)),
  2915. ),
  2916. _ => {
  2917. let summary = summarize_tool_payload(input);
  2918. if summary.is_empty() {
  2919. format!("running {name}")
  2920. } else {
  2921. format!("{name}: {summary}")
  2922. }
  2923. }
  2924. }
  2925. }
  2926. #[allow(clippy::needless_pass_by_value)]
  2927. #[allow(clippy::too_many_arguments)]
  2928. fn build_runtime(
  2929. session: Session,
  2930. session_id: &str,
  2931. model: String,
  2932. system_prompt: Vec<String>,
  2933. enable_tools: bool,
  2934. emit_output: bool,
  2935. allowed_tools: Option<AllowedToolSet>,
  2936. permission_mode: PermissionMode,
  2937. progress_reporter: Option<InternalPromptProgressReporter>,
  2938. ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>> {
  2939. let (feature_config, tool_registry) = build_runtime_plugin_state()?;
  2940. let mut runtime = ConversationRuntime::new_with_features(
  2941. session,
  2942. AnthropicRuntimeClient::new(
  2943. session_id,
  2944. model,
  2945. enable_tools,
  2946. emit_output,
  2947. allowed_tools.clone(),
  2948. tool_registry.clone(),
  2949. progress_reporter,
  2950. )?,
  2951. CliToolExecutor::new(allowed_tools.clone(), emit_output, tool_registry.clone()),
  2952. permission_policy(permission_mode, &feature_config, &tool_registry),
  2953. system_prompt,
  2954. &feature_config,
  2955. );
  2956. if emit_output {
  2957. runtime = runtime.with_hook_progress_reporter(Box::new(CliHookProgressReporter));
  2958. }
  2959. Ok(runtime)
  2960. }
  2961. struct CliHookProgressReporter;
  2962. impl runtime::HookProgressReporter for CliHookProgressReporter {
  2963. fn on_event(&mut self, event: &runtime::HookProgressEvent) {
  2964. match event {
  2965. runtime::HookProgressEvent::Started {
  2966. event,
  2967. tool_name,
  2968. command,
  2969. } => eprintln!(
  2970. "[hook {event_name}] {tool_name}: {command}",
  2971. event_name = event.as_str()
  2972. ),
  2973. runtime::HookProgressEvent::Completed {
  2974. event,
  2975. tool_name,
  2976. command,
  2977. } => eprintln!(
  2978. "[hook done {event_name}] {tool_name}: {command}",
  2979. event_name = event.as_str()
  2980. ),
  2981. runtime::HookProgressEvent::Cancelled {
  2982. event,
  2983. tool_name,
  2984. command,
  2985. } => eprintln!(
  2986. "[hook cancelled {event_name}] {tool_name}: {command}",
  2987. event_name = event.as_str()
  2988. ),
  2989. }
  2990. }
  2991. }
  2992. struct CliPermissionPrompter {
  2993. current_mode: PermissionMode,
  2994. }
  2995. impl CliPermissionPrompter {
  2996. fn new(current_mode: PermissionMode) -> Self {
  2997. Self { current_mode }
  2998. }
  2999. }
  3000. impl runtime::PermissionPrompter for CliPermissionPrompter {
  3001. fn decide(
  3002. &mut self,
  3003. request: &runtime::PermissionRequest,
  3004. ) -> runtime::PermissionPromptDecision {
  3005. println!();
  3006. println!("Permission approval required");
  3007. println!(" Tool {}", request.tool_name);
  3008. println!(" Current mode {}", self.current_mode.as_str());
  3009. println!(" Required mode {}", request.required_mode.as_str());
  3010. if let Some(reason) = &request.reason {
  3011. println!(" Reason {reason}");
  3012. }
  3013. println!(" Input {}", request.input);
  3014. print!("Approve this tool call? [y/N]: ");
  3015. let _ = io::stdout().flush();
  3016. let mut response = String::new();
  3017. match io::stdin().read_line(&mut response) {
  3018. Ok(_) => {
  3019. let normalized = response.trim().to_ascii_lowercase();
  3020. if matches!(normalized.as_str(), "y" | "yes") {
  3021. runtime::PermissionPromptDecision::Allow
  3022. } else {
  3023. runtime::PermissionPromptDecision::Deny {
  3024. reason: format!(
  3025. "tool '{}' denied by user approval prompt",
  3026. request.tool_name
  3027. ),
  3028. }
  3029. }
  3030. }
  3031. Err(error) => runtime::PermissionPromptDecision::Deny {
  3032. reason: format!("permission approval failed: {error}"),
  3033. },
  3034. }
  3035. }
  3036. }
  3037. struct AnthropicRuntimeClient {
  3038. runtime: tokio::runtime::Runtime,
  3039. client: AnthropicClient,
  3040. model: String,
  3041. enable_tools: bool,
  3042. emit_output: bool,
  3043. allowed_tools: Option<AllowedToolSet>,
  3044. tool_registry: GlobalToolRegistry,
  3045. progress_reporter: Option<InternalPromptProgressReporter>,
  3046. }
  3047. impl AnthropicRuntimeClient {
  3048. fn new(
  3049. session_id: &str,
  3050. model: String,
  3051. enable_tools: bool,
  3052. emit_output: bool,
  3053. allowed_tools: Option<AllowedToolSet>,
  3054. tool_registry: GlobalToolRegistry,
  3055. progress_reporter: Option<InternalPromptProgressReporter>,
  3056. ) -> Result<Self, Box<dyn std::error::Error>> {
  3057. Ok(Self {
  3058. runtime: tokio::runtime::Runtime::new()?,
  3059. client: AnthropicClient::from_auth(resolve_cli_auth_source()?)
  3060. .with_base_url(api::read_base_url())
  3061. .with_prompt_cache(PromptCache::new(session_id)),
  3062. model,
  3063. enable_tools,
  3064. emit_output,
  3065. allowed_tools,
  3066. tool_registry,
  3067. progress_reporter,
  3068. })
  3069. }
  3070. fn with_session_tracer(mut self, session_tracer: SessionTracer) -> Self {
  3071. self.client = self.client.with_session_tracer(session_tracer);
  3072. self
  3073. }
  3074. }
  3075. fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
  3076. Ok(resolve_startup_auth_source(|| {
  3077. let cwd = env::current_dir().map_err(api::ApiError::from)?;
  3078. let config = ConfigLoader::default_for(&cwd).load().map_err(|error| {
  3079. api::ApiError::Auth(format!("failed to load runtime OAuth config: {error}"))
  3080. })?;
  3081. Ok(config.oauth().cloned())
  3082. })?)
  3083. }
  3084. impl ApiClient for AnthropicRuntimeClient {
  3085. #[allow(clippy::too_many_lines)]
  3086. fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
  3087. if let Some(progress_reporter) = &self.progress_reporter {
  3088. progress_reporter.mark_model_phase();
  3089. }
  3090. let message_request = MessageRequest {
  3091. model: self.model.clone(),
  3092. max_tokens: max_tokens_for_model(&self.model),
  3093. messages: convert_messages(&request.messages),
  3094. system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
  3095. tools: self
  3096. .enable_tools
  3097. .then(|| filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref())),
  3098. tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
  3099. stream: true,
  3100. };
  3101. self.runtime.block_on(async {
  3102. let mut stream = self
  3103. .client
  3104. .stream_message(&message_request)
  3105. .await
  3106. .map_err(|error| RuntimeError::new(error.to_string()))?;
  3107. let mut stdout = io::stdout();
  3108. let mut sink = io::sink();
  3109. let out: &mut dyn Write = if self.emit_output {
  3110. &mut stdout
  3111. } else {
  3112. &mut sink
  3113. };
  3114. let renderer = TerminalRenderer::new();
  3115. let mut markdown_stream = MarkdownStreamState::default();
  3116. let mut events = Vec::new();
  3117. let mut pending_tool: Option<(String, String, String)> = None;
  3118. let mut saw_stop = false;
  3119. while let Some(event) = stream
  3120. .next_event()
  3121. .await
  3122. .map_err(|error| RuntimeError::new(error.to_string()))?
  3123. {
  3124. match event {
  3125. ApiStreamEvent::MessageStart(start) => {
  3126. for block in start.message.content {
  3127. push_output_block(block, out, &mut events, &mut pending_tool, true)?;
  3128. }
  3129. }
  3130. ApiStreamEvent::ContentBlockStart(start) => {
  3131. push_output_block(
  3132. start.content_block,
  3133. out,
  3134. &mut events,
  3135. &mut pending_tool,
  3136. true,
  3137. )?;
  3138. }
  3139. ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
  3140. ContentBlockDelta::TextDelta { text } => {
  3141. if !text.is_empty() {
  3142. if let Some(progress_reporter) = &self.progress_reporter {
  3143. progress_reporter.mark_text_phase(&text);
  3144. }
  3145. if let Some(rendered) = markdown_stream.push(&renderer, &text) {
  3146. write!(out, "{rendered}")
  3147. .and_then(|()| out.flush())
  3148. .map_err(|error| RuntimeError::new(error.to_string()))?;
  3149. }
  3150. events.push(AssistantEvent::TextDelta(text));
  3151. }
  3152. }
  3153. ContentBlockDelta::InputJsonDelta { partial_json } => {
  3154. if let Some((_, _, input)) = &mut pending_tool {
  3155. input.push_str(&partial_json);
  3156. }
  3157. }
  3158. ContentBlockDelta::ThinkingDelta { .. }
  3159. | ContentBlockDelta::SignatureDelta { .. } => {}
  3160. },
  3161. ApiStreamEvent::ContentBlockStop(_) => {
  3162. if let Some(rendered) = markdown_stream.flush(&renderer) {
  3163. write!(out, "{rendered}")
  3164. .and_then(|()| out.flush())
  3165. .map_err(|error| RuntimeError::new(error.to_string()))?;
  3166. }
  3167. if let Some((id, name, input)) = pending_tool.take() {
  3168. if let Some(progress_reporter) = &self.progress_reporter {
  3169. progress_reporter.mark_tool_phase(&name, &input);
  3170. }
  3171. // Display tool call now that input is fully accumulated
  3172. writeln!(out, "\n{}", format_tool_call_start(&name, &input))
  3173. .and_then(|()| out.flush())
  3174. .map_err(|error| RuntimeError::new(error.to_string()))?;
  3175. events.push(AssistantEvent::ToolUse { id, name, input });
  3176. }
  3177. }
  3178. ApiStreamEvent::MessageDelta(delta) => {
  3179. events.push(AssistantEvent::Usage(delta.usage.token_usage()));
  3180. }
  3181. ApiStreamEvent::MessageStop(_) => {
  3182. saw_stop = true;
  3183. if let Some(rendered) = markdown_stream.flush(&renderer) {
  3184. write!(out, "{rendered}")
  3185. .and_then(|()| out.flush())
  3186. .map_err(|error| RuntimeError::new(error.to_string()))?;
  3187. }
  3188. events.push(AssistantEvent::MessageStop);
  3189. }
  3190. }
  3191. }
  3192. push_prompt_cache_record(&self.client, &mut events);
  3193. if !saw_stop
  3194. && events.iter().any(|event| {
  3195. matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
  3196. || matches!(event, AssistantEvent::ToolUse { .. })
  3197. })
  3198. {
  3199. events.push(AssistantEvent::MessageStop);
  3200. }
  3201. if events
  3202. .iter()
  3203. .any(|event| matches!(event, AssistantEvent::MessageStop))
  3204. {
  3205. return Ok(events);
  3206. }
  3207. let response = self
  3208. .client
  3209. .send_message(&MessageRequest {
  3210. stream: false,
  3211. ..message_request.clone()
  3212. })
  3213. .await
  3214. .map_err(|error| RuntimeError::new(error.to_string()))?;
  3215. let mut events = response_to_events(response, out)?;
  3216. push_prompt_cache_record(&self.client, &mut events);
  3217. Ok(events)
  3218. })
  3219. }
  3220. }
  3221. fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
  3222. summary
  3223. .assistant_messages
  3224. .last()
  3225. .map(|message| {
  3226. message
  3227. .blocks
  3228. .iter()
  3229. .filter_map(|block| match block {
  3230. ContentBlock::Text { text } => Some(text.as_str()),
  3231. _ => None,
  3232. })
  3233. .collect::<Vec<_>>()
  3234. .join("")
  3235. })
  3236. .unwrap_or_default()
  3237. }
  3238. fn collect_tool_uses(summary: &runtime::TurnSummary) -> Vec<serde_json::Value> {
  3239. summary
  3240. .assistant_messages
  3241. .iter()
  3242. .flat_map(|message| message.blocks.iter())
  3243. .filter_map(|block| match block {
  3244. ContentBlock::ToolUse { id, name, input } => Some(json!({
  3245. "id": id,
  3246. "name": name,
  3247. "input": input,
  3248. })),
  3249. _ => None,
  3250. })
  3251. .collect()
  3252. }
  3253. fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec<serde_json::Value> {
  3254. summary
  3255. .tool_results
  3256. .iter()
  3257. .flat_map(|message| message.blocks.iter())
  3258. .filter_map(|block| match block {
  3259. ContentBlock::ToolResult {
  3260. tool_use_id,
  3261. tool_name,
  3262. output,
  3263. is_error,
  3264. } => Some(json!({
  3265. "tool_use_id": tool_use_id,
  3266. "tool_name": tool_name,
  3267. "output": output,
  3268. "is_error": is_error,
  3269. })),
  3270. _ => None,
  3271. })
  3272. .collect()
  3273. }
  3274. fn collect_prompt_cache_events(summary: &runtime::TurnSummary) -> Vec<serde_json::Value> {
  3275. summary
  3276. .prompt_cache_events
  3277. .iter()
  3278. .map(|event| {
  3279. json!({
  3280. "unexpected": event.unexpected,
  3281. "reason": event.reason,
  3282. "previous_cache_read_input_tokens": event.previous_cache_read_input_tokens,
  3283. "current_cache_read_input_tokens": event.current_cache_read_input_tokens,
  3284. "token_drop": event.token_drop,
  3285. })
  3286. })
  3287. .collect()
  3288. }
  3289. fn print_prompt_cache_events(summary: &runtime::TurnSummary) {
  3290. for event in &summary.prompt_cache_events {
  3291. let label = if event.unexpected {
  3292. "Prompt cache break"
  3293. } else {
  3294. "Prompt cache invalidation"
  3295. };
  3296. println!(
  3297. "{label}: {} (cache read {} -> {}, drop {})",
  3298. event.reason,
  3299. event.previous_cache_read_input_tokens,
  3300. event.current_cache_read_input_tokens,
  3301. event.token_drop,
  3302. );
  3303. }
  3304. }
  3305. fn slash_command_completion_candidates() -> Vec<String> {
  3306. slash_command_specs()
  3307. .iter()
  3308. .flat_map(|spec| {
  3309. std::iter::once(spec.name)
  3310. .chain(spec.aliases.iter().copied())
  3311. .map(|name| format!("/{name}"))
  3312. .collect::<Vec<_>>()
  3313. })
  3314. .collect()
  3315. }
  3316. fn format_tool_call_start(name: &str, input: &str) -> String {
  3317. let parsed: serde_json::Value =
  3318. serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string()));
  3319. let detail = match name {
  3320. "bash" | "Bash" => format_bash_call(&parsed),
  3321. "read_file" | "Read" => {
  3322. let path = extract_tool_path(&parsed);
  3323. format!("\x1b[2m📄 Reading {path}…\x1b[0m")
  3324. }
  3325. "write_file" | "Write" => {
  3326. let path = extract_tool_path(&parsed);
  3327. let lines = parsed
  3328. .get("content")
  3329. .and_then(|value| value.as_str())
  3330. .map_or(0, |content| content.lines().count());
  3331. format!("\x1b[1;32m✏️ Writing {path}\x1b[0m \x1b[2m({lines} lines)\x1b[0m")
  3332. }
  3333. "edit_file" | "Edit" => {
  3334. let path = extract_tool_path(&parsed);
  3335. let old_value = parsed
  3336. .get("old_string")
  3337. .or_else(|| parsed.get("oldString"))
  3338. .and_then(|value| value.as_str())
  3339. .unwrap_or_default();
  3340. let new_value = parsed
  3341. .get("new_string")
  3342. .or_else(|| parsed.get("newString"))
  3343. .and_then(|value| value.as_str())
  3344. .unwrap_or_default();
  3345. format!(
  3346. "\x1b[1;33m📝 Editing {path}\x1b[0m{}",
  3347. format_patch_preview(old_value, new_value)
  3348. .map(|preview| format!("\n{preview}"))
  3349. .unwrap_or_default()
  3350. )
  3351. }
  3352. "glob_search" | "Glob" => format_search_start("🔎 Glob", &parsed),
  3353. "grep_search" | "Grep" => format_search_start("🔎 Grep", &parsed),
  3354. "web_search" | "WebSearch" => parsed
  3355. .get("query")
  3356. .and_then(|value| value.as_str())
  3357. .unwrap_or("?")
  3358. .to_string(),
  3359. _ => summarize_tool_payload(input),
  3360. };
  3361. let border = "─".repeat(name.len() + 8);
  3362. format!(
  3363. "\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"
  3364. )
  3365. }
  3366. fn format_tool_result(name: &str, output: &str, is_error: bool) -> String {
  3367. let icon = if is_error {
  3368. "\x1b[1;31m✗\x1b[0m"
  3369. } else {
  3370. "\x1b[1;32m✓\x1b[0m"
  3371. };
  3372. if is_error {
  3373. let summary = truncate_for_summary(output.trim(), 160);
  3374. return if summary.is_empty() {
  3375. format!("{icon} \x1b[38;5;245m{name}\x1b[0m")
  3376. } else {
  3377. format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n\x1b[38;5;203m{summary}\x1b[0m")
  3378. };
  3379. }
  3380. let parsed: serde_json::Value =
  3381. serde_json::from_str(output).unwrap_or(serde_json::Value::String(output.to_string()));
  3382. match name {
  3383. "bash" | "Bash" => format_bash_result(icon, &parsed),
  3384. "read_file" | "Read" => format_read_result(icon, &parsed),
  3385. "write_file" | "Write" => format_write_result(icon, &parsed),
  3386. "edit_file" | "Edit" => format_edit_result(icon, &parsed),
  3387. "glob_search" | "Glob" => format_glob_result(icon, &parsed),
  3388. "grep_search" | "Grep" => format_grep_result(icon, &parsed),
  3389. _ => format_generic_tool_result(icon, name, &parsed),
  3390. }
  3391. }
  3392. const DISPLAY_TRUNCATION_NOTICE: &str =
  3393. "\x1b[2m… output truncated for display; full result preserved in session.\x1b[0m";
  3394. const READ_DISPLAY_MAX_LINES: usize = 80;
  3395. const READ_DISPLAY_MAX_CHARS: usize = 6_000;
  3396. const TOOL_OUTPUT_DISPLAY_MAX_LINES: usize = 60;
  3397. const TOOL_OUTPUT_DISPLAY_MAX_CHARS: usize = 4_000;
  3398. fn extract_tool_path(parsed: &serde_json::Value) -> String {
  3399. parsed
  3400. .get("file_path")
  3401. .or_else(|| parsed.get("filePath"))
  3402. .or_else(|| parsed.get("path"))
  3403. .and_then(|value| value.as_str())
  3404. .unwrap_or("?")
  3405. .to_string()
  3406. }
  3407. fn format_search_start(label: &str, parsed: &serde_json::Value) -> String {
  3408. let pattern = parsed
  3409. .get("pattern")
  3410. .and_then(|value| value.as_str())
  3411. .unwrap_or("?");
  3412. let scope = parsed
  3413. .get("path")
  3414. .and_then(|value| value.as_str())
  3415. .unwrap_or(".");
  3416. format!("{label} {pattern}\n\x1b[2min {scope}\x1b[0m")
  3417. }
  3418. fn format_patch_preview(old_value: &str, new_value: &str) -> Option<String> {
  3419. if old_value.is_empty() && new_value.is_empty() {
  3420. return None;
  3421. }
  3422. Some(format!(
  3423. "\x1b[38;5;203m- {}\x1b[0m\n\x1b[38;5;70m+ {}\x1b[0m",
  3424. truncate_for_summary(first_visible_line(old_value), 72),
  3425. truncate_for_summary(first_visible_line(new_value), 72)
  3426. ))
  3427. }
  3428. fn format_bash_call(parsed: &serde_json::Value) -> String {
  3429. let command = parsed
  3430. .get("command")
  3431. .and_then(|value| value.as_str())
  3432. .unwrap_or_default();
  3433. if command.is_empty() {
  3434. String::new()
  3435. } else {
  3436. format!(
  3437. "\x1b[48;5;236;38;5;255m $ {} \x1b[0m",
  3438. truncate_for_summary(command, 160)
  3439. )
  3440. }
  3441. }
  3442. fn first_visible_line(text: &str) -> &str {
  3443. text.lines()
  3444. .find(|line| !line.trim().is_empty())
  3445. .unwrap_or(text)
  3446. }
  3447. fn format_bash_result(icon: &str, parsed: &serde_json::Value) -> String {
  3448. use std::fmt::Write as _;
  3449. let mut lines = vec![format!("{icon} \x1b[38;5;245mbash\x1b[0m")];
  3450. if let Some(task_id) = parsed
  3451. .get("backgroundTaskId")
  3452. .and_then(|value| value.as_str())
  3453. {
  3454. write!(&mut lines[0], " backgrounded ({task_id})").expect("write to string");
  3455. } else if let Some(status) = parsed
  3456. .get("returnCodeInterpretation")
  3457. .and_then(|value| value.as_str())
  3458. .filter(|status| !status.is_empty())
  3459. {
  3460. write!(&mut lines[0], " {status}").expect("write to string");
  3461. }
  3462. if let Some(stdout) = parsed.get("stdout").and_then(|value| value.as_str()) {
  3463. if !stdout.trim().is_empty() {
  3464. lines.push(truncate_output_for_display(
  3465. stdout,
  3466. TOOL_OUTPUT_DISPLAY_MAX_LINES,
  3467. TOOL_OUTPUT_DISPLAY_MAX_CHARS,
  3468. ));
  3469. }
  3470. }
  3471. if let Some(stderr) = parsed.get("stderr").and_then(|value| value.as_str()) {
  3472. if !stderr.trim().is_empty() {
  3473. lines.push(format!(
  3474. "\x1b[38;5;203m{}\x1b[0m",
  3475. truncate_output_for_display(
  3476. stderr,
  3477. TOOL_OUTPUT_DISPLAY_MAX_LINES,
  3478. TOOL_OUTPUT_DISPLAY_MAX_CHARS,
  3479. )
  3480. ));
  3481. }
  3482. }
  3483. lines.join("\n\n")
  3484. }
  3485. fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String {
  3486. let file = parsed.get("file").unwrap_or(parsed);
  3487. let path = extract_tool_path(file);
  3488. let start_line = file
  3489. .get("startLine")
  3490. .and_then(serde_json::Value::as_u64)
  3491. .unwrap_or(1);
  3492. let num_lines = file
  3493. .get("numLines")
  3494. .and_then(serde_json::Value::as_u64)
  3495. .unwrap_or(0);
  3496. let total_lines = file
  3497. .get("totalLines")
  3498. .and_then(serde_json::Value::as_u64)
  3499. .unwrap_or(num_lines);
  3500. let content = file
  3501. .get("content")
  3502. .and_then(|value| value.as_str())
  3503. .unwrap_or_default();
  3504. let end_line = start_line.saturating_add(num_lines.saturating_sub(1));
  3505. format!(
  3506. "{icon} \x1b[2m📄 Read {path} (lines {}-{} of {})\x1b[0m\n{}",
  3507. start_line,
  3508. end_line.max(start_line),
  3509. total_lines,
  3510. truncate_output_for_display(content, READ_DISPLAY_MAX_LINES, READ_DISPLAY_MAX_CHARS)
  3511. )
  3512. }
  3513. fn format_write_result(icon: &str, parsed: &serde_json::Value) -> String {
  3514. let path = extract_tool_path(parsed);
  3515. let kind = parsed
  3516. .get("type")
  3517. .and_then(|value| value.as_str())
  3518. .unwrap_or("write");
  3519. let line_count = parsed
  3520. .get("content")
  3521. .and_then(|value| value.as_str())
  3522. .map_or(0, |content| content.lines().count());
  3523. format!(
  3524. "{icon} \x1b[1;32m✏️ {} {path}\x1b[0m \x1b[2m({line_count} lines)\x1b[0m",
  3525. if kind == "create" { "Wrote" } else { "Updated" },
  3526. )
  3527. }
  3528. fn format_structured_patch_preview(parsed: &serde_json::Value) -> Option<String> {
  3529. let hunks = parsed.get("structuredPatch")?.as_array()?;
  3530. let mut preview = Vec::new();
  3531. for hunk in hunks.iter().take(2) {
  3532. let lines = hunk.get("lines")?.as_array()?;
  3533. for line in lines.iter().filter_map(|value| value.as_str()).take(6) {
  3534. match line.chars().next() {
  3535. Some('+') => preview.push(format!("\x1b[38;5;70m{line}\x1b[0m")),
  3536. Some('-') => preview.push(format!("\x1b[38;5;203m{line}\x1b[0m")),
  3537. _ => preview.push(line.to_string()),
  3538. }
  3539. }
  3540. }
  3541. if preview.is_empty() {
  3542. None
  3543. } else {
  3544. Some(preview.join("\n"))
  3545. }
  3546. }
  3547. fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String {
  3548. let path = extract_tool_path(parsed);
  3549. let suffix = if parsed
  3550. .get("replaceAll")
  3551. .and_then(serde_json::Value::as_bool)
  3552. .unwrap_or(false)
  3553. {
  3554. " (replace all)"
  3555. } else {
  3556. ""
  3557. };
  3558. let preview = format_structured_patch_preview(parsed).or_else(|| {
  3559. let old_value = parsed
  3560. .get("oldString")
  3561. .and_then(|value| value.as_str())
  3562. .unwrap_or_default();
  3563. let new_value = parsed
  3564. .get("newString")
  3565. .and_then(|value| value.as_str())
  3566. .unwrap_or_default();
  3567. format_patch_preview(old_value, new_value)
  3568. });
  3569. match preview {
  3570. Some(preview) => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m\n{preview}"),
  3571. None => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m"),
  3572. }
  3573. }
  3574. fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String {
  3575. let num_files = parsed
  3576. .get("numFiles")
  3577. .and_then(serde_json::Value::as_u64)
  3578. .unwrap_or(0);
  3579. let filenames = parsed
  3580. .get("filenames")
  3581. .and_then(|value| value.as_array())
  3582. .map(|files| {
  3583. files
  3584. .iter()
  3585. .filter_map(|value| value.as_str())
  3586. .take(8)
  3587. .collect::<Vec<_>>()
  3588. .join("\n")
  3589. })
  3590. .unwrap_or_default();
  3591. if filenames.is_empty() {
  3592. format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files")
  3593. } else {
  3594. format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files\n{filenames}")
  3595. }
  3596. }
  3597. fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String {
  3598. let num_matches = parsed
  3599. .get("numMatches")
  3600. .and_then(serde_json::Value::as_u64)
  3601. .unwrap_or(0);
  3602. let num_files = parsed
  3603. .get("numFiles")
  3604. .and_then(serde_json::Value::as_u64)
  3605. .unwrap_or(0);
  3606. let content = parsed
  3607. .get("content")
  3608. .and_then(|value| value.as_str())
  3609. .unwrap_or_default();
  3610. let filenames = parsed
  3611. .get("filenames")
  3612. .and_then(|value| value.as_array())
  3613. .map(|files| {
  3614. files
  3615. .iter()
  3616. .filter_map(|value| value.as_str())
  3617. .take(8)
  3618. .collect::<Vec<_>>()
  3619. .join("\n")
  3620. })
  3621. .unwrap_or_default();
  3622. let summary = format!(
  3623. "{icon} \x1b[38;5;245mgrep_search\x1b[0m {num_matches} matches across {num_files} files"
  3624. );
  3625. if !content.trim().is_empty() {
  3626. format!(
  3627. "{summary}\n{}",
  3628. truncate_output_for_display(
  3629. content,
  3630. TOOL_OUTPUT_DISPLAY_MAX_LINES,
  3631. TOOL_OUTPUT_DISPLAY_MAX_CHARS,
  3632. )
  3633. )
  3634. } else if !filenames.is_empty() {
  3635. format!("{summary}\n{filenames}")
  3636. } else {
  3637. summary
  3638. }
  3639. }
  3640. fn format_generic_tool_result(icon: &str, name: &str, parsed: &serde_json::Value) -> String {
  3641. let rendered_output = match parsed {
  3642. serde_json::Value::String(text) => text.clone(),
  3643. serde_json::Value::Null => String::new(),
  3644. serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
  3645. serde_json::to_string_pretty(parsed).unwrap_or_else(|_| parsed.to_string())
  3646. }
  3647. _ => parsed.to_string(),
  3648. };
  3649. let preview = truncate_output_for_display(
  3650. &rendered_output,
  3651. TOOL_OUTPUT_DISPLAY_MAX_LINES,
  3652. TOOL_OUTPUT_DISPLAY_MAX_CHARS,
  3653. );
  3654. if preview.is_empty() {
  3655. format!("{icon} \x1b[38;5;245m{name}\x1b[0m")
  3656. } else if preview.contains('\n') {
  3657. format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n{preview}")
  3658. } else {
  3659. format!("{icon} \x1b[38;5;245m{name}:\x1b[0m {preview}")
  3660. }
  3661. }
  3662. fn summarize_tool_payload(payload: &str) -> String {
  3663. let compact = match serde_json::from_str::<serde_json::Value>(payload) {
  3664. Ok(value) => value.to_string(),
  3665. Err(_) => payload.trim().to_string(),
  3666. };
  3667. truncate_for_summary(&compact, 96)
  3668. }
  3669. fn truncate_for_summary(value: &str, limit: usize) -> String {
  3670. let mut chars = value.chars();
  3671. let truncated = chars.by_ref().take(limit).collect::<String>();
  3672. if chars.next().is_some() {
  3673. format!("{truncated}…")
  3674. } else {
  3675. truncated
  3676. }
  3677. }
  3678. fn truncate_output_for_display(content: &str, max_lines: usize, max_chars: usize) -> String {
  3679. let original = content.trim_end_matches('\n');
  3680. if original.is_empty() {
  3681. return String::new();
  3682. }
  3683. let mut preview_lines = Vec::new();
  3684. let mut used_chars = 0usize;
  3685. let mut truncated = false;
  3686. for (index, line) in original.lines().enumerate() {
  3687. if index >= max_lines {
  3688. truncated = true;
  3689. break;
  3690. }
  3691. let newline_cost = usize::from(!preview_lines.is_empty());
  3692. let available = max_chars.saturating_sub(used_chars + newline_cost);
  3693. if available == 0 {
  3694. truncated = true;
  3695. break;
  3696. }
  3697. let line_chars = line.chars().count();
  3698. if line_chars > available {
  3699. preview_lines.push(line.chars().take(available).collect::<String>());
  3700. truncated = true;
  3701. break;
  3702. }
  3703. preview_lines.push(line.to_string());
  3704. used_chars += newline_cost + line_chars;
  3705. }
  3706. let mut preview = preview_lines.join("\n");
  3707. if truncated {
  3708. if !preview.is_empty() {
  3709. preview.push('\n');
  3710. }
  3711. preview.push_str(DISPLAY_TRUNCATION_NOTICE);
  3712. }
  3713. preview
  3714. }
  3715. fn push_output_block(
  3716. block: OutputContentBlock,
  3717. out: &mut (impl Write + ?Sized),
  3718. events: &mut Vec<AssistantEvent>,
  3719. pending_tool: &mut Option<(String, String, String)>,
  3720. streaming_tool_input: bool,
  3721. ) -> Result<(), RuntimeError> {
  3722. match block {
  3723. OutputContentBlock::Text { text } => {
  3724. if !text.is_empty() {
  3725. let rendered = TerminalRenderer::new().markdown_to_ansi(&text);
  3726. write!(out, "{rendered}")
  3727. .and_then(|()| out.flush())
  3728. .map_err(|error| RuntimeError::new(error.to_string()))?;
  3729. events.push(AssistantEvent::TextDelta(text));
  3730. }
  3731. }
  3732. OutputContentBlock::ToolUse { id, name, input } => {
  3733. // During streaming, the initial content_block_start has an empty input ({}).
  3734. // The real input arrives via input_json_delta events. In
  3735. // non-streaming responses, preserve a legitimate empty object.
  3736. let initial_input = if streaming_tool_input
  3737. && input.is_object()
  3738. && input.as_object().is_some_and(serde_json::Map::is_empty)
  3739. {
  3740. String::new()
  3741. } else {
  3742. input.to_string()
  3743. };
  3744. *pending_tool = Some((id, name, initial_input));
  3745. }
  3746. OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => {}
  3747. }
  3748. Ok(())
  3749. }
  3750. fn response_to_events(
  3751. response: MessageResponse,
  3752. out: &mut (impl Write + ?Sized),
  3753. ) -> Result<Vec<AssistantEvent>, RuntimeError> {
  3754. let mut events = Vec::new();
  3755. let mut pending_tool = None;
  3756. for block in response.content {
  3757. push_output_block(block, out, &mut events, &mut pending_tool, false)?;
  3758. if let Some((id, name, input)) = pending_tool.take() {
  3759. events.push(AssistantEvent::ToolUse { id, name, input });
  3760. }
  3761. }
  3762. events.push(AssistantEvent::Usage(response.usage.token_usage()));
  3763. events.push(AssistantEvent::MessageStop);
  3764. Ok(events)
  3765. }
  3766. fn push_prompt_cache_record(client: &AnthropicClient, events: &mut Vec<AssistantEvent>) {
  3767. if let Some(record) = client.take_last_prompt_cache_record() {
  3768. if let Some(event) = prompt_cache_record_to_runtime_event(record) {
  3769. events.push(AssistantEvent::PromptCache(event));
  3770. }
  3771. }
  3772. }
  3773. fn prompt_cache_record_to_runtime_event(record: api::PromptCacheRecord) -> Option<PromptCacheEvent> {
  3774. let cache_break = record.cache_break?;
  3775. Some(PromptCacheEvent {
  3776. unexpected: cache_break.unexpected,
  3777. reason: cache_break.reason,
  3778. previous_cache_read_input_tokens: cache_break.previous_cache_read_input_tokens,
  3779. current_cache_read_input_tokens: cache_break.current_cache_read_input_tokens,
  3780. token_drop: cache_break.token_drop,
  3781. })
  3782. }
  3783. struct CliToolExecutor {
  3784. renderer: TerminalRenderer,
  3785. emit_output: bool,
  3786. allowed_tools: Option<AllowedToolSet>,
  3787. tool_registry: GlobalToolRegistry,
  3788. }
  3789. impl CliToolExecutor {
  3790. fn new(
  3791. allowed_tools: Option<AllowedToolSet>,
  3792. emit_output: bool,
  3793. tool_registry: GlobalToolRegistry,
  3794. ) -> Self {
  3795. Self {
  3796. renderer: TerminalRenderer::new(),
  3797. emit_output,
  3798. allowed_tools,
  3799. tool_registry,
  3800. }
  3801. }
  3802. }
  3803. impl ToolExecutor for CliToolExecutor {
  3804. fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
  3805. if self
  3806. .allowed_tools
  3807. .as_ref()
  3808. .is_some_and(|allowed| !allowed.contains(tool_name))
  3809. {
  3810. return Err(ToolError::new(format!(
  3811. "tool `{tool_name}` is not enabled by the current --allowedTools setting"
  3812. )));
  3813. }
  3814. let value = serde_json::from_str(input)
  3815. .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
  3816. match self.tool_registry.execute(tool_name, &value) {
  3817. Ok(output) => {
  3818. if self.emit_output {
  3819. let markdown = format_tool_result(tool_name, &output, false);
  3820. self.renderer
  3821. .stream_markdown(&markdown, &mut io::stdout())
  3822. .map_err(|error| ToolError::new(error.to_string()))?;
  3823. }
  3824. Ok(output)
  3825. }
  3826. Err(error) => {
  3827. if self.emit_output {
  3828. let markdown = format_tool_result(tool_name, &error, true);
  3829. self.renderer
  3830. .stream_markdown(&markdown, &mut io::stdout())
  3831. .map_err(|stream_error| ToolError::new(stream_error.to_string()))?;
  3832. }
  3833. Err(ToolError::new(error))
  3834. }
  3835. }
  3836. }
  3837. }
  3838. fn permission_policy(
  3839. mode: PermissionMode,
  3840. feature_config: &runtime::RuntimeFeatureConfig,
  3841. tool_registry: &GlobalToolRegistry,
  3842. ) -> PermissionPolicy {
  3843. tool_registry.permission_specs(None).into_iter().fold(
  3844. PermissionPolicy::new(mode).with_permission_rules(feature_config.permission_rules()),
  3845. |policy, (name, required_permission)| {
  3846. policy.with_tool_requirement(name, required_permission)
  3847. },
  3848. )
  3849. }
  3850. fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
  3851. messages
  3852. .iter()
  3853. .filter_map(|message| {
  3854. let role = match message.role {
  3855. MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
  3856. MessageRole::Assistant => "assistant",
  3857. };
  3858. let content = message
  3859. .blocks
  3860. .iter()
  3861. .map(|block| match block {
  3862. ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
  3863. ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
  3864. id: id.clone(),
  3865. name: name.clone(),
  3866. input: serde_json::from_str(input)
  3867. .unwrap_or_else(|_| serde_json::json!({ "raw": input })),
  3868. },
  3869. ContentBlock::ToolResult {
  3870. tool_use_id,
  3871. output,
  3872. is_error,
  3873. ..
  3874. } => InputContentBlock::ToolResult {
  3875. tool_use_id: tool_use_id.clone(),
  3876. content: vec![ToolResultContentBlock::Text {
  3877. text: output.clone(),
  3878. }],
  3879. is_error: *is_error,
  3880. },
  3881. })
  3882. .collect::<Vec<_>>();
  3883. (!content.is_empty()).then(|| InputMessage {
  3884. role: role.to_string(),
  3885. content,
  3886. })
  3887. })
  3888. .collect()
  3889. }
  3890. fn print_help_to(out: &mut impl Write) -> io::Result<()> {
  3891. writeln!(out, "claw v{VERSION}")?;
  3892. writeln!(out)?;
  3893. writeln!(out, "Usage:")?;
  3894. writeln!(
  3895. out,
  3896. " claw [--model MODEL] [--allowedTools TOOL[,TOOL...]]"
  3897. )?;
  3898. writeln!(out, " Start the interactive REPL")?;
  3899. writeln!(
  3900. out,
  3901. " claw [--model MODEL] [--output-format text|json] prompt TEXT"
  3902. )?;
  3903. writeln!(out, " Send one prompt and exit")?;
  3904. writeln!(
  3905. out,
  3906. " claw [--model MODEL] [--output-format text|json] TEXT"
  3907. )?;
  3908. writeln!(out, " Shorthand non-interactive prompt mode")?;
  3909. writeln!(
  3910. out,
  3911. " claw --resume SESSION.jsonl [/status] [/compact] [...]"
  3912. )?;
  3913. writeln!(
  3914. out,
  3915. " Inspect or maintain a saved session without entering the REPL"
  3916. )?;
  3917. writeln!(out, " claw dump-manifests")?;
  3918. writeln!(out, " claw bootstrap-plan")?;
  3919. writeln!(out, " claw agents")?;
  3920. writeln!(out, " claw skills")?;
  3921. writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?;
  3922. writeln!(out, " claw login")?;
  3923. writeln!(out, " claw logout")?;
  3924. writeln!(out, " claw init")?;
  3925. writeln!(out)?;
  3926. writeln!(out, "Flags:")?;
  3927. writeln!(
  3928. out,
  3929. " --model MODEL Override the active model"
  3930. )?;
  3931. writeln!(
  3932. out,
  3933. " --output-format FORMAT Non-interactive output format: text or json"
  3934. )?;
  3935. writeln!(
  3936. out,
  3937. " --permission-mode MODE Set read-only, workspace-write, or danger-full-access"
  3938. )?;
  3939. writeln!(
  3940. out,
  3941. " --dangerously-skip-permissions Skip all permission checks"
  3942. )?;
  3943. writeln!(out, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)")?;
  3944. writeln!(
  3945. out,
  3946. " --version, -V Print version and build information locally"
  3947. )?;
  3948. writeln!(out)?;
  3949. writeln!(out, "Interactive slash commands:")?;
  3950. writeln!(out, "{}", render_slash_command_help())?;
  3951. writeln!(out)?;
  3952. let resume_commands = resume_supported_slash_commands()
  3953. .into_iter()
  3954. .map(|spec| match spec.argument_hint {
  3955. Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
  3956. None => format!("/{}", spec.name),
  3957. })
  3958. .collect::<Vec<_>>()
  3959. .join(", ");
  3960. writeln!(out, "Resume-safe commands: {resume_commands}")?;
  3961. writeln!(out, "Examples:")?;
  3962. writeln!(out, " claw --model claude-opus \"summarize this repo\"")?;
  3963. writeln!(
  3964. out,
  3965. " claw --output-format json prompt \"explain src/main.rs\""
  3966. )?;
  3967. writeln!(
  3968. out,
  3969. " claw --allowedTools read,glob \"summarize Cargo.toml\""
  3970. )?;
  3971. writeln!(
  3972. out,
  3973. " claw --resume session.jsonl /status /diff /export notes.txt"
  3974. )?;
  3975. writeln!(out, " claw agents")?;
  3976. writeln!(out, " claw /skills")?;
  3977. writeln!(out, " claw login")?;
  3978. writeln!(out, " claw init")?;
  3979. Ok(())
  3980. }
  3981. fn print_help() {
  3982. let _ = print_help_to(&mut io::stdout());
  3983. }
  3984. #[cfg(test)]
  3985. mod tests {
  3986. use super::{
  3987. describe_tool_progress, filter_tool_specs, format_compact_report, format_cost_report,
  3988. format_internal_prompt_progress_line, format_model_report, format_model_switch_report,
  3989. format_permissions_report,
  3990. format_permissions_switch_report, format_resume_report, format_status_report,
  3991. format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args,
  3992. parse_git_status_branch, parse_git_status_metadata_for, permission_policy,
  3993. print_help_to, push_output_block, render_config_report, render_diff_report,
  3994. render_memory_report, render_repl_help, resolve_model_alias, response_to_events,
  3995. resume_supported_slash_commands, run_resume_command, status_context, CliAction,
  3996. CliOutputFormat, InternalPromptProgressEvent,
  3997. InternalPromptProgressState, SlashCommand, StatusUsage, DEFAULT_MODEL,
  3998. create_managed_session_handle, resolve_session_reference,
  3999. };
  4000. use api::{MessageResponse, OutputContentBlock, Usage};
  4001. use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
  4002. use runtime::{
  4003. AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode, Session,
  4004. };
  4005. use serde_json::json;
  4006. use std::fs;
  4007. use std::path::{Path, PathBuf};
  4008. use std::process::Command;
  4009. use std::sync::{Mutex, MutexGuard, OnceLock};
  4010. use std::time::{Duration, SystemTime, UNIX_EPOCH};
  4011. use tools::GlobalToolRegistry;
  4012. fn registry_with_plugin_tool() -> GlobalToolRegistry {
  4013. GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
  4014. "plugin-demo@external",
  4015. "plugin-demo",
  4016. PluginToolDefinition {
  4017. name: "plugin_echo".to_string(),
  4018. description: Some("Echo plugin payload".to_string()),
  4019. input_schema: json!({
  4020. "type": "object",
  4021. "properties": {
  4022. "message": { "type": "string" }
  4023. },
  4024. "required": ["message"],
  4025. "additionalProperties": false
  4026. }),
  4027. },
  4028. "echo".to_string(),
  4029. Vec::new(),
  4030. PluginToolPermission::WorkspaceWrite,
  4031. None,
  4032. )])
  4033. .expect("plugin tool registry should build")
  4034. }
  4035. fn temp_dir() -> PathBuf {
  4036. let nanos = SystemTime::now()
  4037. .duration_since(UNIX_EPOCH)
  4038. .expect("time should be after epoch")
  4039. .as_nanos();
  4040. std::env::temp_dir().join(format!("rusty-claude-cli-{nanos}"))
  4041. }
  4042. fn git(args: &[&str], cwd: &Path) {
  4043. let status = Command::new("git")
  4044. .args(args)
  4045. .current_dir(cwd)
  4046. .status()
  4047. .expect("git command should run");
  4048. assert!(
  4049. status.success(),
  4050. "git command failed: git {}",
  4051. args.join(" ")
  4052. );
  4053. }
  4054. fn env_lock() -> MutexGuard<'static, ()> {
  4055. static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
  4056. LOCK.get_or_init(|| Mutex::new(()))
  4057. .lock()
  4058. .unwrap_or_else(std::sync::PoisonError::into_inner)
  4059. }
  4060. fn with_current_dir<T>(cwd: &Path, f: impl FnOnce() -> T) -> T {
  4061. let previous = std::env::current_dir().expect("cwd should load");
  4062. std::env::set_current_dir(cwd).expect("cwd should change");
  4063. let result = f();
  4064. std::env::set_current_dir(previous).expect("cwd should restore");
  4065. result
  4066. }
  4067. #[test]
  4068. fn defaults_to_repl_when_no_args() {
  4069. assert_eq!(
  4070. parse_args(&[]).expect("args should parse"),
  4071. CliAction::Repl {
  4072. model: DEFAULT_MODEL.to_string(),
  4073. allowed_tools: None,
  4074. permission_mode: PermissionMode::DangerFullAccess,
  4075. }
  4076. );
  4077. }
  4078. #[test]
  4079. fn parses_prompt_subcommand() {
  4080. let args = vec![
  4081. "prompt".to_string(),
  4082. "hello".to_string(),
  4083. "world".to_string(),
  4084. ];
  4085. assert_eq!(
  4086. parse_args(&args).expect("args should parse"),
  4087. CliAction::Prompt {
  4088. prompt: "hello world".to_string(),
  4089. model: DEFAULT_MODEL.to_string(),
  4090. output_format: CliOutputFormat::Text,
  4091. allowed_tools: None,
  4092. permission_mode: PermissionMode::DangerFullAccess,
  4093. }
  4094. );
  4095. }
  4096. #[test]
  4097. fn parses_bare_prompt_and_json_output_flag() {
  4098. let args = vec![
  4099. "--output-format=json".to_string(),
  4100. "--model".to_string(),
  4101. "claude-opus".to_string(),
  4102. "explain".to_string(),
  4103. "this".to_string(),
  4104. ];
  4105. assert_eq!(
  4106. parse_args(&args).expect("args should parse"),
  4107. CliAction::Prompt {
  4108. prompt: "explain this".to_string(),
  4109. model: "claude-opus".to_string(),
  4110. output_format: CliOutputFormat::Json,
  4111. allowed_tools: None,
  4112. permission_mode: PermissionMode::DangerFullAccess,
  4113. }
  4114. );
  4115. }
  4116. #[test]
  4117. fn resolves_model_aliases_in_args() {
  4118. let args = vec![
  4119. "--model".to_string(),
  4120. "opus".to_string(),
  4121. "explain".to_string(),
  4122. "this".to_string(),
  4123. ];
  4124. assert_eq!(
  4125. parse_args(&args).expect("args should parse"),
  4126. CliAction::Prompt {
  4127. prompt: "explain this".to_string(),
  4128. model: "claude-opus-4-6".to_string(),
  4129. output_format: CliOutputFormat::Text,
  4130. allowed_tools: None,
  4131. permission_mode: PermissionMode::DangerFullAccess,
  4132. }
  4133. );
  4134. }
  4135. #[test]
  4136. fn resolves_known_model_aliases() {
  4137. assert_eq!(resolve_model_alias("opus"), "claude-opus-4-6");
  4138. assert_eq!(resolve_model_alias("sonnet"), "claude-sonnet-4-6");
  4139. assert_eq!(resolve_model_alias("haiku"), "claude-haiku-4-5-20251213");
  4140. assert_eq!(resolve_model_alias("claude-opus"), "claude-opus");
  4141. }
  4142. #[test]
  4143. fn parses_version_flags_without_initializing_prompt_mode() {
  4144. assert_eq!(
  4145. parse_args(&["--version".to_string()]).expect("args should parse"),
  4146. CliAction::Version
  4147. );
  4148. assert_eq!(
  4149. parse_args(&["-V".to_string()]).expect("args should parse"),
  4150. CliAction::Version
  4151. );
  4152. }
  4153. #[test]
  4154. fn parses_permission_mode_flag() {
  4155. let args = vec!["--permission-mode=read-only".to_string()];
  4156. assert_eq!(
  4157. parse_args(&args).expect("args should parse"),
  4158. CliAction::Repl {
  4159. model: DEFAULT_MODEL.to_string(),
  4160. allowed_tools: None,
  4161. permission_mode: PermissionMode::ReadOnly,
  4162. }
  4163. );
  4164. }
  4165. #[test]
  4166. fn parses_allowed_tools_flags_with_aliases_and_lists() {
  4167. let args = vec![
  4168. "--allowedTools".to_string(),
  4169. "read,glob".to_string(),
  4170. "--allowed-tools=write_file".to_string(),
  4171. ];
  4172. assert_eq!(
  4173. parse_args(&args).expect("args should parse"),
  4174. CliAction::Repl {
  4175. model: DEFAULT_MODEL.to_string(),
  4176. allowed_tools: Some(
  4177. ["glob_search", "read_file", "write_file"]
  4178. .into_iter()
  4179. .map(str::to_string)
  4180. .collect()
  4181. ),
  4182. permission_mode: PermissionMode::DangerFullAccess,
  4183. }
  4184. );
  4185. }
  4186. #[test]
  4187. fn rejects_unknown_allowed_tools() {
  4188. let error = parse_args(&["--allowedTools".to_string(), "teleport".to_string()])
  4189. .expect_err("tool should be rejected");
  4190. assert!(error.contains("unsupported tool in --allowedTools: teleport"));
  4191. }
  4192. #[test]
  4193. fn parses_system_prompt_options() {
  4194. let args = vec![
  4195. "system-prompt".to_string(),
  4196. "--cwd".to_string(),
  4197. "/tmp/project".to_string(),
  4198. "--date".to_string(),
  4199. "2026-04-01".to_string(),
  4200. ];
  4201. assert_eq!(
  4202. parse_args(&args).expect("args should parse"),
  4203. CliAction::PrintSystemPrompt {
  4204. cwd: PathBuf::from("/tmp/project"),
  4205. date: "2026-04-01".to_string(),
  4206. }
  4207. );
  4208. }
  4209. #[test]
  4210. fn parses_login_and_logout_subcommands() {
  4211. assert_eq!(
  4212. parse_args(&["login".to_string()]).expect("login should parse"),
  4213. CliAction::Login
  4214. );
  4215. assert_eq!(
  4216. parse_args(&["logout".to_string()]).expect("logout should parse"),
  4217. CliAction::Logout
  4218. );
  4219. assert_eq!(
  4220. parse_args(&["init".to_string()]).expect("init should parse"),
  4221. CliAction::Init
  4222. );
  4223. assert_eq!(
  4224. parse_args(&["agents".to_string()]).expect("agents should parse"),
  4225. CliAction::Agents { args: None }
  4226. );
  4227. assert_eq!(
  4228. parse_args(&["skills".to_string()]).expect("skills should parse"),
  4229. CliAction::Skills { args: None }
  4230. );
  4231. assert_eq!(
  4232. parse_args(&["agents".to_string(), "--help".to_string()])
  4233. .expect("agents help should parse"),
  4234. CliAction::Agents {
  4235. args: Some("--help".to_string())
  4236. }
  4237. );
  4238. }
  4239. #[test]
  4240. fn parses_direct_agents_and_skills_slash_commands() {
  4241. assert_eq!(
  4242. parse_args(&["/agents".to_string()]).expect("/agents should parse"),
  4243. CliAction::Agents { args: None }
  4244. );
  4245. assert_eq!(
  4246. parse_args(&["/skills".to_string()]).expect("/skills should parse"),
  4247. CliAction::Skills { args: None }
  4248. );
  4249. assert_eq!(
  4250. parse_args(&["/skills".to_string(), "help".to_string()])
  4251. .expect("/skills help should parse"),
  4252. CliAction::Skills {
  4253. args: Some("help".to_string())
  4254. }
  4255. );
  4256. let error = parse_args(&["/status".to_string()])
  4257. .expect_err("/status should remain REPL-only when invoked directly");
  4258. assert!(error.contains("unsupported direct slash command"));
  4259. }
  4260. #[test]
  4261. fn parses_resume_flag_with_slash_command() {
  4262. let args = vec![
  4263. "--resume".to_string(),
  4264. "session.jsonl".to_string(),
  4265. "/compact".to_string(),
  4266. ];
  4267. assert_eq!(
  4268. parse_args(&args).expect("args should parse"),
  4269. CliAction::ResumeSession {
  4270. session_path: PathBuf::from("session.jsonl"),
  4271. commands: vec!["/compact".to_string()],
  4272. }
  4273. );
  4274. }
  4275. #[test]
  4276. fn parses_resume_flag_with_multiple_slash_commands() {
  4277. let args = vec![
  4278. "--resume".to_string(),
  4279. "session.jsonl".to_string(),
  4280. "/status".to_string(),
  4281. "/compact".to_string(),
  4282. "/cost".to_string(),
  4283. ];
  4284. assert_eq!(
  4285. parse_args(&args).expect("args should parse"),
  4286. CliAction::ResumeSession {
  4287. session_path: PathBuf::from("session.jsonl"),
  4288. commands: vec![
  4289. "/status".to_string(),
  4290. "/compact".to_string(),
  4291. "/cost".to_string(),
  4292. ],
  4293. }
  4294. );
  4295. }
  4296. #[test]
  4297. fn filtered_tool_specs_respect_allowlist() {
  4298. let allowed = ["read_file", "grep_search"]
  4299. .into_iter()
  4300. .map(str::to_string)
  4301. .collect();
  4302. let filtered = filter_tool_specs(&GlobalToolRegistry::builtin(), Some(&allowed));
  4303. let names = filtered
  4304. .into_iter()
  4305. .map(|spec| spec.name)
  4306. .collect::<Vec<_>>();
  4307. assert_eq!(names, vec!["read_file", "grep_search"]);
  4308. }
  4309. #[test]
  4310. fn filtered_tool_specs_include_plugin_tools() {
  4311. let filtered = filter_tool_specs(&registry_with_plugin_tool(), None);
  4312. let names = filtered
  4313. .into_iter()
  4314. .map(|definition| definition.name)
  4315. .collect::<Vec<_>>();
  4316. assert!(names.contains(&"bash".to_string()));
  4317. assert!(names.contains(&"plugin_echo".to_string()));
  4318. }
  4319. #[test]
  4320. fn permission_policy_uses_plugin_tool_permissions() {
  4321. let feature_config = runtime::RuntimeFeatureConfig::default();
  4322. let policy = permission_policy(
  4323. PermissionMode::ReadOnly,
  4324. &feature_config,
  4325. &registry_with_plugin_tool(),
  4326. );
  4327. let required = policy.required_mode_for("plugin_echo");
  4328. assert_eq!(required, PermissionMode::WorkspaceWrite);
  4329. }
  4330. #[test]
  4331. fn shared_help_uses_resume_annotation_copy() {
  4332. let help = commands::render_slash_command_help();
  4333. assert!(help.contains("Slash commands"));
  4334. assert!(help.contains("works with --resume SESSION.jsonl"));
  4335. }
  4336. #[test]
  4337. fn repl_help_includes_shared_commands_and_exit() {
  4338. let help = render_repl_help();
  4339. assert!(help.contains("REPL"));
  4340. assert!(help.contains("/help"));
  4341. assert!(help.contains("/status"));
  4342. assert!(help.contains("/sandbox"));
  4343. assert!(help.contains("/model [model]"));
  4344. assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
  4345. assert!(help.contains("/clear [--confirm]"));
  4346. assert!(help.contains("/cost"));
  4347. assert!(help.contains("/resume <session-path>"));
  4348. assert!(help.contains("/config [env|hooks|model|plugins]"));
  4349. assert!(help.contains("/memory"));
  4350. assert!(help.contains("/init"));
  4351. assert!(help.contains("/diff"));
  4352. assert!(help.contains("/version"));
  4353. assert!(help.contains("/export [file]"));
  4354. assert!(help.contains("/session [list|switch <session-id>|fork [branch-name]]"));
  4355. assert!(help.contains(
  4356. "/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
  4357. ));
  4358. assert!(help.contains("aliases: /plugins, /marketplace"));
  4359. assert!(help.contains("/agents"));
  4360. assert!(help.contains("/skills"));
  4361. assert!(help.contains("/exit"));
  4362. }
  4363. #[test]
  4364. fn resume_supported_command_list_matches_expected_surface() {
  4365. let names = resume_supported_slash_commands()
  4366. .into_iter()
  4367. .map(|spec| spec.name)
  4368. .collect::<Vec<_>>();
  4369. assert_eq!(
  4370. names,
  4371. vec![
  4372. "help", "status", "sandbox", "compact", "clear", "cost", "config", "memory",
  4373. "init", "diff", "version", "export", "agents", "skills",
  4374. ]
  4375. );
  4376. }
  4377. #[test]
  4378. fn resume_report_uses_sectioned_layout() {
  4379. let report = format_resume_report("session.jsonl", 14, 6);
  4380. assert!(report.contains("Session resumed"));
  4381. assert!(report.contains("Session file session.jsonl"));
  4382. assert!(report.contains("Messages 14"));
  4383. assert!(report.contains("Turns 6"));
  4384. }
  4385. #[test]
  4386. fn compact_report_uses_structured_output() {
  4387. let compacted = format_compact_report(8, 5, false);
  4388. assert!(compacted.contains("Compact"));
  4389. assert!(compacted.contains("Result compacted"));
  4390. assert!(compacted.contains("Messages removed 8"));
  4391. let skipped = format_compact_report(0, 3, true);
  4392. assert!(skipped.contains("Result skipped"));
  4393. }
  4394. #[test]
  4395. fn cost_report_uses_sectioned_layout() {
  4396. let report = format_cost_report(runtime::TokenUsage {
  4397. input_tokens: 20,
  4398. output_tokens: 8,
  4399. cache_creation_input_tokens: 3,
  4400. cache_read_input_tokens: 1,
  4401. });
  4402. assert!(report.contains("Cost"));
  4403. assert!(report.contains("Input tokens 20"));
  4404. assert!(report.contains("Output tokens 8"));
  4405. assert!(report.contains("Cache create 3"));
  4406. assert!(report.contains("Cache read 1"));
  4407. assert!(report.contains("Total tokens 32"));
  4408. }
  4409. #[test]
  4410. fn permissions_report_uses_sectioned_layout() {
  4411. let report = format_permissions_report("workspace-write");
  4412. assert!(report.contains("Permissions"));
  4413. assert!(report.contains("Active mode workspace-write"));
  4414. assert!(report.contains("Modes"));
  4415. assert!(report.contains("read-only ○ available Read/search tools only"));
  4416. assert!(report.contains("workspace-write ● current Edit files inside the workspace"));
  4417. assert!(report.contains("danger-full-access ○ available Unrestricted tool access"));
  4418. }
  4419. #[test]
  4420. fn permissions_switch_report_is_structured() {
  4421. let report = format_permissions_switch_report("read-only", "workspace-write");
  4422. assert!(report.contains("Permissions updated"));
  4423. assert!(report.contains("Result mode switched"));
  4424. assert!(report.contains("Previous mode read-only"));
  4425. assert!(report.contains("Active mode workspace-write"));
  4426. assert!(report.contains("Applies to subsequent tool calls"));
  4427. }
  4428. #[test]
  4429. fn init_help_mentions_direct_subcommand() {
  4430. let mut help = Vec::new();
  4431. print_help_to(&mut help).expect("help should render");
  4432. let help = String::from_utf8(help).expect("help should be utf8");
  4433. assert!(help.contains("claw init"));
  4434. assert!(help.contains("claw agents"));
  4435. assert!(help.contains("claw skills"));
  4436. assert!(help.contains("claw /skills"));
  4437. }
  4438. #[test]
  4439. fn model_report_uses_sectioned_layout() {
  4440. let report = format_model_report("claude-sonnet", 12, 4);
  4441. assert!(report.contains("Model"));
  4442. assert!(report.contains("Current model claude-sonnet"));
  4443. assert!(report.contains("Session messages 12"));
  4444. assert!(report.contains("Switch models with /model <name>"));
  4445. }
  4446. #[test]
  4447. fn model_switch_report_preserves_context_summary() {
  4448. let report = format_model_switch_report("claude-sonnet", "claude-opus", 9);
  4449. assert!(report.contains("Model updated"));
  4450. assert!(report.contains("Previous claude-sonnet"));
  4451. assert!(report.contains("Current claude-opus"));
  4452. assert!(report.contains("Preserved msgs 9"));
  4453. }
  4454. #[test]
  4455. fn status_line_reports_model_and_token_totals() {
  4456. let status = format_status_report(
  4457. "claude-sonnet",
  4458. StatusUsage {
  4459. message_count: 7,
  4460. turns: 3,
  4461. latest: runtime::TokenUsage {
  4462. input_tokens: 5,
  4463. output_tokens: 4,
  4464. cache_creation_input_tokens: 1,
  4465. cache_read_input_tokens: 0,
  4466. },
  4467. cumulative: runtime::TokenUsage {
  4468. input_tokens: 20,
  4469. output_tokens: 8,
  4470. cache_creation_input_tokens: 2,
  4471. cache_read_input_tokens: 1,
  4472. },
  4473. estimated_tokens: 128,
  4474. },
  4475. "workspace-write",
  4476. &super::StatusContext {
  4477. cwd: PathBuf::from("/tmp/project"),
  4478. session_path: Some(PathBuf::from("session.jsonl")),
  4479. loaded_config_files: 2,
  4480. discovered_config_files: 3,
  4481. memory_file_count: 4,
  4482. project_root: Some(PathBuf::from("/tmp")),
  4483. git_branch: Some("main".to_string()),
  4484. sandbox_status: runtime::SandboxStatus::default(),
  4485. },
  4486. );
  4487. assert!(status.contains("Status"));
  4488. assert!(status.contains("Model claude-sonnet"));
  4489. assert!(status.contains("Permission mode workspace-write"));
  4490. assert!(status.contains("Messages 7"));
  4491. assert!(status.contains("Latest total 10"));
  4492. assert!(status.contains("Cumulative total 31"));
  4493. assert!(status.contains("Cwd /tmp/project"));
  4494. assert!(status.contains("Project root /tmp"));
  4495. assert!(status.contains("Git branch main"));
  4496. assert!(status.contains("Session session.jsonl"));
  4497. assert!(status.contains("Config files loaded 2/3"));
  4498. assert!(status.contains("Memory files 4"));
  4499. }
  4500. #[test]
  4501. fn config_report_supports_section_views() {
  4502. let report = render_config_report(Some("env")).expect("config report should render");
  4503. assert!(report.contains("Merged section: env"));
  4504. let plugins_report =
  4505. render_config_report(Some("plugins")).expect("plugins config report should render");
  4506. assert!(plugins_report.contains("Merged section: plugins"));
  4507. }
  4508. #[test]
  4509. fn memory_report_uses_sectioned_layout() {
  4510. let report = render_memory_report().expect("memory report should render");
  4511. assert!(report.contains("Memory"));
  4512. assert!(report.contains("Working directory"));
  4513. assert!(report.contains("Instruction files"));
  4514. assert!(report.contains("Discovered files"));
  4515. }
  4516. #[test]
  4517. fn config_report_uses_sectioned_layout() {
  4518. let report = render_config_report(None).expect("config report should render");
  4519. assert!(report.contains("Config"));
  4520. assert!(report.contains("Discovered files"));
  4521. assert!(report.contains("Merged JSON"));
  4522. }
  4523. #[test]
  4524. fn parses_git_status_metadata() {
  4525. let _guard = env_lock();
  4526. let temp_root = temp_dir();
  4527. fs::create_dir_all(&temp_root).expect("root dir");
  4528. let (project_root, branch) = parse_git_status_metadata_for(
  4529. &temp_root,
  4530. Some(
  4531. "## rcc/cli...origin/rcc/cli
  4532. M src/main.rs",
  4533. ),
  4534. );
  4535. assert_eq!(branch.as_deref(), Some("rcc/cli"));
  4536. assert!(project_root.is_none());
  4537. fs::remove_dir_all(temp_root).expect("cleanup temp dir");
  4538. }
  4539. #[test]
  4540. fn parses_detached_head_from_status_snapshot() {
  4541. let _guard = env_lock();
  4542. assert_eq!(
  4543. parse_git_status_branch(Some(
  4544. "## HEAD (no branch)
  4545. M src/main.rs"
  4546. )),
  4547. Some("detached HEAD".to_string())
  4548. );
  4549. }
  4550. #[test]
  4551. fn render_diff_report_shows_clean_tree_for_committed_repo() {
  4552. let _guard = env_lock();
  4553. let root = temp_dir();
  4554. fs::create_dir_all(&root).expect("root dir");
  4555. git(&["init", "--quiet"], &root);
  4556. git(&["config", "user.email", "tests@example.com"], &root);
  4557. git(&["config", "user.name", "Rusty Claude Tests"], &root);
  4558. fs::write(root.join("tracked.txt"), "hello\n").expect("write file");
  4559. git(&["add", "tracked.txt"], &root);
  4560. git(&["commit", "-m", "init", "--quiet"], &root);
  4561. let report = with_current_dir(&root, || {
  4562. render_diff_report().expect("diff report should render")
  4563. });
  4564. assert!(report.contains("clean working tree"));
  4565. fs::remove_dir_all(root).expect("cleanup temp dir");
  4566. }
  4567. #[test]
  4568. fn render_diff_report_includes_staged_and_unstaged_sections() {
  4569. let _guard = env_lock();
  4570. let root = temp_dir();
  4571. fs::create_dir_all(&root).expect("root dir");
  4572. git(&["init", "--quiet"], &root);
  4573. git(&["config", "user.email", "tests@example.com"], &root);
  4574. git(&["config", "user.name", "Rusty Claude Tests"], &root);
  4575. fs::write(root.join("tracked.txt"), "hello\n").expect("write file");
  4576. git(&["add", "tracked.txt"], &root);
  4577. git(&["commit", "-m", "init", "--quiet"], &root);
  4578. fs::write(root.join("tracked.txt"), "hello\nstaged\n").expect("update file");
  4579. git(&["add", "tracked.txt"], &root);
  4580. fs::write(root.join("tracked.txt"), "hello\nstaged\nunstaged\n")
  4581. .expect("update file twice");
  4582. let report = with_current_dir(&root, || {
  4583. render_diff_report().expect("diff report should render")
  4584. });
  4585. assert!(report.contains("Staged changes:"));
  4586. assert!(report.contains("Unstaged changes:"));
  4587. assert!(report.contains("tracked.txt"));
  4588. fs::remove_dir_all(root).expect("cleanup temp dir");
  4589. }
  4590. #[test]
  4591. fn render_diff_report_omits_ignored_files() {
  4592. let _guard = env_lock();
  4593. let root = temp_dir();
  4594. fs::create_dir_all(&root).expect("root dir");
  4595. git(&["init", "--quiet"], &root);
  4596. git(&["config", "user.email", "tests@example.com"], &root);
  4597. git(&["config", "user.name", "Rusty Claude Tests"], &root);
  4598. fs::write(root.join(".gitignore"), ".omx/\nignored.txt\n").expect("write gitignore");
  4599. fs::write(root.join("tracked.txt"), "hello\n").expect("write tracked");
  4600. git(&["add", ".gitignore", "tracked.txt"], &root);
  4601. git(&["commit", "-m", "init", "--quiet"], &root);
  4602. fs::create_dir_all(root.join(".omx")).expect("write omx dir");
  4603. fs::write(root.join(".omx").join("state.json"), "{}").expect("write ignored omx");
  4604. fs::write(root.join("ignored.txt"), "secret\n").expect("write ignored file");
  4605. fs::write(root.join("tracked.txt"), "hello\nworld\n").expect("write tracked change");
  4606. let report = with_current_dir(&root, || {
  4607. render_diff_report().expect("diff report should render")
  4608. });
  4609. assert!(report.contains("tracked.txt"));
  4610. assert!(!report.contains("+++ b/ignored.txt"));
  4611. assert!(!report.contains("+++ b/.omx/state.json"));
  4612. fs::remove_dir_all(root).expect("cleanup temp dir");
  4613. }
  4614. #[test]
  4615. fn resume_diff_command_renders_report_for_saved_session() {
  4616. let _guard = env_lock();
  4617. let root = temp_dir();
  4618. fs::create_dir_all(&root).expect("root dir");
  4619. git(&["init", "--quiet"], &root);
  4620. git(&["config", "user.email", "tests@example.com"], &root);
  4621. git(&["config", "user.name", "Rusty Claude Tests"], &root);
  4622. fs::write(root.join("tracked.txt"), "hello\n").expect("write tracked");
  4623. git(&["add", "tracked.txt"], &root);
  4624. git(&["commit", "-m", "init", "--quiet"], &root);
  4625. fs::write(root.join("tracked.txt"), "hello\nworld\n").expect("modify tracked");
  4626. let session_path = root.join("session.json");
  4627. Session::new()
  4628. .save_to_path(&session_path)
  4629. .expect("session should save");
  4630. let session = Session::load_from_path(&session_path).expect("session should load");
  4631. let outcome = with_current_dir(&root, || {
  4632. run_resume_command(&session_path, &session, &SlashCommand::Diff)
  4633. .expect("resume diff should work")
  4634. });
  4635. let message = outcome.message.expect("diff message should exist");
  4636. assert!(message.contains("Unstaged changes:"));
  4637. assert!(message.contains("tracked.txt"));
  4638. fs::remove_dir_all(root).expect("cleanup temp dir");
  4639. }
  4640. #[test]
  4641. fn status_context_reads_real_workspace_metadata() {
  4642. let context = status_context(None).expect("status context should load");
  4643. assert!(context.cwd.is_absolute());
  4644. assert!(context.discovered_config_files >= context.loaded_config_files);
  4645. assert!(context.loaded_config_files <= context.discovered_config_files);
  4646. }
  4647. #[test]
  4648. fn normalizes_supported_permission_modes() {
  4649. assert_eq!(normalize_permission_mode("read-only"), Some("read-only"));
  4650. assert_eq!(
  4651. normalize_permission_mode("workspace-write"),
  4652. Some("workspace-write")
  4653. );
  4654. assert_eq!(
  4655. normalize_permission_mode("danger-full-access"),
  4656. Some("danger-full-access")
  4657. );
  4658. assert_eq!(normalize_permission_mode("unknown"), None);
  4659. }
  4660. #[test]
  4661. fn clear_command_requires_explicit_confirmation_flag() {
  4662. assert_eq!(
  4663. SlashCommand::parse("/clear"),
  4664. Some(SlashCommand::Clear { confirm: false })
  4665. );
  4666. assert_eq!(
  4667. SlashCommand::parse("/clear --confirm"),
  4668. Some(SlashCommand::Clear { confirm: true })
  4669. );
  4670. }
  4671. #[test]
  4672. fn parses_resume_and_config_slash_commands() {
  4673. assert_eq!(
  4674. SlashCommand::parse("/resume saved-session.jsonl"),
  4675. Some(SlashCommand::Resume {
  4676. session_path: Some("saved-session.jsonl".to_string())
  4677. })
  4678. );
  4679. assert_eq!(
  4680. SlashCommand::parse("/clear --confirm"),
  4681. Some(SlashCommand::Clear { confirm: true })
  4682. );
  4683. assert_eq!(
  4684. SlashCommand::parse("/config"),
  4685. Some(SlashCommand::Config { section: None })
  4686. );
  4687. assert_eq!(
  4688. SlashCommand::parse("/config env"),
  4689. Some(SlashCommand::Config {
  4690. section: Some("env".to_string())
  4691. })
  4692. );
  4693. assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
  4694. assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
  4695. assert_eq!(
  4696. SlashCommand::parse("/session fork incident-review"),
  4697. Some(SlashCommand::Session {
  4698. action: Some("fork".to_string()),
  4699. target: Some("incident-review".to_string())
  4700. })
  4701. );
  4702. }
  4703. #[test]
  4704. fn help_mentions_jsonl_resume_examples() {
  4705. let mut help = Vec::new();
  4706. print_help_to(&mut help).expect("help should render");
  4707. let help = String::from_utf8(help).expect("help should be utf8");
  4708. assert!(help.contains("claw --resume SESSION.jsonl"));
  4709. assert!(help.contains("claw --resume session.jsonl /status /diff /export notes.txt"));
  4710. }
  4711. #[test]
  4712. fn managed_sessions_default_to_jsonl_and_resolve_legacy_json() {
  4713. let _guard = cwd_lock().lock().expect("cwd lock");
  4714. let workspace = temp_workspace("session-resolution");
  4715. std::fs::create_dir_all(&workspace).expect("workspace should create");
  4716. let previous = std::env::current_dir().expect("cwd");
  4717. std::env::set_current_dir(&workspace).expect("switch cwd");
  4718. let handle = create_managed_session_handle("session-alpha").expect("jsonl handle");
  4719. assert!(handle.path.ends_with("session-alpha.jsonl"));
  4720. let legacy_path = workspace.join(".claw/sessions/legacy.json");
  4721. std::fs::create_dir_all(
  4722. legacy_path
  4723. .parent()
  4724. .expect("legacy path should have parent directory"),
  4725. )
  4726. .expect("session dir should exist");
  4727. Session::new()
  4728. .with_persistence_path(legacy_path.clone())
  4729. .save_to_path(&legacy_path)
  4730. .expect("legacy session should save");
  4731. let resolved = resolve_session_reference("legacy").expect("legacy session should resolve");
  4732. assert_eq!(
  4733. resolved.path.canonicalize().expect("resolved path should exist"),
  4734. legacy_path.canonicalize().expect("legacy path should exist")
  4735. );
  4736. std::env::set_current_dir(previous).expect("restore cwd");
  4737. std::fs::remove_dir_all(workspace).expect("workspace should clean up");
  4738. }
  4739. fn cwd_lock() -> &'static Mutex<()> {
  4740. static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
  4741. LOCK.get_or_init(|| Mutex::new(()))
  4742. }
  4743. fn temp_workspace(label: &str) -> PathBuf {
  4744. let nanos = std::time::SystemTime::now()
  4745. .duration_since(std::time::UNIX_EPOCH)
  4746. .expect("system time should be after epoch")
  4747. .as_nanos();
  4748. std::env::temp_dir().join(format!("claw-cli-{label}-{nanos}"))
  4749. }
  4750. #[test]
  4751. fn init_template_mentions_detected_rust_workspace() {
  4752. let rendered = crate::init::render_init_claude_md(std::path::Path::new("."));
  4753. assert!(rendered.contains("# CLAUDE.md"));
  4754. assert!(rendered.contains("cargo clippy --workspace --all-targets -- -D warnings"));
  4755. }
  4756. #[test]
  4757. fn converts_tool_roundtrip_messages() {
  4758. let messages = vec![
  4759. ConversationMessage::user_text("hello"),
  4760. ConversationMessage::assistant(vec![ContentBlock::ToolUse {
  4761. id: "tool-1".to_string(),
  4762. name: "bash".to_string(),
  4763. input: "{\"command\":\"pwd\"}".to_string(),
  4764. }]),
  4765. ConversationMessage {
  4766. role: MessageRole::Tool,
  4767. blocks: vec![ContentBlock::ToolResult {
  4768. tool_use_id: "tool-1".to_string(),
  4769. tool_name: "bash".to_string(),
  4770. output: "ok".to_string(),
  4771. is_error: false,
  4772. }],
  4773. usage: None,
  4774. },
  4775. ];
  4776. let converted = super::convert_messages(&messages);
  4777. assert_eq!(converted.len(), 3);
  4778. assert_eq!(converted[1].role, "assistant");
  4779. assert_eq!(converted[2].role, "user");
  4780. }
  4781. #[test]
  4782. fn repl_help_mentions_history_completion_and_multiline() {
  4783. let help = render_repl_help();
  4784. assert!(help.contains("Up/Down"));
  4785. assert!(help.contains("Tab"));
  4786. assert!(help.contains("Shift+Enter/Ctrl+J"));
  4787. }
  4788. #[test]
  4789. fn tool_rendering_helpers_compact_output() {
  4790. let start = format_tool_call_start("read_file", r#"{"path":"src/main.rs"}"#);
  4791. assert!(start.contains("read_file"));
  4792. assert!(start.contains("src/main.rs"));
  4793. let done = format_tool_result(
  4794. "read_file",
  4795. r#"{"file":{"filePath":"src/main.rs","content":"hello","numLines":1,"startLine":1,"totalLines":1}}"#,
  4796. false,
  4797. );
  4798. assert!(done.contains("📄 Read src/main.rs"));
  4799. assert!(done.contains("hello"));
  4800. }
  4801. #[test]
  4802. fn tool_rendering_truncates_large_read_output_for_display_only() {
  4803. let content = (0..200)
  4804. .map(|index| format!("line {index:03}"))
  4805. .collect::<Vec<_>>()
  4806. .join("\n");
  4807. let output = json!({
  4808. "file": {
  4809. "filePath": "src/main.rs",
  4810. "content": content,
  4811. "numLines": 200,
  4812. "startLine": 1,
  4813. "totalLines": 200
  4814. }
  4815. })
  4816. .to_string();
  4817. let rendered = format_tool_result("read_file", &output, false);
  4818. assert!(rendered.contains("line 000"));
  4819. assert!(rendered.contains("line 079"));
  4820. assert!(!rendered.contains("line 199"));
  4821. assert!(rendered.contains("full result preserved in session"));
  4822. assert!(output.contains("line 199"));
  4823. }
  4824. #[test]
  4825. fn tool_rendering_truncates_large_bash_output_for_display_only() {
  4826. let stdout = (0..120)
  4827. .map(|index| format!("stdout {index:03}"))
  4828. .collect::<Vec<_>>()
  4829. .join("\n");
  4830. let output = json!({
  4831. "stdout": stdout,
  4832. "stderr": "",
  4833. "returnCodeInterpretation": "completed successfully"
  4834. })
  4835. .to_string();
  4836. let rendered = format_tool_result("bash", &output, false);
  4837. assert!(rendered.contains("stdout 000"));
  4838. assert!(rendered.contains("stdout 059"));
  4839. assert!(!rendered.contains("stdout 119"));
  4840. assert!(rendered.contains("full result preserved in session"));
  4841. assert!(output.contains("stdout 119"));
  4842. }
  4843. #[test]
  4844. fn tool_rendering_truncates_generic_long_output_for_display_only() {
  4845. let items = (0..120)
  4846. .map(|index| format!("payload {index:03}"))
  4847. .collect::<Vec<_>>();
  4848. let output = json!({
  4849. "summary": "plugin payload",
  4850. "items": items,
  4851. })
  4852. .to_string();
  4853. let rendered = format_tool_result("plugin_echo", &output, false);
  4854. assert!(rendered.contains("plugin_echo"));
  4855. assert!(rendered.contains("payload 000"));
  4856. assert!(rendered.contains("payload 040"));
  4857. assert!(!rendered.contains("payload 080"));
  4858. assert!(!rendered.contains("payload 119"));
  4859. assert!(rendered.contains("full result preserved in session"));
  4860. assert!(output.contains("payload 119"));
  4861. }
  4862. #[test]
  4863. fn tool_rendering_truncates_raw_generic_output_for_display_only() {
  4864. let output = (0..120)
  4865. .map(|index| format!("raw {index:03}"))
  4866. .collect::<Vec<_>>()
  4867. .join("\n");
  4868. let rendered = format_tool_result("plugin_echo", &output, false);
  4869. assert!(rendered.contains("plugin_echo"));
  4870. assert!(rendered.contains("raw 000"));
  4871. assert!(rendered.contains("raw 059"));
  4872. assert!(!rendered.contains("raw 119"));
  4873. assert!(rendered.contains("full result preserved in session"));
  4874. assert!(output.contains("raw 119"));
  4875. }
  4876. #[test]
  4877. fn ultraplan_progress_lines_include_phase_step_and_elapsed_status() {
  4878. let snapshot = InternalPromptProgressState {
  4879. command_label: "Ultraplan",
  4880. task_label: "ship plugin progress".to_string(),
  4881. step: 3,
  4882. phase: "running read_file".to_string(),
  4883. detail: Some("reading rust/crates/rusty-claude-cli/src/main.rs".to_string()),
  4884. saw_final_text: false,
  4885. };
  4886. let started = format_internal_prompt_progress_line(
  4887. InternalPromptProgressEvent::Started,
  4888. &snapshot,
  4889. Duration::from_secs(0),
  4890. None,
  4891. );
  4892. let heartbeat = format_internal_prompt_progress_line(
  4893. InternalPromptProgressEvent::Heartbeat,
  4894. &snapshot,
  4895. Duration::from_secs(9),
  4896. None,
  4897. );
  4898. let completed = format_internal_prompt_progress_line(
  4899. InternalPromptProgressEvent::Complete,
  4900. &snapshot,
  4901. Duration::from_secs(12),
  4902. None,
  4903. );
  4904. let failed = format_internal_prompt_progress_line(
  4905. InternalPromptProgressEvent::Failed,
  4906. &snapshot,
  4907. Duration::from_secs(12),
  4908. Some("network timeout"),
  4909. );
  4910. assert!(started.contains("planning started"));
  4911. assert!(started.contains("current step 3"));
  4912. assert!(heartbeat.contains("heartbeat"));
  4913. assert!(heartbeat.contains("9s elapsed"));
  4914. assert!(heartbeat.contains("phase running read_file"));
  4915. assert!(completed.contains("completed"));
  4916. assert!(completed.contains("3 steps total"));
  4917. assert!(failed.contains("failed"));
  4918. assert!(failed.contains("network timeout"));
  4919. }
  4920. #[test]
  4921. fn describe_tool_progress_summarizes_known_tools() {
  4922. assert_eq!(
  4923. describe_tool_progress("read_file", r#"{"path":"src/main.rs"}"#),
  4924. "reading src/main.rs"
  4925. );
  4926. assert!(
  4927. describe_tool_progress("bash", r#"{"command":"cargo test -p rusty-claude-cli"}"#)
  4928. .contains("cargo test -p rusty-claude-cli")
  4929. );
  4930. assert_eq!(
  4931. describe_tool_progress("grep_search", r#"{"pattern":"ultraplan","path":"rust"}"#),
  4932. "grep `ultraplan` in rust"
  4933. );
  4934. }
  4935. #[test]
  4936. fn push_output_block_renders_markdown_text() {
  4937. let mut out = Vec::new();
  4938. let mut events = Vec::new();
  4939. let mut pending_tool = None;
  4940. push_output_block(
  4941. OutputContentBlock::Text {
  4942. text: "# Heading".to_string(),
  4943. },
  4944. &mut out,
  4945. &mut events,
  4946. &mut pending_tool,
  4947. false,
  4948. )
  4949. .expect("text block should render");
  4950. let rendered = String::from_utf8(out).expect("utf8");
  4951. assert!(rendered.contains("Heading"));
  4952. assert!(rendered.contains('\u{1b}'));
  4953. }
  4954. #[test]
  4955. fn push_output_block_skips_empty_object_prefix_for_tool_streams() {
  4956. let mut out = Vec::new();
  4957. let mut events = Vec::new();
  4958. let mut pending_tool = None;
  4959. push_output_block(
  4960. OutputContentBlock::ToolUse {
  4961. id: "tool-1".to_string(),
  4962. name: "read_file".to_string(),
  4963. input: json!({}),
  4964. },
  4965. &mut out,
  4966. &mut events,
  4967. &mut pending_tool,
  4968. true,
  4969. )
  4970. .expect("tool block should accumulate");
  4971. assert!(events.is_empty());
  4972. assert_eq!(
  4973. pending_tool,
  4974. Some(("tool-1".to_string(), "read_file".to_string(), String::new(),))
  4975. );
  4976. }
  4977. #[test]
  4978. fn response_to_events_preserves_empty_object_json_input_outside_streaming() {
  4979. let mut out = Vec::new();
  4980. let events = response_to_events(
  4981. MessageResponse {
  4982. id: "msg-1".to_string(),
  4983. kind: "message".to_string(),
  4984. model: "claude-opus-4-6".to_string(),
  4985. role: "assistant".to_string(),
  4986. content: vec![OutputContentBlock::ToolUse {
  4987. id: "tool-1".to_string(),
  4988. name: "read_file".to_string(),
  4989. input: json!({}),
  4990. }],
  4991. stop_reason: Some("tool_use".to_string()),
  4992. stop_sequence: None,
  4993. usage: Usage {
  4994. input_tokens: 1,
  4995. output_tokens: 1,
  4996. cache_creation_input_tokens: 0,
  4997. cache_read_input_tokens: 0,
  4998. },
  4999. request_id: None,
  5000. },
  5001. &mut out,
  5002. )
  5003. .expect("response conversion should succeed");
  5004. assert!(matches!(
  5005. &events[0],
  5006. AssistantEvent::ToolUse { name, input, .. }
  5007. if name == "read_file" && input == "{}"
  5008. ));
  5009. }
  5010. #[test]
  5011. fn response_to_events_preserves_non_empty_json_input_outside_streaming() {
  5012. let mut out = Vec::new();
  5013. let events = response_to_events(
  5014. MessageResponse {
  5015. id: "msg-2".to_string(),
  5016. kind: "message".to_string(),
  5017. model: "claude-opus-4-6".to_string(),
  5018. role: "assistant".to_string(),
  5019. content: vec![OutputContentBlock::ToolUse {
  5020. id: "tool-2".to_string(),
  5021. name: "read_file".to_string(),
  5022. input: json!({ "path": "rust/Cargo.toml" }),
  5023. }],
  5024. stop_reason: Some("tool_use".to_string()),
  5025. stop_sequence: None,
  5026. usage: Usage {
  5027. input_tokens: 1,
  5028. output_tokens: 1,
  5029. cache_creation_input_tokens: 0,
  5030. cache_read_input_tokens: 0,
  5031. },
  5032. request_id: None,
  5033. },
  5034. &mut out,
  5035. )
  5036. .expect("response conversion should succeed");
  5037. assert!(matches!(
  5038. &events[0],
  5039. AssistantEvent::ToolUse { name, input, .. }
  5040. if name == "read_file" && input == "{\"path\":\"rust/Cargo.toml\"}"
  5041. ));
  5042. }
  5043. #[test]
  5044. fn response_to_events_ignores_thinking_blocks() {
  5045. let mut out = Vec::new();
  5046. let events = response_to_events(
  5047. MessageResponse {
  5048. id: "msg-3".to_string(),
  5049. kind: "message".to_string(),
  5050. model: "claude-opus-4-6".to_string(),
  5051. role: "assistant".to_string(),
  5052. content: vec![
  5053. OutputContentBlock::Thinking {
  5054. thinking: "step 1".to_string(),
  5055. signature: Some("sig_123".to_string()),
  5056. },
  5057. OutputContentBlock::Text {
  5058. text: "Final answer".to_string(),
  5059. },
  5060. ],
  5061. stop_reason: Some("end_turn".to_string()),
  5062. stop_sequence: None,
  5063. usage: Usage {
  5064. input_tokens: 1,
  5065. output_tokens: 1,
  5066. cache_creation_input_tokens: 0,
  5067. cache_read_input_tokens: 0,
  5068. },
  5069. request_id: None,
  5070. },
  5071. &mut out,
  5072. )
  5073. .expect("response conversion should succeed");
  5074. assert!(matches!(
  5075. &events[0],
  5076. AssistantEvent::TextDelta(text) if text == "Final answer"
  5077. ));
  5078. assert!(!String::from_utf8(out).expect("utf8").contains("step 1"));
  5079. }
  5080. }
  5081. #[cfg(test)]
  5082. mod sandbox_report_tests {
  5083. use super::{format_sandbox_report, HookAbortMonitor};
  5084. use runtime::HookAbortSignal;
  5085. use std::sync::mpsc;
  5086. use std::time::Duration;
  5087. #[test]
  5088. fn sandbox_report_renders_expected_fields() {
  5089. let report = format_sandbox_report(&runtime::SandboxStatus::default());
  5090. assert!(report.contains("Sandbox"));
  5091. assert!(report.contains("Enabled"));
  5092. assert!(report.contains("Filesystem mode"));
  5093. assert!(report.contains("Fallback reason"));
  5094. }
  5095. #[test]
  5096. fn hook_abort_monitor_stops_without_aborting() {
  5097. let abort_signal = HookAbortSignal::new();
  5098. let (ready_tx, ready_rx) = mpsc::channel();
  5099. let monitor = HookAbortMonitor::spawn_with_waiter(
  5100. abort_signal.clone(),
  5101. move |stop_rx, abort_signal| {
  5102. ready_tx.send(()).expect("ready signal");
  5103. let _ = stop_rx.recv();
  5104. assert!(!abort_signal.is_aborted());
  5105. },
  5106. );
  5107. ready_rx.recv().expect("waiter should be ready");
  5108. monitor.stop();
  5109. assert!(!abort_signal.is_aborted());
  5110. }
  5111. #[test]
  5112. fn hook_abort_monitor_propagates_interrupt() {
  5113. let abort_signal = HookAbortSignal::new();
  5114. let (done_tx, done_rx) = mpsc::channel();
  5115. let monitor = HookAbortMonitor::spawn_with_waiter(
  5116. abort_signal.clone(),
  5117. move |_stop_rx, abort_signal| {
  5118. abort_signal.abort();
  5119. done_tx.send(()).expect("done signal");
  5120. },
  5121. );
  5122. done_rx
  5123. .recv_timeout(Duration::from_secs(1))
  5124. .expect("interrupt should complete");
  5125. monitor.stop();
  5126. assert!(abort_signal.is_aborted());
  5127. }
  5128. }