main.rs 219 KB

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