main.rs 239 KB

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