main.rs 235 KB

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