main.rs 186 KB

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