lib.rs 119 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505
  1. use std::collections::{BTreeMap, BTreeSet};
  2. use std::path::{Path, PathBuf};
  3. use std::process::Command;
  4. use std::time::{Duration, Instant};
  5. use reqwest::blocking::Client;
  6. use runtime::{
  7. edit_file, execute_bash, glob_search, grep_search, read_file, write_file, BashCommandInput,
  8. GrepSearchInput, PermissionMode,
  9. };
  10. use serde::{Deserialize, Serialize};
  11. use serde_json::{json, Value};
  12. #[derive(Debug, Clone, PartialEq, Eq)]
  13. pub struct ToolManifestEntry {
  14. pub name: String,
  15. pub source: ToolSource,
  16. }
  17. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  18. pub enum ToolSource {
  19. Base,
  20. Conditional,
  21. }
  22. #[derive(Debug, Clone, Default, PartialEq, Eq)]
  23. pub struct ToolRegistry {
  24. entries: Vec<ToolManifestEntry>,
  25. }
  26. impl ToolRegistry {
  27. #[must_use]
  28. pub fn new(entries: Vec<ToolManifestEntry>) -> Self {
  29. Self { entries }
  30. }
  31. #[must_use]
  32. pub fn entries(&self) -> &[ToolManifestEntry] {
  33. &self.entries
  34. }
  35. }
  36. #[derive(Debug, Clone, PartialEq, Eq)]
  37. pub struct ToolSpec {
  38. pub name: &'static str,
  39. pub description: &'static str,
  40. pub input_schema: Value,
  41. pub required_permission: PermissionMode,
  42. }
  43. #[must_use]
  44. #[allow(clippy::too_many_lines)]
  45. pub fn mvp_tool_specs() -> Vec<ToolSpec> {
  46. vec![
  47. ToolSpec {
  48. name: "bash",
  49. description: "Execute a shell command in the current workspace.",
  50. input_schema: json!({
  51. "type": "object",
  52. "properties": {
  53. "command": { "type": "string" },
  54. "timeout": { "type": "integer", "minimum": 1 },
  55. "description": { "type": "string" },
  56. "run_in_background": { "type": "boolean" },
  57. "dangerouslyDisableSandbox": { "type": "boolean" }
  58. },
  59. "required": ["command"],
  60. "additionalProperties": false
  61. }),
  62. required_permission: PermissionMode::DangerFullAccess,
  63. },
  64. ToolSpec {
  65. name: "read_file",
  66. description: "Read a text file from the workspace.",
  67. input_schema: json!({
  68. "type": "object",
  69. "properties": {
  70. "path": { "type": "string" },
  71. "offset": { "type": "integer", "minimum": 0 },
  72. "limit": { "type": "integer", "minimum": 1 }
  73. },
  74. "required": ["path"],
  75. "additionalProperties": false
  76. }),
  77. required_permission: PermissionMode::ReadOnly,
  78. },
  79. ToolSpec {
  80. name: "write_file",
  81. description: "Write a text file in the workspace.",
  82. input_schema: json!({
  83. "type": "object",
  84. "properties": {
  85. "path": { "type": "string" },
  86. "content": { "type": "string" }
  87. },
  88. "required": ["path", "content"],
  89. "additionalProperties": false
  90. }),
  91. required_permission: PermissionMode::WorkspaceWrite,
  92. },
  93. ToolSpec {
  94. name: "edit_file",
  95. description: "Replace text in a workspace file.",
  96. input_schema: json!({
  97. "type": "object",
  98. "properties": {
  99. "path": { "type": "string" },
  100. "old_string": { "type": "string" },
  101. "new_string": { "type": "string" },
  102. "replace_all": { "type": "boolean" }
  103. },
  104. "required": ["path", "old_string", "new_string"],
  105. "additionalProperties": false
  106. }),
  107. required_permission: PermissionMode::WorkspaceWrite,
  108. },
  109. ToolSpec {
  110. name: "glob_search",
  111. description: "Find files by glob pattern.",
  112. input_schema: json!({
  113. "type": "object",
  114. "properties": {
  115. "pattern": { "type": "string" },
  116. "path": { "type": "string" }
  117. },
  118. "required": ["pattern"],
  119. "additionalProperties": false
  120. }),
  121. required_permission: PermissionMode::ReadOnly,
  122. },
  123. ToolSpec {
  124. name: "grep_search",
  125. description: "Search file contents with a regex pattern.",
  126. input_schema: json!({
  127. "type": "object",
  128. "properties": {
  129. "pattern": { "type": "string" },
  130. "path": { "type": "string" },
  131. "glob": { "type": "string" },
  132. "output_mode": { "type": "string" },
  133. "-B": { "type": "integer", "minimum": 0 },
  134. "-A": { "type": "integer", "minimum": 0 },
  135. "-C": { "type": "integer", "minimum": 0 },
  136. "context": { "type": "integer", "minimum": 0 },
  137. "-n": { "type": "boolean" },
  138. "-i": { "type": "boolean" },
  139. "type": { "type": "string" },
  140. "head_limit": { "type": "integer", "minimum": 1 },
  141. "offset": { "type": "integer", "minimum": 0 },
  142. "multiline": { "type": "boolean" }
  143. },
  144. "required": ["pattern"],
  145. "additionalProperties": false
  146. }),
  147. required_permission: PermissionMode::ReadOnly,
  148. },
  149. ToolSpec {
  150. name: "WebFetch",
  151. description:
  152. "Fetch a URL, convert it into readable text, and answer a prompt about it.",
  153. input_schema: json!({
  154. "type": "object",
  155. "properties": {
  156. "url": { "type": "string", "format": "uri" },
  157. "prompt": { "type": "string" }
  158. },
  159. "required": ["url", "prompt"],
  160. "additionalProperties": false
  161. }),
  162. required_permission: PermissionMode::ReadOnly,
  163. },
  164. ToolSpec {
  165. name: "WebSearch",
  166. description: "Search the web for current information and return cited results.",
  167. input_schema: json!({
  168. "type": "object",
  169. "properties": {
  170. "query": { "type": "string", "minLength": 2 },
  171. "allowed_domains": {
  172. "type": "array",
  173. "items": { "type": "string" }
  174. },
  175. "blocked_domains": {
  176. "type": "array",
  177. "items": { "type": "string" }
  178. }
  179. },
  180. "required": ["query"],
  181. "additionalProperties": false
  182. }),
  183. required_permission: PermissionMode::ReadOnly,
  184. },
  185. ToolSpec {
  186. name: "TodoWrite",
  187. description: "Update the structured task list for the current session.",
  188. input_schema: json!({
  189. "type": "object",
  190. "properties": {
  191. "todos": {
  192. "type": "array",
  193. "items": {
  194. "type": "object",
  195. "properties": {
  196. "content": { "type": "string" },
  197. "activeForm": { "type": "string" },
  198. "status": {
  199. "type": "string",
  200. "enum": ["pending", "in_progress", "completed"]
  201. }
  202. },
  203. "required": ["content", "activeForm", "status"],
  204. "additionalProperties": false
  205. }
  206. }
  207. },
  208. "required": ["todos"],
  209. "additionalProperties": false
  210. }),
  211. required_permission: PermissionMode::WorkspaceWrite,
  212. },
  213. ToolSpec {
  214. name: "Skill",
  215. description: "Load a local skill definition and its instructions.",
  216. input_schema: json!({
  217. "type": "object",
  218. "properties": {
  219. "skill": { "type": "string" },
  220. "args": { "type": "string" }
  221. },
  222. "required": ["skill"],
  223. "additionalProperties": false
  224. }),
  225. required_permission: PermissionMode::ReadOnly,
  226. },
  227. ToolSpec {
  228. name: "Agent",
  229. description: "Launch a specialized agent task and persist its handoff metadata.",
  230. input_schema: json!({
  231. "type": "object",
  232. "properties": {
  233. "description": { "type": "string" },
  234. "prompt": { "type": "string" },
  235. "subagent_type": { "type": "string" },
  236. "name": { "type": "string" },
  237. "model": { "type": "string" }
  238. },
  239. "required": ["description", "prompt"],
  240. "additionalProperties": false
  241. }),
  242. required_permission: PermissionMode::DangerFullAccess,
  243. },
  244. ToolSpec {
  245. name: "ToolSearch",
  246. description: "Search for deferred or specialized tools by exact name or keywords.",
  247. input_schema: json!({
  248. "type": "object",
  249. "properties": {
  250. "query": { "type": "string" },
  251. "max_results": { "type": "integer", "minimum": 1 }
  252. },
  253. "required": ["query"],
  254. "additionalProperties": false
  255. }),
  256. required_permission: PermissionMode::ReadOnly,
  257. },
  258. ToolSpec {
  259. name: "NotebookEdit",
  260. description: "Replace, insert, or delete a cell in a Jupyter notebook.",
  261. input_schema: json!({
  262. "type": "object",
  263. "properties": {
  264. "notebook_path": { "type": "string" },
  265. "cell_id": { "type": "string" },
  266. "new_source": { "type": "string" },
  267. "cell_type": { "type": "string", "enum": ["code", "markdown"] },
  268. "edit_mode": { "type": "string", "enum": ["replace", "insert", "delete"] }
  269. },
  270. "required": ["notebook_path"],
  271. "additionalProperties": false
  272. }),
  273. required_permission: PermissionMode::WorkspaceWrite,
  274. },
  275. ToolSpec {
  276. name: "Sleep",
  277. description: "Wait for a specified duration without holding a shell process.",
  278. input_schema: json!({
  279. "type": "object",
  280. "properties": {
  281. "duration_ms": { "type": "integer", "minimum": 0 }
  282. },
  283. "required": ["duration_ms"],
  284. "additionalProperties": false
  285. }),
  286. required_permission: PermissionMode::ReadOnly,
  287. },
  288. ToolSpec {
  289. name: "SendUserMessage",
  290. description: "Send a message to the user.",
  291. input_schema: json!({
  292. "type": "object",
  293. "properties": {
  294. "message": { "type": "string" },
  295. "attachments": {
  296. "type": "array",
  297. "items": { "type": "string" }
  298. },
  299. "status": {
  300. "type": "string",
  301. "enum": ["normal", "proactive"]
  302. }
  303. },
  304. "required": ["message", "status"],
  305. "additionalProperties": false
  306. }),
  307. required_permission: PermissionMode::ReadOnly,
  308. },
  309. ToolSpec {
  310. name: "Config",
  311. description: "Get or set Claude Code settings.",
  312. input_schema: json!({
  313. "type": "object",
  314. "properties": {
  315. "setting": { "type": "string" },
  316. "value": {
  317. "type": ["string", "boolean", "number"]
  318. }
  319. },
  320. "required": ["setting"],
  321. "additionalProperties": false
  322. }),
  323. required_permission: PermissionMode::WorkspaceWrite,
  324. },
  325. ToolSpec {
  326. name: "StructuredOutput",
  327. description: "Return structured output in the requested format.",
  328. input_schema: json!({
  329. "type": "object",
  330. "additionalProperties": true
  331. }),
  332. required_permission: PermissionMode::ReadOnly,
  333. },
  334. ToolSpec {
  335. name: "REPL",
  336. description: "Execute code in a REPL-like subprocess.",
  337. input_schema: json!({
  338. "type": "object",
  339. "properties": {
  340. "code": { "type": "string" },
  341. "language": { "type": "string" },
  342. "timeout_ms": { "type": "integer", "minimum": 1 }
  343. },
  344. "required": ["code", "language"],
  345. "additionalProperties": false
  346. }),
  347. required_permission: PermissionMode::DangerFullAccess,
  348. },
  349. ToolSpec {
  350. name: "PowerShell",
  351. description: "Execute a PowerShell command with optional timeout.",
  352. input_schema: json!({
  353. "type": "object",
  354. "properties": {
  355. "command": { "type": "string" },
  356. "timeout": { "type": "integer", "minimum": 1 },
  357. "description": { "type": "string" },
  358. "run_in_background": { "type": "boolean" }
  359. },
  360. "required": ["command"],
  361. "additionalProperties": false
  362. }),
  363. required_permission: PermissionMode::DangerFullAccess,
  364. },
  365. ]
  366. }
  367. pub fn execute_tool(name: &str, input: &Value) -> Result<String, String> {
  368. match name {
  369. "bash" => from_value::<BashCommandInput>(input).and_then(run_bash),
  370. "read_file" => from_value::<ReadFileInput>(input).and_then(run_read_file),
  371. "write_file" => from_value::<WriteFileInput>(input).and_then(run_write_file),
  372. "edit_file" => from_value::<EditFileInput>(input).and_then(run_edit_file),
  373. "glob_search" => from_value::<GlobSearchInputValue>(input).and_then(run_glob_search),
  374. "grep_search" => from_value::<GrepSearchInput>(input).and_then(run_grep_search),
  375. "WebFetch" => from_value::<WebFetchInput>(input).and_then(run_web_fetch),
  376. "WebSearch" => from_value::<WebSearchInput>(input).and_then(run_web_search),
  377. "TodoWrite" => from_value::<TodoWriteInput>(input).and_then(run_todo_write),
  378. "Skill" => from_value::<SkillInput>(input).and_then(run_skill),
  379. "Agent" => from_value::<AgentInput>(input).and_then(run_agent),
  380. "ToolSearch" => from_value::<ToolSearchInput>(input).and_then(run_tool_search),
  381. "NotebookEdit" => from_value::<NotebookEditInput>(input).and_then(run_notebook_edit),
  382. "Sleep" => from_value::<SleepInput>(input).and_then(run_sleep),
  383. "SendUserMessage" | "Brief" => from_value::<BriefInput>(input).and_then(run_brief),
  384. "Config" => from_value::<ConfigInput>(input).and_then(run_config),
  385. "StructuredOutput" => {
  386. from_value::<StructuredOutputInput>(input).and_then(run_structured_output)
  387. }
  388. "REPL" => from_value::<ReplInput>(input).and_then(run_repl),
  389. "PowerShell" => from_value::<PowerShellInput>(input).and_then(run_powershell),
  390. _ => Err(format!("unsupported tool: {name}")),
  391. }
  392. }
  393. fn from_value<T: for<'de> Deserialize<'de>>(input: &Value) -> Result<T, String> {
  394. serde_json::from_value(input.clone()).map_err(|error| error.to_string())
  395. }
  396. fn run_bash(input: BashCommandInput) -> Result<String, String> {
  397. serde_json::to_string_pretty(&execute_bash(input).map_err(|error| error.to_string())?)
  398. .map_err(|error| error.to_string())
  399. }
  400. #[allow(clippy::needless_pass_by_value)]
  401. fn run_read_file(input: ReadFileInput) -> Result<String, String> {
  402. to_pretty_json(read_file(&input.path, input.offset, input.limit).map_err(io_to_string)?)
  403. }
  404. #[allow(clippy::needless_pass_by_value)]
  405. fn run_write_file(input: WriteFileInput) -> Result<String, String> {
  406. to_pretty_json(write_file(&input.path, &input.content).map_err(io_to_string)?)
  407. }
  408. #[allow(clippy::needless_pass_by_value)]
  409. fn run_edit_file(input: EditFileInput) -> Result<String, String> {
  410. to_pretty_json(
  411. edit_file(
  412. &input.path,
  413. &input.old_string,
  414. &input.new_string,
  415. input.replace_all.unwrap_or(false),
  416. )
  417. .map_err(io_to_string)?,
  418. )
  419. }
  420. #[allow(clippy::needless_pass_by_value)]
  421. fn run_glob_search(input: GlobSearchInputValue) -> Result<String, String> {
  422. to_pretty_json(glob_search(&input.pattern, input.path.as_deref()).map_err(io_to_string)?)
  423. }
  424. #[allow(clippy::needless_pass_by_value)]
  425. fn run_grep_search(input: GrepSearchInput) -> Result<String, String> {
  426. to_pretty_json(grep_search(&input).map_err(io_to_string)?)
  427. }
  428. #[allow(clippy::needless_pass_by_value)]
  429. fn run_web_fetch(input: WebFetchInput) -> Result<String, String> {
  430. to_pretty_json(execute_web_fetch(&input)?)
  431. }
  432. #[allow(clippy::needless_pass_by_value)]
  433. fn run_web_search(input: WebSearchInput) -> Result<String, String> {
  434. to_pretty_json(execute_web_search(&input)?)
  435. }
  436. fn run_todo_write(input: TodoWriteInput) -> Result<String, String> {
  437. to_pretty_json(execute_todo_write(input)?)
  438. }
  439. fn run_skill(input: SkillInput) -> Result<String, String> {
  440. to_pretty_json(execute_skill(input)?)
  441. }
  442. fn run_agent(input: AgentInput) -> Result<String, String> {
  443. to_pretty_json(execute_agent(input)?)
  444. }
  445. fn run_tool_search(input: ToolSearchInput) -> Result<String, String> {
  446. to_pretty_json(execute_tool_search(input))
  447. }
  448. fn run_notebook_edit(input: NotebookEditInput) -> Result<String, String> {
  449. to_pretty_json(execute_notebook_edit(input)?)
  450. }
  451. fn run_sleep(input: SleepInput) -> Result<String, String> {
  452. to_pretty_json(execute_sleep(input))
  453. }
  454. fn run_brief(input: BriefInput) -> Result<String, String> {
  455. to_pretty_json(execute_brief(input)?)
  456. }
  457. fn run_config(input: ConfigInput) -> Result<String, String> {
  458. to_pretty_json(execute_config(input)?)
  459. }
  460. fn run_structured_output(input: StructuredOutputInput) -> Result<String, String> {
  461. to_pretty_json(execute_structured_output(input))
  462. }
  463. fn run_repl(input: ReplInput) -> Result<String, String> {
  464. to_pretty_json(execute_repl(input)?)
  465. }
  466. fn run_powershell(input: PowerShellInput) -> Result<String, String> {
  467. to_pretty_json(execute_powershell(input).map_err(|error| error.to_string())?)
  468. }
  469. fn to_pretty_json<T: serde::Serialize>(value: T) -> Result<String, String> {
  470. serde_json::to_string_pretty(&value).map_err(|error| error.to_string())
  471. }
  472. #[allow(clippy::needless_pass_by_value)]
  473. fn io_to_string(error: std::io::Error) -> String {
  474. error.to_string()
  475. }
  476. #[derive(Debug, Deserialize)]
  477. struct ReadFileInput {
  478. path: String,
  479. offset: Option<usize>,
  480. limit: Option<usize>,
  481. }
  482. #[derive(Debug, Deserialize)]
  483. struct WriteFileInput {
  484. path: String,
  485. content: String,
  486. }
  487. #[derive(Debug, Deserialize)]
  488. struct EditFileInput {
  489. path: String,
  490. old_string: String,
  491. new_string: String,
  492. replace_all: Option<bool>,
  493. }
  494. #[derive(Debug, Deserialize)]
  495. struct GlobSearchInputValue {
  496. pattern: String,
  497. path: Option<String>,
  498. }
  499. #[derive(Debug, Deserialize)]
  500. struct WebFetchInput {
  501. url: String,
  502. prompt: String,
  503. }
  504. #[derive(Debug, Deserialize)]
  505. struct WebSearchInput {
  506. query: String,
  507. allowed_domains: Option<Vec<String>>,
  508. blocked_domains: Option<Vec<String>>,
  509. }
  510. #[derive(Debug, Deserialize)]
  511. struct TodoWriteInput {
  512. todos: Vec<TodoItem>,
  513. }
  514. #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
  515. struct TodoItem {
  516. content: String,
  517. #[serde(rename = "activeForm")]
  518. active_form: String,
  519. status: TodoStatus,
  520. }
  521. #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
  522. #[serde(rename_all = "snake_case")]
  523. enum TodoStatus {
  524. Pending,
  525. InProgress,
  526. Completed,
  527. }
  528. #[derive(Debug, Deserialize)]
  529. struct SkillInput {
  530. skill: String,
  531. args: Option<String>,
  532. }
  533. #[derive(Debug, Deserialize)]
  534. struct AgentInput {
  535. description: String,
  536. prompt: String,
  537. subagent_type: Option<String>,
  538. name: Option<String>,
  539. model: Option<String>,
  540. }
  541. #[derive(Debug, Deserialize)]
  542. struct ToolSearchInput {
  543. query: String,
  544. max_results: Option<usize>,
  545. }
  546. #[derive(Debug, Deserialize)]
  547. struct NotebookEditInput {
  548. notebook_path: String,
  549. cell_id: Option<String>,
  550. new_source: Option<String>,
  551. cell_type: Option<NotebookCellType>,
  552. edit_mode: Option<NotebookEditMode>,
  553. }
  554. #[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
  555. #[serde(rename_all = "lowercase")]
  556. enum NotebookCellType {
  557. Code,
  558. Markdown,
  559. }
  560. #[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
  561. #[serde(rename_all = "lowercase")]
  562. enum NotebookEditMode {
  563. Replace,
  564. Insert,
  565. Delete,
  566. }
  567. #[derive(Debug, Deserialize)]
  568. struct SleepInput {
  569. duration_ms: u64,
  570. }
  571. #[derive(Debug, Deserialize)]
  572. struct BriefInput {
  573. message: String,
  574. attachments: Option<Vec<String>>,
  575. status: BriefStatus,
  576. }
  577. #[derive(Debug, Deserialize)]
  578. #[serde(rename_all = "lowercase")]
  579. enum BriefStatus {
  580. Normal,
  581. Proactive,
  582. }
  583. #[derive(Debug, Deserialize)]
  584. struct ConfigInput {
  585. setting: String,
  586. value: Option<ConfigValue>,
  587. }
  588. #[derive(Debug, Deserialize)]
  589. #[serde(untagged)]
  590. enum ConfigValue {
  591. String(String),
  592. Bool(bool),
  593. Number(f64),
  594. }
  595. #[derive(Debug, Deserialize)]
  596. #[serde(transparent)]
  597. struct StructuredOutputInput(BTreeMap<String, Value>);
  598. #[derive(Debug, Deserialize)]
  599. struct ReplInput {
  600. code: String,
  601. language: String,
  602. timeout_ms: Option<u64>,
  603. }
  604. #[derive(Debug, Deserialize)]
  605. struct PowerShellInput {
  606. command: String,
  607. timeout: Option<u64>,
  608. description: Option<String>,
  609. run_in_background: Option<bool>,
  610. }
  611. #[derive(Debug, Serialize)]
  612. struct WebFetchOutput {
  613. bytes: usize,
  614. code: u16,
  615. #[serde(rename = "codeText")]
  616. code_text: String,
  617. result: String,
  618. #[serde(rename = "durationMs")]
  619. duration_ms: u128,
  620. url: String,
  621. }
  622. #[derive(Debug, Serialize)]
  623. struct WebSearchOutput {
  624. query: String,
  625. results: Vec<WebSearchResultItem>,
  626. #[serde(rename = "durationSeconds")]
  627. duration_seconds: f64,
  628. }
  629. #[derive(Debug, Serialize)]
  630. struct TodoWriteOutput {
  631. #[serde(rename = "oldTodos")]
  632. old_todos: Vec<TodoItem>,
  633. #[serde(rename = "newTodos")]
  634. new_todos: Vec<TodoItem>,
  635. #[serde(rename = "verificationNudgeNeeded")]
  636. verification_nudge_needed: Option<bool>,
  637. }
  638. #[derive(Debug, Serialize)]
  639. struct SkillOutput {
  640. skill: String,
  641. path: String,
  642. args: Option<String>,
  643. description: Option<String>,
  644. prompt: String,
  645. }
  646. #[derive(Debug, Serialize, Deserialize)]
  647. struct AgentOutput {
  648. #[serde(rename = "agentId")]
  649. agent_id: String,
  650. name: String,
  651. description: String,
  652. #[serde(rename = "subagentType")]
  653. subagent_type: Option<String>,
  654. model: Option<String>,
  655. status: String,
  656. #[serde(rename = "outputFile")]
  657. output_file: String,
  658. #[serde(rename = "manifestFile")]
  659. manifest_file: String,
  660. #[serde(rename = "createdAt")]
  661. created_at: String,
  662. }
  663. #[derive(Debug, Serialize)]
  664. struct ToolSearchOutput {
  665. matches: Vec<String>,
  666. query: String,
  667. normalized_query: String,
  668. #[serde(rename = "total_deferred_tools")]
  669. total_deferred_tools: usize,
  670. #[serde(rename = "pending_mcp_servers")]
  671. pending_mcp_servers: Option<Vec<String>>,
  672. }
  673. #[derive(Debug, Serialize)]
  674. struct NotebookEditOutput {
  675. new_source: String,
  676. cell_id: Option<String>,
  677. cell_type: Option<NotebookCellType>,
  678. language: String,
  679. edit_mode: String,
  680. error: Option<String>,
  681. notebook_path: String,
  682. original_file: String,
  683. updated_file: String,
  684. }
  685. #[derive(Debug, Serialize)]
  686. struct SleepOutput {
  687. duration_ms: u64,
  688. message: String,
  689. }
  690. #[derive(Debug, Serialize)]
  691. struct BriefOutput {
  692. message: String,
  693. attachments: Option<Vec<ResolvedAttachment>>,
  694. #[serde(rename = "sentAt")]
  695. sent_at: String,
  696. }
  697. #[derive(Debug, Serialize)]
  698. struct ResolvedAttachment {
  699. path: String,
  700. size: u64,
  701. #[serde(rename = "isImage")]
  702. is_image: bool,
  703. }
  704. #[derive(Debug, Serialize)]
  705. struct ConfigOutput {
  706. success: bool,
  707. operation: Option<String>,
  708. setting: Option<String>,
  709. value: Option<Value>,
  710. #[serde(rename = "previousValue")]
  711. previous_value: Option<Value>,
  712. #[serde(rename = "newValue")]
  713. new_value: Option<Value>,
  714. error: Option<String>,
  715. }
  716. #[derive(Debug, Serialize)]
  717. struct StructuredOutputResult {
  718. data: String,
  719. structured_output: BTreeMap<String, Value>,
  720. }
  721. #[derive(Debug, Serialize)]
  722. struct ReplOutput {
  723. language: String,
  724. stdout: String,
  725. stderr: String,
  726. #[serde(rename = "exitCode")]
  727. exit_code: i32,
  728. #[serde(rename = "durationMs")]
  729. duration_ms: u128,
  730. }
  731. #[derive(Debug, Serialize)]
  732. #[serde(untagged)]
  733. enum WebSearchResultItem {
  734. SearchResult {
  735. tool_use_id: String,
  736. content: Vec<SearchHit>,
  737. },
  738. Commentary(String),
  739. }
  740. #[derive(Debug, Serialize)]
  741. struct SearchHit {
  742. title: String,
  743. url: String,
  744. }
  745. fn execute_web_fetch(input: &WebFetchInput) -> Result<WebFetchOutput, String> {
  746. let started = Instant::now();
  747. let client = build_http_client()?;
  748. let request_url = normalize_fetch_url(&input.url)?;
  749. let response = client
  750. .get(request_url.clone())
  751. .send()
  752. .map_err(|error| error.to_string())?;
  753. let status = response.status();
  754. let final_url = response.url().to_string();
  755. let code = status.as_u16();
  756. let code_text = status.canonical_reason().unwrap_or("Unknown").to_string();
  757. let content_type = response
  758. .headers()
  759. .get(reqwest::header::CONTENT_TYPE)
  760. .and_then(|value| value.to_str().ok())
  761. .unwrap_or_default()
  762. .to_string();
  763. let body = response.text().map_err(|error| error.to_string())?;
  764. let bytes = body.len();
  765. let normalized = normalize_fetched_content(&body, &content_type);
  766. let result = summarize_web_fetch(&final_url, &input.prompt, &normalized, &body, &content_type);
  767. Ok(WebFetchOutput {
  768. bytes,
  769. code,
  770. code_text,
  771. result,
  772. duration_ms: started.elapsed().as_millis(),
  773. url: final_url,
  774. })
  775. }
  776. fn execute_web_search(input: &WebSearchInput) -> Result<WebSearchOutput, String> {
  777. let started = Instant::now();
  778. let client = build_http_client()?;
  779. let search_url = build_search_url(&input.query)?;
  780. let response = client
  781. .get(search_url)
  782. .send()
  783. .map_err(|error| error.to_string())?;
  784. let final_url = response.url().clone();
  785. let html = response.text().map_err(|error| error.to_string())?;
  786. let mut hits = extract_search_hits(&html);
  787. if hits.is_empty() && final_url.host_str().is_some() {
  788. hits = extract_search_hits_from_generic_links(&html);
  789. }
  790. if let Some(allowed) = input.allowed_domains.as_ref() {
  791. hits.retain(|hit| host_matches_list(&hit.url, allowed));
  792. }
  793. if let Some(blocked) = input.blocked_domains.as_ref() {
  794. hits.retain(|hit| !host_matches_list(&hit.url, blocked));
  795. }
  796. dedupe_hits(&mut hits);
  797. hits.truncate(8);
  798. let summary = if hits.is_empty() {
  799. format!("No web search results matched the query {:?}.", input.query)
  800. } else {
  801. let rendered_hits = hits
  802. .iter()
  803. .map(|hit| format!("- [{}]({})", hit.title, hit.url))
  804. .collect::<Vec<_>>()
  805. .join("\n");
  806. format!(
  807. "Search results for {:?}. Include a Sources section in the final answer.\n{}",
  808. input.query, rendered_hits
  809. )
  810. };
  811. Ok(WebSearchOutput {
  812. query: input.query.clone(),
  813. results: vec![
  814. WebSearchResultItem::Commentary(summary),
  815. WebSearchResultItem::SearchResult {
  816. tool_use_id: String::from("web_search_1"),
  817. content: hits,
  818. },
  819. ],
  820. duration_seconds: started.elapsed().as_secs_f64(),
  821. })
  822. }
  823. fn build_http_client() -> Result<Client, String> {
  824. Client::builder()
  825. .timeout(Duration::from_secs(20))
  826. .redirect(reqwest::redirect::Policy::limited(10))
  827. .user_agent("clawd-rust-tools/0.1")
  828. .build()
  829. .map_err(|error| error.to_string())
  830. }
  831. fn normalize_fetch_url(url: &str) -> Result<String, String> {
  832. let parsed = reqwest::Url::parse(url).map_err(|error| error.to_string())?;
  833. if parsed.scheme() == "http" {
  834. let host = parsed.host_str().unwrap_or_default();
  835. if host != "localhost" && host != "127.0.0.1" && host != "::1" {
  836. let mut upgraded = parsed;
  837. upgraded
  838. .set_scheme("https")
  839. .map_err(|()| String::from("failed to upgrade URL to https"))?;
  840. return Ok(upgraded.to_string());
  841. }
  842. }
  843. Ok(parsed.to_string())
  844. }
  845. fn build_search_url(query: &str) -> Result<reqwest::Url, String> {
  846. if let Ok(base) = std::env::var("CLAWD_WEB_SEARCH_BASE_URL") {
  847. let mut url = reqwest::Url::parse(&base).map_err(|error| error.to_string())?;
  848. url.query_pairs_mut().append_pair("q", query);
  849. return Ok(url);
  850. }
  851. let mut url = reqwest::Url::parse("https://html.duckduckgo.com/html/")
  852. .map_err(|error| error.to_string())?;
  853. url.query_pairs_mut().append_pair("q", query);
  854. Ok(url)
  855. }
  856. fn normalize_fetched_content(body: &str, content_type: &str) -> String {
  857. if content_type.contains("html") {
  858. html_to_text(body)
  859. } else {
  860. body.trim().to_string()
  861. }
  862. }
  863. fn summarize_web_fetch(
  864. url: &str,
  865. prompt: &str,
  866. content: &str,
  867. raw_body: &str,
  868. content_type: &str,
  869. ) -> String {
  870. let lower_prompt = prompt.to_lowercase();
  871. let compact = collapse_whitespace(content);
  872. let detail = if lower_prompt.contains("title") {
  873. extract_title(content, raw_body, content_type).map_or_else(
  874. || preview_text(&compact, 600),
  875. |title| format!("Title: {title}"),
  876. )
  877. } else if lower_prompt.contains("summary") || lower_prompt.contains("summarize") {
  878. preview_text(&compact, 900)
  879. } else {
  880. let preview = preview_text(&compact, 900);
  881. format!("Prompt: {prompt}\nContent preview:\n{preview}")
  882. };
  883. format!("Fetched {url}\n{detail}")
  884. }
  885. fn extract_title(content: &str, raw_body: &str, content_type: &str) -> Option<String> {
  886. if content_type.contains("html") {
  887. let lowered = raw_body.to_lowercase();
  888. if let Some(start) = lowered.find("<title>") {
  889. let after = start + "<title>".len();
  890. if let Some(end_rel) = lowered[after..].find("</title>") {
  891. let title =
  892. collapse_whitespace(&decode_html_entities(&raw_body[after..after + end_rel]));
  893. if !title.is_empty() {
  894. return Some(title);
  895. }
  896. }
  897. }
  898. }
  899. for line in content.lines() {
  900. let trimmed = line.trim();
  901. if !trimmed.is_empty() {
  902. return Some(trimmed.to_string());
  903. }
  904. }
  905. None
  906. }
  907. fn html_to_text(html: &str) -> String {
  908. let mut text = String::with_capacity(html.len());
  909. let mut in_tag = false;
  910. let mut previous_was_space = false;
  911. for ch in html.chars() {
  912. match ch {
  913. '<' => in_tag = true,
  914. '>' => in_tag = false,
  915. _ if in_tag => {}
  916. '&' => {
  917. text.push('&');
  918. previous_was_space = false;
  919. }
  920. ch if ch.is_whitespace() => {
  921. if !previous_was_space {
  922. text.push(' ');
  923. previous_was_space = true;
  924. }
  925. }
  926. _ => {
  927. text.push(ch);
  928. previous_was_space = false;
  929. }
  930. }
  931. }
  932. collapse_whitespace(&decode_html_entities(&text))
  933. }
  934. fn decode_html_entities(input: &str) -> String {
  935. input
  936. .replace("&amp;", "&")
  937. .replace("&lt;", "<")
  938. .replace("&gt;", ">")
  939. .replace("&quot;", "\"")
  940. .replace("&#39;", "'")
  941. .replace("&nbsp;", " ")
  942. }
  943. fn collapse_whitespace(input: &str) -> String {
  944. input.split_whitespace().collect::<Vec<_>>().join(" ")
  945. }
  946. fn preview_text(input: &str, max_chars: usize) -> String {
  947. if input.chars().count() <= max_chars {
  948. return input.to_string();
  949. }
  950. let shortened = input.chars().take(max_chars).collect::<String>();
  951. format!("{}…", shortened.trim_end())
  952. }
  953. fn extract_search_hits(html: &str) -> Vec<SearchHit> {
  954. let mut hits = Vec::new();
  955. let mut remaining = html;
  956. while let Some(anchor_start) = remaining.find("result__a") {
  957. let after_class = &remaining[anchor_start..];
  958. let Some(href_idx) = after_class.find("href=") else {
  959. remaining = &after_class[1..];
  960. continue;
  961. };
  962. let href_slice = &after_class[href_idx + 5..];
  963. let Some((url, rest)) = extract_quoted_value(href_slice) else {
  964. remaining = &after_class[1..];
  965. continue;
  966. };
  967. let Some(close_tag_idx) = rest.find('>') else {
  968. remaining = &after_class[1..];
  969. continue;
  970. };
  971. let after_tag = &rest[close_tag_idx + 1..];
  972. let Some(end_anchor_idx) = after_tag.find("</a>") else {
  973. remaining = &after_tag[1..];
  974. continue;
  975. };
  976. let title = html_to_text(&after_tag[..end_anchor_idx]);
  977. if let Some(decoded_url) = decode_duckduckgo_redirect(&url) {
  978. hits.push(SearchHit {
  979. title: title.trim().to_string(),
  980. url: decoded_url,
  981. });
  982. }
  983. remaining = &after_tag[end_anchor_idx + 4..];
  984. }
  985. hits
  986. }
  987. fn extract_search_hits_from_generic_links(html: &str) -> Vec<SearchHit> {
  988. let mut hits = Vec::new();
  989. let mut remaining = html;
  990. while let Some(anchor_start) = remaining.find("<a") {
  991. let after_anchor = &remaining[anchor_start..];
  992. let Some(href_idx) = after_anchor.find("href=") else {
  993. remaining = &after_anchor[2..];
  994. continue;
  995. };
  996. let href_slice = &after_anchor[href_idx + 5..];
  997. let Some((url, rest)) = extract_quoted_value(href_slice) else {
  998. remaining = &after_anchor[2..];
  999. continue;
  1000. };
  1001. let Some(close_tag_idx) = rest.find('>') else {
  1002. remaining = &after_anchor[2..];
  1003. continue;
  1004. };
  1005. let after_tag = &rest[close_tag_idx + 1..];
  1006. let Some(end_anchor_idx) = after_tag.find("</a>") else {
  1007. remaining = &after_anchor[2..];
  1008. continue;
  1009. };
  1010. let title = html_to_text(&after_tag[..end_anchor_idx]);
  1011. if title.trim().is_empty() {
  1012. remaining = &after_tag[end_anchor_idx + 4..];
  1013. continue;
  1014. }
  1015. let decoded_url = decode_duckduckgo_redirect(&url).unwrap_or(url);
  1016. if decoded_url.starts_with("http://") || decoded_url.starts_with("https://") {
  1017. hits.push(SearchHit {
  1018. title: title.trim().to_string(),
  1019. url: decoded_url,
  1020. });
  1021. }
  1022. remaining = &after_tag[end_anchor_idx + 4..];
  1023. }
  1024. hits
  1025. }
  1026. fn extract_quoted_value(input: &str) -> Option<(String, &str)> {
  1027. let quote = input.chars().next()?;
  1028. if quote != '"' && quote != '\'' {
  1029. return None;
  1030. }
  1031. let rest = &input[quote.len_utf8()..];
  1032. let end = rest.find(quote)?;
  1033. Some((rest[..end].to_string(), &rest[end + quote.len_utf8()..]))
  1034. }
  1035. fn decode_duckduckgo_redirect(url: &str) -> Option<String> {
  1036. if url.starts_with("http://") || url.starts_with("https://") {
  1037. return Some(html_entity_decode_url(url));
  1038. }
  1039. let joined = if url.starts_with("//") {
  1040. format!("https:{url}")
  1041. } else if url.starts_with('/') {
  1042. format!("https://duckduckgo.com{url}")
  1043. } else {
  1044. return None;
  1045. };
  1046. let parsed = reqwest::Url::parse(&joined).ok()?;
  1047. if parsed.path() == "/l/" || parsed.path() == "/l" {
  1048. for (key, value) in parsed.query_pairs() {
  1049. if key == "uddg" {
  1050. return Some(html_entity_decode_url(value.as_ref()));
  1051. }
  1052. }
  1053. }
  1054. Some(joined)
  1055. }
  1056. fn html_entity_decode_url(url: &str) -> String {
  1057. decode_html_entities(url)
  1058. }
  1059. fn host_matches_list(url: &str, domains: &[String]) -> bool {
  1060. let Ok(parsed) = reqwest::Url::parse(url) else {
  1061. return false;
  1062. };
  1063. let Some(host) = parsed.host_str() else {
  1064. return false;
  1065. };
  1066. let host = host.to_ascii_lowercase();
  1067. domains.iter().any(|domain| {
  1068. let normalized = normalize_domain_filter(domain);
  1069. !normalized.is_empty() && (host == normalized || host.ends_with(&format!(".{normalized}")))
  1070. })
  1071. }
  1072. fn normalize_domain_filter(domain: &str) -> String {
  1073. let trimmed = domain.trim();
  1074. let candidate = reqwest::Url::parse(trimmed)
  1075. .ok()
  1076. .and_then(|url| url.host_str().map(str::to_string))
  1077. .unwrap_or_else(|| trimmed.to_string());
  1078. candidate
  1079. .trim()
  1080. .trim_start_matches('.')
  1081. .trim_end_matches('/')
  1082. .to_ascii_lowercase()
  1083. }
  1084. fn dedupe_hits(hits: &mut Vec<SearchHit>) {
  1085. let mut seen = BTreeSet::new();
  1086. hits.retain(|hit| seen.insert(hit.url.clone()));
  1087. }
  1088. fn execute_todo_write(input: TodoWriteInput) -> Result<TodoWriteOutput, String> {
  1089. validate_todos(&input.todos)?;
  1090. let store_path = todo_store_path()?;
  1091. let old_todos = if store_path.exists() {
  1092. serde_json::from_str::<Vec<TodoItem>>(
  1093. &std::fs::read_to_string(&store_path).map_err(|error| error.to_string())?,
  1094. )
  1095. .map_err(|error| error.to_string())?
  1096. } else {
  1097. Vec::new()
  1098. };
  1099. let all_done = input
  1100. .todos
  1101. .iter()
  1102. .all(|todo| matches!(todo.status, TodoStatus::Completed));
  1103. let persisted = if all_done {
  1104. Vec::new()
  1105. } else {
  1106. input.todos.clone()
  1107. };
  1108. if let Some(parent) = store_path.parent() {
  1109. std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
  1110. }
  1111. std::fs::write(
  1112. &store_path,
  1113. serde_json::to_string_pretty(&persisted).map_err(|error| error.to_string())?,
  1114. )
  1115. .map_err(|error| error.to_string())?;
  1116. let verification_nudge_needed = (all_done
  1117. && input.todos.len() >= 3
  1118. && !input
  1119. .todos
  1120. .iter()
  1121. .any(|todo| todo.content.to_lowercase().contains("verif")))
  1122. .then_some(true);
  1123. Ok(TodoWriteOutput {
  1124. old_todos,
  1125. new_todos: input.todos,
  1126. verification_nudge_needed,
  1127. })
  1128. }
  1129. fn execute_skill(input: SkillInput) -> Result<SkillOutput, String> {
  1130. let skill_path = resolve_skill_path(&input.skill)?;
  1131. let prompt = std::fs::read_to_string(&skill_path).map_err(|error| error.to_string())?;
  1132. let description = parse_skill_description(&prompt);
  1133. Ok(SkillOutput {
  1134. skill: input.skill,
  1135. path: skill_path.display().to_string(),
  1136. args: input.args,
  1137. description,
  1138. prompt,
  1139. })
  1140. }
  1141. fn validate_todos(todos: &[TodoItem]) -> Result<(), String> {
  1142. if todos.is_empty() {
  1143. return Err(String::from("todos must not be empty"));
  1144. }
  1145. let in_progress = todos
  1146. .iter()
  1147. .filter(|todo| matches!(todo.status, TodoStatus::InProgress))
  1148. .count();
  1149. if in_progress > 1 {
  1150. return Err(String::from(
  1151. "exactly zero or one todo items may be in_progress",
  1152. ));
  1153. }
  1154. if todos.iter().any(|todo| todo.content.trim().is_empty()) {
  1155. return Err(String::from("todo content must not be empty"));
  1156. }
  1157. if todos.iter().any(|todo| todo.active_form.trim().is_empty()) {
  1158. return Err(String::from("todo activeForm must not be empty"));
  1159. }
  1160. Ok(())
  1161. }
  1162. fn todo_store_path() -> Result<std::path::PathBuf, String> {
  1163. if let Ok(path) = std::env::var("CLAWD_TODO_STORE") {
  1164. return Ok(std::path::PathBuf::from(path));
  1165. }
  1166. let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
  1167. Ok(cwd.join(".clawd-todos.json"))
  1168. }
  1169. fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
  1170. let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
  1171. if requested.is_empty() {
  1172. return Err(String::from("skill must not be empty"));
  1173. }
  1174. let mut candidates = Vec::new();
  1175. if let Ok(codex_home) = std::env::var("CODEX_HOME") {
  1176. candidates.push(std::path::PathBuf::from(codex_home).join("skills"));
  1177. }
  1178. candidates.push(std::path::PathBuf::from("/home/bellman/.codex/skills"));
  1179. for root in candidates {
  1180. let direct = root.join(requested).join("SKILL.md");
  1181. if direct.exists() {
  1182. return Ok(direct);
  1183. }
  1184. if let Ok(entries) = std::fs::read_dir(&root) {
  1185. for entry in entries.flatten() {
  1186. let path = entry.path().join("SKILL.md");
  1187. if !path.exists() {
  1188. continue;
  1189. }
  1190. if entry
  1191. .file_name()
  1192. .to_string_lossy()
  1193. .eq_ignore_ascii_case(requested)
  1194. {
  1195. return Ok(path);
  1196. }
  1197. }
  1198. }
  1199. }
  1200. Err(format!("unknown skill: {requested}"))
  1201. }
  1202. fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
  1203. if input.description.trim().is_empty() {
  1204. return Err(String::from("description must not be empty"));
  1205. }
  1206. if input.prompt.trim().is_empty() {
  1207. return Err(String::from("prompt must not be empty"));
  1208. }
  1209. let agent_id = make_agent_id();
  1210. let output_dir = agent_store_dir()?;
  1211. std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?;
  1212. let output_file = output_dir.join(format!("{agent_id}.md"));
  1213. let manifest_file = output_dir.join(format!("{agent_id}.json"));
  1214. let normalized_subagent_type = normalize_subagent_type(input.subagent_type.as_deref());
  1215. let agent_name = input
  1216. .name
  1217. .as_deref()
  1218. .map(slugify_agent_name)
  1219. .filter(|name| !name.is_empty())
  1220. .unwrap_or_else(|| slugify_agent_name(&input.description));
  1221. let created_at = iso8601_now();
  1222. let output_contents = format!(
  1223. "# Agent Task
  1224. - id: {}
  1225. - name: {}
  1226. - description: {}
  1227. - subagent_type: {}
  1228. - created_at: {}
  1229. ## Prompt
  1230. {}
  1231. ",
  1232. agent_id, agent_name, input.description, normalized_subagent_type, created_at, input.prompt
  1233. );
  1234. std::fs::write(&output_file, output_contents).map_err(|error| error.to_string())?;
  1235. let manifest = AgentOutput {
  1236. agent_id,
  1237. name: agent_name,
  1238. description: input.description,
  1239. subagent_type: Some(normalized_subagent_type),
  1240. model: input.model,
  1241. status: String::from("queued"),
  1242. output_file: output_file.display().to_string(),
  1243. manifest_file: manifest_file.display().to_string(),
  1244. created_at,
  1245. };
  1246. std::fs::write(
  1247. &manifest_file,
  1248. serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?,
  1249. )
  1250. .map_err(|error| error.to_string())?;
  1251. Ok(manifest)
  1252. }
  1253. #[allow(clippy::needless_pass_by_value)]
  1254. fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput {
  1255. let deferred = deferred_tool_specs();
  1256. let max_results = input.max_results.unwrap_or(5).max(1);
  1257. let query = input.query.trim().to_string();
  1258. let normalized_query = normalize_tool_search_query(&query);
  1259. let matches = search_tool_specs(&query, max_results, &deferred);
  1260. ToolSearchOutput {
  1261. matches,
  1262. query,
  1263. normalized_query,
  1264. total_deferred_tools: deferred.len(),
  1265. pending_mcp_servers: None,
  1266. }
  1267. }
  1268. fn deferred_tool_specs() -> Vec<ToolSpec> {
  1269. mvp_tool_specs()
  1270. .into_iter()
  1271. .filter(|spec| {
  1272. !matches!(
  1273. spec.name,
  1274. "bash" | "read_file" | "write_file" | "edit_file" | "glob_search" | "grep_search"
  1275. )
  1276. })
  1277. .collect()
  1278. }
  1279. fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec<String> {
  1280. let lowered = query.to_lowercase();
  1281. if let Some(selection) = lowered.strip_prefix("select:") {
  1282. return selection
  1283. .split(',')
  1284. .map(str::trim)
  1285. .filter(|part| !part.is_empty())
  1286. .filter_map(|wanted| {
  1287. let wanted = canonical_tool_token(wanted);
  1288. specs
  1289. .iter()
  1290. .find(|spec| canonical_tool_token(spec.name) == wanted)
  1291. .map(|spec| spec.name.to_string())
  1292. })
  1293. .take(max_results)
  1294. .collect();
  1295. }
  1296. let mut required = Vec::new();
  1297. let mut optional = Vec::new();
  1298. for term in lowered.split_whitespace() {
  1299. if let Some(rest) = term.strip_prefix('+') {
  1300. if !rest.is_empty() {
  1301. required.push(rest);
  1302. }
  1303. } else {
  1304. optional.push(term);
  1305. }
  1306. }
  1307. let terms = if required.is_empty() {
  1308. optional.clone()
  1309. } else {
  1310. required.iter().chain(optional.iter()).copied().collect()
  1311. };
  1312. let mut scored = specs
  1313. .iter()
  1314. .filter_map(|spec| {
  1315. let name = spec.name.to_lowercase();
  1316. let canonical_name = canonical_tool_token(spec.name);
  1317. let normalized_description = normalize_tool_search_query(spec.description);
  1318. let haystack = format!(
  1319. "{name} {} {canonical_name}",
  1320. spec.description.to_lowercase()
  1321. );
  1322. let normalized_haystack = format!("{canonical_name} {normalized_description}");
  1323. if required.iter().any(|term| !haystack.contains(term)) {
  1324. return None;
  1325. }
  1326. let mut score = 0_i32;
  1327. for term in &terms {
  1328. let canonical_term = canonical_tool_token(term);
  1329. if haystack.contains(term) {
  1330. score += 2;
  1331. }
  1332. if name == *term {
  1333. score += 8;
  1334. }
  1335. if name.contains(term) {
  1336. score += 4;
  1337. }
  1338. if canonical_name == canonical_term {
  1339. score += 12;
  1340. }
  1341. if normalized_haystack.contains(&canonical_term) {
  1342. score += 3;
  1343. }
  1344. }
  1345. if score == 0 && !lowered.is_empty() {
  1346. return None;
  1347. }
  1348. Some((score, spec.name.to_string()))
  1349. })
  1350. .collect::<Vec<_>>();
  1351. scored.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
  1352. scored
  1353. .into_iter()
  1354. .map(|(_, name)| name)
  1355. .take(max_results)
  1356. .collect()
  1357. }
  1358. fn normalize_tool_search_query(query: &str) -> String {
  1359. query
  1360. .trim()
  1361. .split(|ch: char| ch.is_whitespace() || ch == ',')
  1362. .filter(|term| !term.is_empty())
  1363. .map(canonical_tool_token)
  1364. .collect::<Vec<_>>()
  1365. .join(" ")
  1366. }
  1367. fn canonical_tool_token(value: &str) -> String {
  1368. let mut canonical = value
  1369. .chars()
  1370. .filter(char::is_ascii_alphanumeric)
  1371. .flat_map(char::to_lowercase)
  1372. .collect::<String>();
  1373. if let Some(stripped) = canonical.strip_suffix("tool") {
  1374. canonical = stripped.to_string();
  1375. }
  1376. canonical
  1377. }
  1378. fn agent_store_dir() -> Result<std::path::PathBuf, String> {
  1379. if let Ok(path) = std::env::var("CLAWD_AGENT_STORE") {
  1380. return Ok(std::path::PathBuf::from(path));
  1381. }
  1382. let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
  1383. if let Some(workspace_root) = cwd.ancestors().nth(2) {
  1384. return Ok(workspace_root.join(".clawd-agents"));
  1385. }
  1386. Ok(cwd.join(".clawd-agents"))
  1387. }
  1388. fn make_agent_id() -> String {
  1389. let nanos = std::time::SystemTime::now()
  1390. .duration_since(std::time::UNIX_EPOCH)
  1391. .unwrap_or_default()
  1392. .as_nanos();
  1393. format!("agent-{nanos}")
  1394. }
  1395. fn slugify_agent_name(description: &str) -> String {
  1396. let mut out = description
  1397. .chars()
  1398. .map(|ch| {
  1399. if ch.is_ascii_alphanumeric() {
  1400. ch.to_ascii_lowercase()
  1401. } else {
  1402. '-'
  1403. }
  1404. })
  1405. .collect::<String>();
  1406. while out.contains("--") {
  1407. out = out.replace("--", "-");
  1408. }
  1409. out.trim_matches('-').chars().take(32).collect()
  1410. }
  1411. fn normalize_subagent_type(subagent_type: Option<&str>) -> String {
  1412. let trimmed = subagent_type.map(str::trim).unwrap_or_default();
  1413. if trimmed.is_empty() {
  1414. return String::from("general-purpose");
  1415. }
  1416. match canonical_tool_token(trimmed).as_str() {
  1417. "general" | "generalpurpose" | "generalpurposeagent" => String::from("general-purpose"),
  1418. "explore" | "explorer" | "exploreagent" => String::from("Explore"),
  1419. "plan" | "planagent" => String::from("Plan"),
  1420. "verification" | "verificationagent" | "verify" | "verifier" => {
  1421. String::from("Verification")
  1422. }
  1423. "claudecodeguide" | "claudecodeguideagent" | "guide" => String::from("claude-code-guide"),
  1424. "statusline" | "statuslinesetup" => String::from("statusline-setup"),
  1425. _ => trimmed.to_string(),
  1426. }
  1427. }
  1428. fn iso8601_now() -> String {
  1429. std::time::SystemTime::now()
  1430. .duration_since(std::time::UNIX_EPOCH)
  1431. .unwrap_or_default()
  1432. .as_secs()
  1433. .to_string()
  1434. }
  1435. #[allow(clippy::too_many_lines)]
  1436. fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput, String> {
  1437. let path = std::path::PathBuf::from(&input.notebook_path);
  1438. if path.extension().and_then(|ext| ext.to_str()) != Some("ipynb") {
  1439. return Err(String::from(
  1440. "File must be a Jupyter notebook (.ipynb file).",
  1441. ));
  1442. }
  1443. let original_file = std::fs::read_to_string(&path).map_err(|error| error.to_string())?;
  1444. let mut notebook: serde_json::Value =
  1445. serde_json::from_str(&original_file).map_err(|error| error.to_string())?;
  1446. let language = notebook
  1447. .get("metadata")
  1448. .and_then(|metadata| metadata.get("kernelspec"))
  1449. .and_then(|kernelspec| kernelspec.get("language"))
  1450. .and_then(serde_json::Value::as_str)
  1451. .unwrap_or("python")
  1452. .to_string();
  1453. let cells = notebook
  1454. .get_mut("cells")
  1455. .and_then(serde_json::Value::as_array_mut)
  1456. .ok_or_else(|| String::from("Notebook cells array not found"))?;
  1457. let edit_mode = input.edit_mode.unwrap_or(NotebookEditMode::Replace);
  1458. let target_index = match input.cell_id.as_deref() {
  1459. Some(cell_id) => Some(resolve_cell_index(cells, Some(cell_id), edit_mode)?),
  1460. None if matches!(
  1461. edit_mode,
  1462. NotebookEditMode::Replace | NotebookEditMode::Delete
  1463. ) =>
  1464. {
  1465. Some(resolve_cell_index(cells, None, edit_mode)?)
  1466. }
  1467. None => None,
  1468. };
  1469. let resolved_cell_type = match edit_mode {
  1470. NotebookEditMode::Delete => None,
  1471. NotebookEditMode::Insert => Some(input.cell_type.unwrap_or(NotebookCellType::Code)),
  1472. NotebookEditMode::Replace => Some(input.cell_type.unwrap_or_else(|| {
  1473. target_index
  1474. .and_then(|index| cells.get(index))
  1475. .and_then(cell_kind)
  1476. .unwrap_or(NotebookCellType::Code)
  1477. })),
  1478. };
  1479. let new_source = require_notebook_source(input.new_source, edit_mode)?;
  1480. let cell_id = match edit_mode {
  1481. NotebookEditMode::Insert => {
  1482. let resolved_cell_type = resolved_cell_type.expect("insert cell type");
  1483. let new_id = make_cell_id(cells.len());
  1484. let new_cell = build_notebook_cell(&new_id, resolved_cell_type, &new_source);
  1485. let insert_at = target_index.map_or(cells.len(), |index| index + 1);
  1486. cells.insert(insert_at, new_cell);
  1487. cells
  1488. .get(insert_at)
  1489. .and_then(|cell| cell.get("id"))
  1490. .and_then(serde_json::Value::as_str)
  1491. .map(ToString::to_string)
  1492. }
  1493. NotebookEditMode::Delete => {
  1494. let removed = cells.remove(target_index.expect("delete target index"));
  1495. removed
  1496. .get("id")
  1497. .and_then(serde_json::Value::as_str)
  1498. .map(ToString::to_string)
  1499. }
  1500. NotebookEditMode::Replace => {
  1501. let resolved_cell_type = resolved_cell_type.expect("replace cell type");
  1502. let cell = cells
  1503. .get_mut(target_index.expect("replace target index"))
  1504. .ok_or_else(|| String::from("Cell index out of range"))?;
  1505. cell["source"] = serde_json::Value::Array(source_lines(&new_source));
  1506. cell["cell_type"] = serde_json::Value::String(match resolved_cell_type {
  1507. NotebookCellType::Code => String::from("code"),
  1508. NotebookCellType::Markdown => String::from("markdown"),
  1509. });
  1510. match resolved_cell_type {
  1511. NotebookCellType::Code => {
  1512. if !cell.get("outputs").is_some_and(serde_json::Value::is_array) {
  1513. cell["outputs"] = json!([]);
  1514. }
  1515. if cell.get("execution_count").is_none() {
  1516. cell["execution_count"] = serde_json::Value::Null;
  1517. }
  1518. }
  1519. NotebookCellType::Markdown => {
  1520. if let Some(object) = cell.as_object_mut() {
  1521. object.remove("outputs");
  1522. object.remove("execution_count");
  1523. }
  1524. }
  1525. }
  1526. cell.get("id")
  1527. .and_then(serde_json::Value::as_str)
  1528. .map(ToString::to_string)
  1529. }
  1530. };
  1531. let updated_file =
  1532. serde_json::to_string_pretty(&notebook).map_err(|error| error.to_string())?;
  1533. std::fs::write(&path, &updated_file).map_err(|error| error.to_string())?;
  1534. Ok(NotebookEditOutput {
  1535. new_source,
  1536. cell_id,
  1537. cell_type: resolved_cell_type,
  1538. language,
  1539. edit_mode: format_notebook_edit_mode(edit_mode),
  1540. error: None,
  1541. notebook_path: path.display().to_string(),
  1542. original_file,
  1543. updated_file,
  1544. })
  1545. }
  1546. fn require_notebook_source(
  1547. source: Option<String>,
  1548. edit_mode: NotebookEditMode,
  1549. ) -> Result<String, String> {
  1550. match edit_mode {
  1551. NotebookEditMode::Delete => Ok(source.unwrap_or_default()),
  1552. NotebookEditMode::Insert | NotebookEditMode::Replace => source
  1553. .ok_or_else(|| String::from("new_source is required for insert and replace edits")),
  1554. }
  1555. }
  1556. fn build_notebook_cell(cell_id: &str, cell_type: NotebookCellType, source: &str) -> Value {
  1557. let mut cell = json!({
  1558. "cell_type": match cell_type {
  1559. NotebookCellType::Code => "code",
  1560. NotebookCellType::Markdown => "markdown",
  1561. },
  1562. "id": cell_id,
  1563. "metadata": {},
  1564. "source": source_lines(source),
  1565. });
  1566. if let Some(object) = cell.as_object_mut() {
  1567. match cell_type {
  1568. NotebookCellType::Code => {
  1569. object.insert(String::from("outputs"), json!([]));
  1570. object.insert(String::from("execution_count"), Value::Null);
  1571. }
  1572. NotebookCellType::Markdown => {}
  1573. }
  1574. }
  1575. cell
  1576. }
  1577. fn cell_kind(cell: &serde_json::Value) -> Option<NotebookCellType> {
  1578. cell.get("cell_type")
  1579. .and_then(serde_json::Value::as_str)
  1580. .map(|kind| {
  1581. if kind == "markdown" {
  1582. NotebookCellType::Markdown
  1583. } else {
  1584. NotebookCellType::Code
  1585. }
  1586. })
  1587. }
  1588. #[allow(clippy::needless_pass_by_value)]
  1589. fn execute_sleep(input: SleepInput) -> SleepOutput {
  1590. std::thread::sleep(Duration::from_millis(input.duration_ms));
  1591. SleepOutput {
  1592. duration_ms: input.duration_ms,
  1593. message: format!("Slept for {}ms", input.duration_ms),
  1594. }
  1595. }
  1596. fn execute_brief(input: BriefInput) -> Result<BriefOutput, String> {
  1597. if input.message.trim().is_empty() {
  1598. return Err(String::from("message must not be empty"));
  1599. }
  1600. let attachments = input
  1601. .attachments
  1602. .as_ref()
  1603. .map(|paths| {
  1604. paths
  1605. .iter()
  1606. .map(|path| resolve_attachment(path))
  1607. .collect::<Result<Vec<_>, String>>()
  1608. })
  1609. .transpose()?;
  1610. let message = match input.status {
  1611. BriefStatus::Normal | BriefStatus::Proactive => input.message,
  1612. };
  1613. Ok(BriefOutput {
  1614. message,
  1615. attachments,
  1616. sent_at: iso8601_timestamp(),
  1617. })
  1618. }
  1619. fn resolve_attachment(path: &str) -> Result<ResolvedAttachment, String> {
  1620. let resolved = std::fs::canonicalize(path).map_err(|error| error.to_string())?;
  1621. let metadata = std::fs::metadata(&resolved).map_err(|error| error.to_string())?;
  1622. Ok(ResolvedAttachment {
  1623. path: resolved.display().to_string(),
  1624. size: metadata.len(),
  1625. is_image: is_image_path(&resolved),
  1626. })
  1627. }
  1628. fn is_image_path(path: &Path) -> bool {
  1629. matches!(
  1630. path.extension()
  1631. .and_then(|ext| ext.to_str())
  1632. .map(str::to_ascii_lowercase)
  1633. .as_deref(),
  1634. Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg")
  1635. )
  1636. }
  1637. fn execute_config(input: ConfigInput) -> Result<ConfigOutput, String> {
  1638. let setting = input.setting.trim();
  1639. if setting.is_empty() {
  1640. return Err(String::from("setting must not be empty"));
  1641. }
  1642. let Some(spec) = supported_config_setting(setting) else {
  1643. return Ok(ConfigOutput {
  1644. success: false,
  1645. operation: None,
  1646. setting: None,
  1647. value: None,
  1648. previous_value: None,
  1649. new_value: None,
  1650. error: Some(format!("Unknown setting: \"{setting}\"")),
  1651. });
  1652. };
  1653. let path = config_file_for_scope(spec.scope)?;
  1654. let mut document = read_json_object(&path)?;
  1655. if let Some(value) = input.value {
  1656. let normalized = normalize_config_value(spec, value)?;
  1657. let previous_value = get_nested_value(&document, spec.path).cloned();
  1658. set_nested_value(&mut document, spec.path, normalized.clone());
  1659. write_json_object(&path, &document)?;
  1660. Ok(ConfigOutput {
  1661. success: true,
  1662. operation: Some(String::from("set")),
  1663. setting: Some(setting.to_string()),
  1664. value: Some(normalized.clone()),
  1665. previous_value,
  1666. new_value: Some(normalized),
  1667. error: None,
  1668. })
  1669. } else {
  1670. Ok(ConfigOutput {
  1671. success: true,
  1672. operation: Some(String::from("get")),
  1673. setting: Some(setting.to_string()),
  1674. value: get_nested_value(&document, spec.path).cloned(),
  1675. previous_value: None,
  1676. new_value: None,
  1677. error: None,
  1678. })
  1679. }
  1680. }
  1681. fn execute_structured_output(input: StructuredOutputInput) -> StructuredOutputResult {
  1682. StructuredOutputResult {
  1683. data: String::from("Structured output provided successfully"),
  1684. structured_output: input.0,
  1685. }
  1686. }
  1687. fn execute_repl(input: ReplInput) -> Result<ReplOutput, String> {
  1688. if input.code.trim().is_empty() {
  1689. return Err(String::from("code must not be empty"));
  1690. }
  1691. let _ = input.timeout_ms;
  1692. let runtime = resolve_repl_runtime(&input.language)?;
  1693. let started = Instant::now();
  1694. let output = Command::new(runtime.program)
  1695. .args(runtime.args)
  1696. .arg(&input.code)
  1697. .output()
  1698. .map_err(|error| error.to_string())?;
  1699. Ok(ReplOutput {
  1700. language: input.language,
  1701. stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
  1702. stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
  1703. exit_code: output.status.code().unwrap_or(1),
  1704. duration_ms: started.elapsed().as_millis(),
  1705. })
  1706. }
  1707. struct ReplRuntime {
  1708. program: &'static str,
  1709. args: &'static [&'static str],
  1710. }
  1711. fn resolve_repl_runtime(language: &str) -> Result<ReplRuntime, String> {
  1712. match language.trim().to_ascii_lowercase().as_str() {
  1713. "python" | "py" => Ok(ReplRuntime {
  1714. program: detect_first_command(&["python3", "python"])
  1715. .ok_or_else(|| String::from("python runtime not found"))?,
  1716. args: &["-c"],
  1717. }),
  1718. "javascript" | "js" | "node" => Ok(ReplRuntime {
  1719. program: detect_first_command(&["node"])
  1720. .ok_or_else(|| String::from("node runtime not found"))?,
  1721. args: &["-e"],
  1722. }),
  1723. "sh" | "shell" | "bash" => Ok(ReplRuntime {
  1724. program: detect_first_command(&["bash", "sh"])
  1725. .ok_or_else(|| String::from("shell runtime not found"))?,
  1726. args: &["-lc"],
  1727. }),
  1728. other => Err(format!("unsupported REPL language: {other}")),
  1729. }
  1730. }
  1731. fn detect_first_command(commands: &[&'static str]) -> Option<&'static str> {
  1732. commands
  1733. .iter()
  1734. .copied()
  1735. .find(|command| command_exists(command))
  1736. }
  1737. #[derive(Clone, Copy)]
  1738. enum ConfigScope {
  1739. Global,
  1740. Settings,
  1741. }
  1742. #[derive(Clone, Copy)]
  1743. struct ConfigSettingSpec {
  1744. scope: ConfigScope,
  1745. kind: ConfigKind,
  1746. path: &'static [&'static str],
  1747. options: Option<&'static [&'static str]>,
  1748. }
  1749. #[derive(Clone, Copy)]
  1750. enum ConfigKind {
  1751. Boolean,
  1752. String,
  1753. }
  1754. fn supported_config_setting(setting: &str) -> Option<ConfigSettingSpec> {
  1755. Some(match setting {
  1756. "theme" => ConfigSettingSpec {
  1757. scope: ConfigScope::Global,
  1758. kind: ConfigKind::String,
  1759. path: &["theme"],
  1760. options: None,
  1761. },
  1762. "editorMode" => ConfigSettingSpec {
  1763. scope: ConfigScope::Global,
  1764. kind: ConfigKind::String,
  1765. path: &["editorMode"],
  1766. options: Some(&["default", "vim", "emacs"]),
  1767. },
  1768. "verbose" => ConfigSettingSpec {
  1769. scope: ConfigScope::Global,
  1770. kind: ConfigKind::Boolean,
  1771. path: &["verbose"],
  1772. options: None,
  1773. },
  1774. "preferredNotifChannel" => ConfigSettingSpec {
  1775. scope: ConfigScope::Global,
  1776. kind: ConfigKind::String,
  1777. path: &["preferredNotifChannel"],
  1778. options: None,
  1779. },
  1780. "autoCompactEnabled" => ConfigSettingSpec {
  1781. scope: ConfigScope::Global,
  1782. kind: ConfigKind::Boolean,
  1783. path: &["autoCompactEnabled"],
  1784. options: None,
  1785. },
  1786. "autoMemoryEnabled" => ConfigSettingSpec {
  1787. scope: ConfigScope::Settings,
  1788. kind: ConfigKind::Boolean,
  1789. path: &["autoMemoryEnabled"],
  1790. options: None,
  1791. },
  1792. "autoDreamEnabled" => ConfigSettingSpec {
  1793. scope: ConfigScope::Settings,
  1794. kind: ConfigKind::Boolean,
  1795. path: &["autoDreamEnabled"],
  1796. options: None,
  1797. },
  1798. "fileCheckpointingEnabled" => ConfigSettingSpec {
  1799. scope: ConfigScope::Global,
  1800. kind: ConfigKind::Boolean,
  1801. path: &["fileCheckpointingEnabled"],
  1802. options: None,
  1803. },
  1804. "showTurnDuration" => ConfigSettingSpec {
  1805. scope: ConfigScope::Global,
  1806. kind: ConfigKind::Boolean,
  1807. path: &["showTurnDuration"],
  1808. options: None,
  1809. },
  1810. "terminalProgressBarEnabled" => ConfigSettingSpec {
  1811. scope: ConfigScope::Global,
  1812. kind: ConfigKind::Boolean,
  1813. path: &["terminalProgressBarEnabled"],
  1814. options: None,
  1815. },
  1816. "todoFeatureEnabled" => ConfigSettingSpec {
  1817. scope: ConfigScope::Global,
  1818. kind: ConfigKind::Boolean,
  1819. path: &["todoFeatureEnabled"],
  1820. options: None,
  1821. },
  1822. "model" => ConfigSettingSpec {
  1823. scope: ConfigScope::Settings,
  1824. kind: ConfigKind::String,
  1825. path: &["model"],
  1826. options: None,
  1827. },
  1828. "alwaysThinkingEnabled" => ConfigSettingSpec {
  1829. scope: ConfigScope::Settings,
  1830. kind: ConfigKind::Boolean,
  1831. path: &["alwaysThinkingEnabled"],
  1832. options: None,
  1833. },
  1834. "permissions.defaultMode" => ConfigSettingSpec {
  1835. scope: ConfigScope::Settings,
  1836. kind: ConfigKind::String,
  1837. path: &["permissions", "defaultMode"],
  1838. options: Some(&["default", "plan", "acceptEdits", "dontAsk", "auto"]),
  1839. },
  1840. "language" => ConfigSettingSpec {
  1841. scope: ConfigScope::Settings,
  1842. kind: ConfigKind::String,
  1843. path: &["language"],
  1844. options: None,
  1845. },
  1846. "teammateMode" => ConfigSettingSpec {
  1847. scope: ConfigScope::Global,
  1848. kind: ConfigKind::String,
  1849. path: &["teammateMode"],
  1850. options: Some(&["tmux", "in-process", "auto"]),
  1851. },
  1852. _ => return None,
  1853. })
  1854. }
  1855. fn normalize_config_value(spec: ConfigSettingSpec, value: ConfigValue) -> Result<Value, String> {
  1856. let normalized = match (spec.kind, value) {
  1857. (ConfigKind::Boolean, ConfigValue::Bool(value)) => Value::Bool(value),
  1858. (ConfigKind::Boolean, ConfigValue::String(value)) => {
  1859. match value.trim().to_ascii_lowercase().as_str() {
  1860. "true" => Value::Bool(true),
  1861. "false" => Value::Bool(false),
  1862. _ => return Err(String::from("setting requires true or false")),
  1863. }
  1864. }
  1865. (ConfigKind::Boolean, ConfigValue::Number(_)) => {
  1866. return Err(String::from("setting requires true or false"))
  1867. }
  1868. (ConfigKind::String, ConfigValue::String(value)) => Value::String(value),
  1869. (ConfigKind::String, ConfigValue::Bool(value)) => Value::String(value.to_string()),
  1870. (ConfigKind::String, ConfigValue::Number(value)) => json!(value),
  1871. };
  1872. if let Some(options) = spec.options {
  1873. let Some(as_str) = normalized.as_str() else {
  1874. return Err(String::from("setting requires a string value"));
  1875. };
  1876. if !options.iter().any(|option| option == &as_str) {
  1877. return Err(format!(
  1878. "Invalid value \"{as_str}\". Options: {}",
  1879. options.join(", ")
  1880. ));
  1881. }
  1882. }
  1883. Ok(normalized)
  1884. }
  1885. fn config_file_for_scope(scope: ConfigScope) -> Result<PathBuf, String> {
  1886. let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
  1887. Ok(match scope {
  1888. ConfigScope::Global => config_home_dir()?.join("settings.json"),
  1889. ConfigScope::Settings => cwd.join(".claude").join("settings.local.json"),
  1890. })
  1891. }
  1892. fn config_home_dir() -> Result<PathBuf, String> {
  1893. if let Ok(path) = std::env::var("CLAUDE_CONFIG_HOME") {
  1894. return Ok(PathBuf::from(path));
  1895. }
  1896. let home = std::env::var("HOME").map_err(|_| String::from("HOME is not set"))?;
  1897. Ok(PathBuf::from(home).join(".claude"))
  1898. }
  1899. fn read_json_object(path: &Path) -> Result<serde_json::Map<String, Value>, String> {
  1900. match std::fs::read_to_string(path) {
  1901. Ok(contents) => {
  1902. if contents.trim().is_empty() {
  1903. return Ok(serde_json::Map::new());
  1904. }
  1905. serde_json::from_str::<Value>(&contents)
  1906. .map_err(|error| error.to_string())?
  1907. .as_object()
  1908. .cloned()
  1909. .ok_or_else(|| String::from("config file must contain a JSON object"))
  1910. }
  1911. Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(serde_json::Map::new()),
  1912. Err(error) => Err(error.to_string()),
  1913. }
  1914. }
  1915. fn write_json_object(path: &Path, value: &serde_json::Map<String, Value>) -> Result<(), String> {
  1916. if let Some(parent) = path.parent() {
  1917. std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
  1918. }
  1919. std::fs::write(
  1920. path,
  1921. serde_json::to_string_pretty(value).map_err(|error| error.to_string())?,
  1922. )
  1923. .map_err(|error| error.to_string())
  1924. }
  1925. fn get_nested_value<'a>(
  1926. value: &'a serde_json::Map<String, Value>,
  1927. path: &[&str],
  1928. ) -> Option<&'a Value> {
  1929. let (first, rest) = path.split_first()?;
  1930. let mut current = value.get(*first)?;
  1931. for key in rest {
  1932. current = current.as_object()?.get(*key)?;
  1933. }
  1934. Some(current)
  1935. }
  1936. fn set_nested_value(root: &mut serde_json::Map<String, Value>, path: &[&str], new_value: Value) {
  1937. let (first, rest) = path.split_first().expect("config path must not be empty");
  1938. if rest.is_empty() {
  1939. root.insert((*first).to_string(), new_value);
  1940. return;
  1941. }
  1942. let entry = root
  1943. .entry((*first).to_string())
  1944. .or_insert_with(|| Value::Object(serde_json::Map::new()));
  1945. if !entry.is_object() {
  1946. *entry = Value::Object(serde_json::Map::new());
  1947. }
  1948. let map = entry.as_object_mut().expect("object inserted");
  1949. set_nested_value(map, rest, new_value);
  1950. }
  1951. fn iso8601_timestamp() -> String {
  1952. if let Ok(output) = Command::new("date")
  1953. .args(["-u", "+%Y-%m-%dT%H:%M:%SZ"])
  1954. .output()
  1955. {
  1956. if output.status.success() {
  1957. return String::from_utf8_lossy(&output.stdout).trim().to_string();
  1958. }
  1959. }
  1960. iso8601_now()
  1961. }
  1962. #[allow(clippy::needless_pass_by_value)]
  1963. fn execute_powershell(input: PowerShellInput) -> std::io::Result<runtime::BashCommandOutput> {
  1964. let _ = &input.description;
  1965. let shell = detect_powershell_shell()?;
  1966. execute_shell_command(
  1967. shell,
  1968. &input.command,
  1969. input.timeout,
  1970. input.run_in_background,
  1971. )
  1972. }
  1973. fn detect_powershell_shell() -> std::io::Result<&'static str> {
  1974. if command_exists("pwsh") {
  1975. Ok("pwsh")
  1976. } else if command_exists("powershell") {
  1977. Ok("powershell")
  1978. } else {
  1979. Err(std::io::Error::new(
  1980. std::io::ErrorKind::NotFound,
  1981. "PowerShell executable not found (expected `pwsh` or `powershell` in PATH)",
  1982. ))
  1983. }
  1984. }
  1985. fn command_exists(command: &str) -> bool {
  1986. std::process::Command::new("sh")
  1987. .arg("-lc")
  1988. .arg(format!("command -v {command} >/dev/null 2>&1"))
  1989. .status()
  1990. .map(|status| status.success())
  1991. .unwrap_or(false)
  1992. }
  1993. #[allow(clippy::too_many_lines)]
  1994. fn execute_shell_command(
  1995. shell: &str,
  1996. command: &str,
  1997. timeout: Option<u64>,
  1998. run_in_background: Option<bool>,
  1999. ) -> std::io::Result<runtime::BashCommandOutput> {
  2000. if run_in_background.unwrap_or(false) {
  2001. let child = std::process::Command::new(shell)
  2002. .arg("-NoProfile")
  2003. .arg("-NonInteractive")
  2004. .arg("-Command")
  2005. .arg(command)
  2006. .stdin(std::process::Stdio::null())
  2007. .stdout(std::process::Stdio::null())
  2008. .stderr(std::process::Stdio::null())
  2009. .spawn()?;
  2010. return Ok(runtime::BashCommandOutput {
  2011. stdout: String::new(),
  2012. stderr: String::new(),
  2013. raw_output_path: None,
  2014. interrupted: false,
  2015. is_image: None,
  2016. background_task_id: Some(child.id().to_string()),
  2017. backgrounded_by_user: Some(true),
  2018. assistant_auto_backgrounded: Some(false),
  2019. dangerously_disable_sandbox: None,
  2020. return_code_interpretation: None,
  2021. no_output_expected: Some(true),
  2022. structured_content: None,
  2023. persisted_output_path: None,
  2024. persisted_output_size: None,
  2025. });
  2026. }
  2027. let mut process = std::process::Command::new(shell);
  2028. process
  2029. .arg("-NoProfile")
  2030. .arg("-NonInteractive")
  2031. .arg("-Command")
  2032. .arg(command);
  2033. process
  2034. .stdout(std::process::Stdio::piped())
  2035. .stderr(std::process::Stdio::piped());
  2036. if let Some(timeout_ms) = timeout {
  2037. let mut child = process.spawn()?;
  2038. let started = Instant::now();
  2039. loop {
  2040. if let Some(status) = child.try_wait()? {
  2041. let output = child.wait_with_output()?;
  2042. return Ok(runtime::BashCommandOutput {
  2043. stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
  2044. stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
  2045. raw_output_path: None,
  2046. interrupted: false,
  2047. is_image: None,
  2048. background_task_id: None,
  2049. backgrounded_by_user: None,
  2050. assistant_auto_backgrounded: None,
  2051. dangerously_disable_sandbox: None,
  2052. return_code_interpretation: status
  2053. .code()
  2054. .filter(|code| *code != 0)
  2055. .map(|code| format!("exit_code:{code}")),
  2056. no_output_expected: Some(output.stdout.is_empty() && output.stderr.is_empty()),
  2057. structured_content: None,
  2058. persisted_output_path: None,
  2059. persisted_output_size: None,
  2060. });
  2061. }
  2062. if started.elapsed() >= Duration::from_millis(timeout_ms) {
  2063. let _ = child.kill();
  2064. let output = child.wait_with_output()?;
  2065. let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
  2066. let stderr = if stderr.trim().is_empty() {
  2067. format!("Command exceeded timeout of {timeout_ms} ms")
  2068. } else {
  2069. format!(
  2070. "{}
  2071. Command exceeded timeout of {timeout_ms} ms",
  2072. stderr.trim_end()
  2073. )
  2074. };
  2075. return Ok(runtime::BashCommandOutput {
  2076. stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
  2077. stderr,
  2078. raw_output_path: None,
  2079. interrupted: true,
  2080. is_image: None,
  2081. background_task_id: None,
  2082. backgrounded_by_user: None,
  2083. assistant_auto_backgrounded: None,
  2084. dangerously_disable_sandbox: None,
  2085. return_code_interpretation: Some(String::from("timeout")),
  2086. no_output_expected: Some(false),
  2087. structured_content: None,
  2088. persisted_output_path: None,
  2089. persisted_output_size: None,
  2090. });
  2091. }
  2092. std::thread::sleep(Duration::from_millis(10));
  2093. }
  2094. }
  2095. let output = process.output()?;
  2096. Ok(runtime::BashCommandOutput {
  2097. stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
  2098. stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
  2099. raw_output_path: None,
  2100. interrupted: false,
  2101. is_image: None,
  2102. background_task_id: None,
  2103. backgrounded_by_user: None,
  2104. assistant_auto_backgrounded: None,
  2105. dangerously_disable_sandbox: None,
  2106. return_code_interpretation: output
  2107. .status
  2108. .code()
  2109. .filter(|code| *code != 0)
  2110. .map(|code| format!("exit_code:{code}")),
  2111. no_output_expected: Some(output.stdout.is_empty() && output.stderr.is_empty()),
  2112. structured_content: None,
  2113. persisted_output_path: None,
  2114. persisted_output_size: None,
  2115. })
  2116. }
  2117. fn resolve_cell_index(
  2118. cells: &[serde_json::Value],
  2119. cell_id: Option<&str>,
  2120. edit_mode: NotebookEditMode,
  2121. ) -> Result<usize, String> {
  2122. if cells.is_empty()
  2123. && matches!(
  2124. edit_mode,
  2125. NotebookEditMode::Replace | NotebookEditMode::Delete
  2126. )
  2127. {
  2128. return Err(String::from("Notebook has no cells to edit"));
  2129. }
  2130. if let Some(cell_id) = cell_id {
  2131. cells
  2132. .iter()
  2133. .position(|cell| cell.get("id").and_then(serde_json::Value::as_str) == Some(cell_id))
  2134. .ok_or_else(|| format!("Cell id not found: {cell_id}"))
  2135. } else {
  2136. Ok(cells.len().saturating_sub(1))
  2137. }
  2138. }
  2139. fn source_lines(source: &str) -> Vec<serde_json::Value> {
  2140. if source.is_empty() {
  2141. return vec![serde_json::Value::String(String::new())];
  2142. }
  2143. source
  2144. .split_inclusive('\n')
  2145. .map(|line| serde_json::Value::String(line.to_string()))
  2146. .collect()
  2147. }
  2148. fn format_notebook_edit_mode(mode: NotebookEditMode) -> String {
  2149. match mode {
  2150. NotebookEditMode::Replace => String::from("replace"),
  2151. NotebookEditMode::Insert => String::from("insert"),
  2152. NotebookEditMode::Delete => String::from("delete"),
  2153. }
  2154. }
  2155. fn make_cell_id(index: usize) -> String {
  2156. format!("cell-{}", index + 1)
  2157. }
  2158. fn parse_skill_description(contents: &str) -> Option<String> {
  2159. for line in contents.lines() {
  2160. if let Some(value) = line.strip_prefix("description:") {
  2161. let trimmed = value.trim();
  2162. if !trimmed.is_empty() {
  2163. return Some(trimmed.to_string());
  2164. }
  2165. }
  2166. }
  2167. None
  2168. }
  2169. #[cfg(test)]
  2170. mod tests {
  2171. use std::fs;
  2172. use std::io::{Read, Write};
  2173. use std::net::{SocketAddr, TcpListener};
  2174. use std::path::PathBuf;
  2175. use std::sync::{Arc, Mutex, OnceLock};
  2176. use std::thread;
  2177. use std::time::Duration;
  2178. use super::{execute_tool, mvp_tool_specs};
  2179. use serde_json::json;
  2180. fn env_lock() -> &'static Mutex<()> {
  2181. static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
  2182. LOCK.get_or_init(|| Mutex::new(()))
  2183. }
  2184. fn temp_path(name: &str) -> PathBuf {
  2185. let unique = std::time::SystemTime::now()
  2186. .duration_since(std::time::UNIX_EPOCH)
  2187. .expect("time")
  2188. .as_nanos();
  2189. std::env::temp_dir().join(format!("clawd-tools-{unique}-{name}"))
  2190. }
  2191. #[test]
  2192. fn exposes_mvp_tools() {
  2193. let names = mvp_tool_specs()
  2194. .into_iter()
  2195. .map(|spec| spec.name)
  2196. .collect::<Vec<_>>();
  2197. assert!(names.contains(&"bash"));
  2198. assert!(names.contains(&"read_file"));
  2199. assert!(names.contains(&"WebFetch"));
  2200. assert!(names.contains(&"WebSearch"));
  2201. assert!(names.contains(&"TodoWrite"));
  2202. assert!(names.contains(&"Skill"));
  2203. assert!(names.contains(&"Agent"));
  2204. assert!(names.contains(&"ToolSearch"));
  2205. assert!(names.contains(&"NotebookEdit"));
  2206. assert!(names.contains(&"Sleep"));
  2207. assert!(names.contains(&"SendUserMessage"));
  2208. assert!(names.contains(&"Config"));
  2209. assert!(names.contains(&"StructuredOutput"));
  2210. assert!(names.contains(&"REPL"));
  2211. assert!(names.contains(&"PowerShell"));
  2212. }
  2213. #[test]
  2214. fn rejects_unknown_tool_names() {
  2215. let error = execute_tool("nope", &json!({})).expect_err("tool should be rejected");
  2216. assert!(error.contains("unsupported tool"));
  2217. }
  2218. #[test]
  2219. fn web_fetch_returns_prompt_aware_summary() {
  2220. let server = TestServer::spawn(Arc::new(|request_line: &str| {
  2221. assert!(request_line.starts_with("GET /page "));
  2222. HttpResponse::html(
  2223. 200,
  2224. "OK",
  2225. "<html><head><title>Ignored</title></head><body><h1>Test Page</h1><p>Hello <b>world</b> from local server.</p></body></html>",
  2226. )
  2227. }));
  2228. let result = execute_tool(
  2229. "WebFetch",
  2230. &json!({
  2231. "url": format!("http://{}/page", server.addr()),
  2232. "prompt": "Summarize this page"
  2233. }),
  2234. )
  2235. .expect("WebFetch should succeed");
  2236. let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
  2237. assert_eq!(output["code"], 200);
  2238. let summary = output["result"].as_str().expect("result string");
  2239. assert!(summary.contains("Fetched"));
  2240. assert!(summary.contains("Test Page"));
  2241. assert!(summary.contains("Hello world from local server"));
  2242. let titled = execute_tool(
  2243. "WebFetch",
  2244. &json!({
  2245. "url": format!("http://{}/page", server.addr()),
  2246. "prompt": "What is the page title?"
  2247. }),
  2248. )
  2249. .expect("WebFetch title query should succeed");
  2250. let titled_output: serde_json::Value = serde_json::from_str(&titled).expect("valid json");
  2251. let titled_summary = titled_output["result"].as_str().expect("result string");
  2252. assert!(titled_summary.contains("Title: Ignored"));
  2253. }
  2254. #[test]
  2255. fn web_fetch_supports_plain_text_and_rejects_invalid_url() {
  2256. let server = TestServer::spawn(Arc::new(|request_line: &str| {
  2257. assert!(request_line.starts_with("GET /plain "));
  2258. HttpResponse::text(200, "OK", "plain text response")
  2259. }));
  2260. let result = execute_tool(
  2261. "WebFetch",
  2262. &json!({
  2263. "url": format!("http://{}/plain", server.addr()),
  2264. "prompt": "Show me the content"
  2265. }),
  2266. )
  2267. .expect("WebFetch should succeed for text content");
  2268. let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
  2269. assert_eq!(output["url"], format!("http://{}/plain", server.addr()));
  2270. assert!(output["result"]
  2271. .as_str()
  2272. .expect("result")
  2273. .contains("plain text response"));
  2274. let error = execute_tool(
  2275. "WebFetch",
  2276. &json!({
  2277. "url": "not a url",
  2278. "prompt": "Summarize"
  2279. }),
  2280. )
  2281. .expect_err("invalid URL should fail");
  2282. assert!(error.contains("relative URL without a base") || error.contains("invalid"));
  2283. }
  2284. #[test]
  2285. fn web_search_extracts_and_filters_results() {
  2286. let server = TestServer::spawn(Arc::new(|request_line: &str| {
  2287. assert!(request_line.contains("GET /search?q=rust+web+search "));
  2288. HttpResponse::html(
  2289. 200,
  2290. "OK",
  2291. r#"
  2292. <html><body>
  2293. <a class="result__a" href="https://docs.rs/reqwest">Reqwest docs</a>
  2294. <a class="result__a" href="https://example.com/blocked">Blocked result</a>
  2295. </body></html>
  2296. "#,
  2297. )
  2298. }));
  2299. std::env::set_var(
  2300. "CLAWD_WEB_SEARCH_BASE_URL",
  2301. format!("http://{}/search", server.addr()),
  2302. );
  2303. let result = execute_tool(
  2304. "WebSearch",
  2305. &json!({
  2306. "query": "rust web search",
  2307. "allowed_domains": ["https://DOCS.rs/"],
  2308. "blocked_domains": ["HTTPS://EXAMPLE.COM"]
  2309. }),
  2310. )
  2311. .expect("WebSearch should succeed");
  2312. std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
  2313. let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
  2314. assert_eq!(output["query"], "rust web search");
  2315. let results = output["results"].as_array().expect("results array");
  2316. let search_result = results
  2317. .iter()
  2318. .find(|item| item.get("content").is_some())
  2319. .expect("search result block present");
  2320. let content = search_result["content"].as_array().expect("content array");
  2321. assert_eq!(content.len(), 1);
  2322. assert_eq!(content[0]["title"], "Reqwest docs");
  2323. assert_eq!(content[0]["url"], "https://docs.rs/reqwest");
  2324. }
  2325. #[test]
  2326. fn web_search_handles_generic_links_and_invalid_base_url() {
  2327. let _guard = env_lock()
  2328. .lock()
  2329. .unwrap_or_else(std::sync::PoisonError::into_inner);
  2330. let server = TestServer::spawn(Arc::new(|request_line: &str| {
  2331. assert!(request_line.contains("GET /fallback?q=generic+links "));
  2332. HttpResponse::html(
  2333. 200,
  2334. "OK",
  2335. r#"
  2336. <html><body>
  2337. <a href="https://example.com/one">Example One</a>
  2338. <a href="https://example.com/one">Duplicate Example One</a>
  2339. <a href="https://docs.rs/tokio">Tokio Docs</a>
  2340. </body></html>
  2341. "#,
  2342. )
  2343. }));
  2344. std::env::set_var(
  2345. "CLAWD_WEB_SEARCH_BASE_URL",
  2346. format!("http://{}/fallback", server.addr()),
  2347. );
  2348. let result = execute_tool(
  2349. "WebSearch",
  2350. &json!({
  2351. "query": "generic links"
  2352. }),
  2353. )
  2354. .expect("WebSearch fallback parsing should succeed");
  2355. std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
  2356. let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
  2357. let results = output["results"].as_array().expect("results array");
  2358. let search_result = results
  2359. .iter()
  2360. .find(|item| item.get("content").is_some())
  2361. .expect("search result block present");
  2362. let content = search_result["content"].as_array().expect("content array");
  2363. assert_eq!(content.len(), 2);
  2364. assert_eq!(content[0]["url"], "https://example.com/one");
  2365. assert_eq!(content[1]["url"], "https://docs.rs/tokio");
  2366. std::env::set_var("CLAWD_WEB_SEARCH_BASE_URL", "://bad-base-url");
  2367. let error = execute_tool("WebSearch", &json!({ "query": "generic links" }))
  2368. .expect_err("invalid base URL should fail");
  2369. std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
  2370. assert!(error.contains("relative URL without a base") || error.contains("empty host"));
  2371. }
  2372. #[test]
  2373. fn todo_write_persists_and_returns_previous_state() {
  2374. let _guard = env_lock()
  2375. .lock()
  2376. .unwrap_or_else(std::sync::PoisonError::into_inner);
  2377. let path = temp_path("todos.json");
  2378. std::env::set_var("CLAWD_TODO_STORE", &path);
  2379. let first = execute_tool(
  2380. "TodoWrite",
  2381. &json!({
  2382. "todos": [
  2383. {"content": "Add tool", "activeForm": "Adding tool", "status": "in_progress"},
  2384. {"content": "Run tests", "activeForm": "Running tests", "status": "pending"}
  2385. ]
  2386. }),
  2387. )
  2388. .expect("TodoWrite should succeed");
  2389. let first_output: serde_json::Value = serde_json::from_str(&first).expect("valid json");
  2390. assert_eq!(first_output["oldTodos"].as_array().expect("array").len(), 0);
  2391. let second = execute_tool(
  2392. "TodoWrite",
  2393. &json!({
  2394. "todos": [
  2395. {"content": "Add tool", "activeForm": "Adding tool", "status": "completed"},
  2396. {"content": "Run tests", "activeForm": "Running tests", "status": "completed"},
  2397. {"content": "Verify", "activeForm": "Verifying", "status": "completed"}
  2398. ]
  2399. }),
  2400. )
  2401. .expect("TodoWrite should succeed");
  2402. std::env::remove_var("CLAWD_TODO_STORE");
  2403. let _ = std::fs::remove_file(path);
  2404. let second_output: serde_json::Value = serde_json::from_str(&second).expect("valid json");
  2405. assert_eq!(
  2406. second_output["oldTodos"].as_array().expect("array").len(),
  2407. 2
  2408. );
  2409. assert_eq!(
  2410. second_output["newTodos"].as_array().expect("array").len(),
  2411. 3
  2412. );
  2413. assert!(second_output["verificationNudgeNeeded"].is_null());
  2414. }
  2415. #[test]
  2416. fn todo_write_rejects_invalid_payloads_and_sets_verification_nudge() {
  2417. let _guard = env_lock()
  2418. .lock()
  2419. .unwrap_or_else(std::sync::PoisonError::into_inner);
  2420. let path = temp_path("todos-errors.json");
  2421. std::env::set_var("CLAWD_TODO_STORE", &path);
  2422. let empty = execute_tool("TodoWrite", &json!({ "todos": [] }))
  2423. .expect_err("empty todos should fail");
  2424. assert!(empty.contains("todos must not be empty"));
  2425. let too_many_active = execute_tool(
  2426. "TodoWrite",
  2427. &json!({
  2428. "todos": [
  2429. {"content": "One", "activeForm": "Doing one", "status": "in_progress"},
  2430. {"content": "Two", "activeForm": "Doing two", "status": "in_progress"}
  2431. ]
  2432. }),
  2433. )
  2434. .expect_err("multiple in-progress todos should fail");
  2435. assert!(too_many_active.contains("zero or one todo items may be in_progress"));
  2436. let blank_content = execute_tool(
  2437. "TodoWrite",
  2438. &json!({
  2439. "todos": [
  2440. {"content": " ", "activeForm": "Doing it", "status": "pending"}
  2441. ]
  2442. }),
  2443. )
  2444. .expect_err("blank content should fail");
  2445. assert!(blank_content.contains("todo content must not be empty"));
  2446. let nudge = execute_tool(
  2447. "TodoWrite",
  2448. &json!({
  2449. "todos": [
  2450. {"content": "Write tests", "activeForm": "Writing tests", "status": "completed"},
  2451. {"content": "Fix errors", "activeForm": "Fixing errors", "status": "completed"},
  2452. {"content": "Ship branch", "activeForm": "Shipping branch", "status": "completed"}
  2453. ]
  2454. }),
  2455. )
  2456. .expect("completed todos should succeed");
  2457. std::env::remove_var("CLAWD_TODO_STORE");
  2458. let _ = fs::remove_file(path);
  2459. let output: serde_json::Value = serde_json::from_str(&nudge).expect("valid json");
  2460. assert_eq!(output["verificationNudgeNeeded"], true);
  2461. }
  2462. #[test]
  2463. fn skill_loads_local_skill_prompt() {
  2464. let result = execute_tool(
  2465. "Skill",
  2466. &json!({
  2467. "skill": "help",
  2468. "args": "overview"
  2469. }),
  2470. )
  2471. .expect("Skill should succeed");
  2472. let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
  2473. assert_eq!(output["skill"], "help");
  2474. assert!(output["path"]
  2475. .as_str()
  2476. .expect("path")
  2477. .ends_with("/help/SKILL.md"));
  2478. assert!(output["prompt"]
  2479. .as_str()
  2480. .expect("prompt")
  2481. .contains("Guide on using oh-my-codex plugin"));
  2482. let dollar_result = execute_tool(
  2483. "Skill",
  2484. &json!({
  2485. "skill": "$help"
  2486. }),
  2487. )
  2488. .expect("Skill should accept $skill invocation form");
  2489. let dollar_output: serde_json::Value =
  2490. serde_json::from_str(&dollar_result).expect("valid json");
  2491. assert_eq!(dollar_output["skill"], "$help");
  2492. assert!(dollar_output["path"]
  2493. .as_str()
  2494. .expect("path")
  2495. .ends_with("/help/SKILL.md"));
  2496. }
  2497. #[test]
  2498. fn tool_search_supports_keyword_and_select_queries() {
  2499. let keyword = execute_tool(
  2500. "ToolSearch",
  2501. &json!({"query": "web current", "max_results": 3}),
  2502. )
  2503. .expect("ToolSearch should succeed");
  2504. let keyword_output: serde_json::Value = serde_json::from_str(&keyword).expect("valid json");
  2505. let matches = keyword_output["matches"].as_array().expect("matches");
  2506. assert!(matches.iter().any(|value| value == "WebSearch"));
  2507. let selected = execute_tool("ToolSearch", &json!({"query": "select:Agent,Skill"}))
  2508. .expect("ToolSearch should succeed");
  2509. let selected_output: serde_json::Value =
  2510. serde_json::from_str(&selected).expect("valid json");
  2511. assert_eq!(selected_output["matches"][0], "Agent");
  2512. assert_eq!(selected_output["matches"][1], "Skill");
  2513. let aliased = execute_tool("ToolSearch", &json!({"query": "AgentTool"}))
  2514. .expect("ToolSearch should support tool aliases");
  2515. let aliased_output: serde_json::Value = serde_json::from_str(&aliased).expect("valid json");
  2516. assert_eq!(aliased_output["matches"][0], "Agent");
  2517. assert_eq!(aliased_output["normalized_query"], "agent");
  2518. let selected_with_alias =
  2519. execute_tool("ToolSearch", &json!({"query": "select:AgentTool,Skill"}))
  2520. .expect("ToolSearch alias select should succeed");
  2521. let selected_with_alias_output: serde_json::Value =
  2522. serde_json::from_str(&selected_with_alias).expect("valid json");
  2523. assert_eq!(selected_with_alias_output["matches"][0], "Agent");
  2524. assert_eq!(selected_with_alias_output["matches"][1], "Skill");
  2525. }
  2526. #[test]
  2527. fn agent_persists_handoff_metadata() {
  2528. let _guard = env_lock()
  2529. .lock()
  2530. .unwrap_or_else(std::sync::PoisonError::into_inner);
  2531. let dir = temp_path("agent-store");
  2532. std::env::set_var("CLAWD_AGENT_STORE", &dir);
  2533. let result = execute_tool(
  2534. "Agent",
  2535. &json!({
  2536. "description": "Audit the branch",
  2537. "prompt": "Check tests and outstanding work.",
  2538. "subagent_type": "Explore",
  2539. "name": "ship-audit"
  2540. }),
  2541. )
  2542. .expect("Agent should succeed");
  2543. std::env::remove_var("CLAWD_AGENT_STORE");
  2544. let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
  2545. assert_eq!(output["name"], "ship-audit");
  2546. assert_eq!(output["subagentType"], "Explore");
  2547. assert_eq!(output["status"], "queued");
  2548. assert!(output["createdAt"].as_str().is_some());
  2549. let manifest_file = output["manifestFile"].as_str().expect("manifest file");
  2550. let output_file = output["outputFile"].as_str().expect("output file");
  2551. let contents = std::fs::read_to_string(output_file).expect("agent file exists");
  2552. let manifest_contents =
  2553. std::fs::read_to_string(manifest_file).expect("manifest file exists");
  2554. assert!(contents.contains("Audit the branch"));
  2555. assert!(contents.contains("Check tests and outstanding work."));
  2556. assert!(manifest_contents.contains("\"subagentType\": \"Explore\""));
  2557. let normalized = execute_tool(
  2558. "Agent",
  2559. &json!({
  2560. "description": "Verify the branch",
  2561. "prompt": "Check tests.",
  2562. "subagent_type": "explorer"
  2563. }),
  2564. )
  2565. .expect("Agent should normalize built-in aliases");
  2566. let normalized_output: serde_json::Value =
  2567. serde_json::from_str(&normalized).expect("valid json");
  2568. assert_eq!(normalized_output["subagentType"], "Explore");
  2569. let named = execute_tool(
  2570. "Agent",
  2571. &json!({
  2572. "description": "Review the branch",
  2573. "prompt": "Inspect diff.",
  2574. "name": "Ship Audit!!!"
  2575. }),
  2576. )
  2577. .expect("Agent should normalize explicit names");
  2578. let named_output: serde_json::Value = serde_json::from_str(&named).expect("valid json");
  2579. assert_eq!(named_output["name"], "ship-audit");
  2580. let _ = std::fs::remove_dir_all(dir);
  2581. }
  2582. #[test]
  2583. fn agent_rejects_blank_required_fields() {
  2584. let missing_description = execute_tool(
  2585. "Agent",
  2586. &json!({
  2587. "description": " ",
  2588. "prompt": "Inspect"
  2589. }),
  2590. )
  2591. .expect_err("blank description should fail");
  2592. assert!(missing_description.contains("description must not be empty"));
  2593. let missing_prompt = execute_tool(
  2594. "Agent",
  2595. &json!({
  2596. "description": "Inspect branch",
  2597. "prompt": " "
  2598. }),
  2599. )
  2600. .expect_err("blank prompt should fail");
  2601. assert!(missing_prompt.contains("prompt must not be empty"));
  2602. }
  2603. #[test]
  2604. fn notebook_edit_replaces_inserts_and_deletes_cells() {
  2605. let path = temp_path("notebook.ipynb");
  2606. std::fs::write(
  2607. &path,
  2608. r#"{
  2609. "cells": [
  2610. {"cell_type": "code", "id": "cell-a", "metadata": {}, "source": ["print(1)\n"], "outputs": [], "execution_count": null}
  2611. ],
  2612. "metadata": {"kernelspec": {"language": "python"}},
  2613. "nbformat": 4,
  2614. "nbformat_minor": 5
  2615. }"#,
  2616. )
  2617. .expect("write notebook");
  2618. let replaced = execute_tool(
  2619. "NotebookEdit",
  2620. &json!({
  2621. "notebook_path": path.display().to_string(),
  2622. "cell_id": "cell-a",
  2623. "new_source": "print(2)\n",
  2624. "edit_mode": "replace"
  2625. }),
  2626. )
  2627. .expect("NotebookEdit replace should succeed");
  2628. let replaced_output: serde_json::Value = serde_json::from_str(&replaced).expect("json");
  2629. assert_eq!(replaced_output["cell_id"], "cell-a");
  2630. assert_eq!(replaced_output["cell_type"], "code");
  2631. let inserted = execute_tool(
  2632. "NotebookEdit",
  2633. &json!({
  2634. "notebook_path": path.display().to_string(),
  2635. "cell_id": "cell-a",
  2636. "new_source": "# heading\n",
  2637. "cell_type": "markdown",
  2638. "edit_mode": "insert"
  2639. }),
  2640. )
  2641. .expect("NotebookEdit insert should succeed");
  2642. let inserted_output: serde_json::Value = serde_json::from_str(&inserted).expect("json");
  2643. assert_eq!(inserted_output["cell_type"], "markdown");
  2644. let appended = execute_tool(
  2645. "NotebookEdit",
  2646. &json!({
  2647. "notebook_path": path.display().to_string(),
  2648. "new_source": "print(3)\n",
  2649. "edit_mode": "insert"
  2650. }),
  2651. )
  2652. .expect("NotebookEdit append should succeed");
  2653. let appended_output: serde_json::Value = serde_json::from_str(&appended).expect("json");
  2654. assert_eq!(appended_output["cell_type"], "code");
  2655. let deleted = execute_tool(
  2656. "NotebookEdit",
  2657. &json!({
  2658. "notebook_path": path.display().to_string(),
  2659. "cell_id": "cell-a",
  2660. "edit_mode": "delete"
  2661. }),
  2662. )
  2663. .expect("NotebookEdit delete should succeed without new_source");
  2664. let deleted_output: serde_json::Value = serde_json::from_str(&deleted).expect("json");
  2665. assert!(deleted_output["cell_type"].is_null());
  2666. assert_eq!(deleted_output["new_source"], "");
  2667. let final_notebook: serde_json::Value =
  2668. serde_json::from_str(&std::fs::read_to_string(&path).expect("read notebook"))
  2669. .expect("valid notebook json");
  2670. let cells = final_notebook["cells"].as_array().expect("cells array");
  2671. assert_eq!(cells.len(), 2);
  2672. assert_eq!(cells[0]["cell_type"], "markdown");
  2673. assert!(cells[0].get("outputs").is_none());
  2674. assert_eq!(cells[1]["cell_type"], "code");
  2675. assert_eq!(cells[1]["source"][0], "print(3)\n");
  2676. let _ = std::fs::remove_file(path);
  2677. }
  2678. #[test]
  2679. fn notebook_edit_rejects_invalid_inputs() {
  2680. let text_path = temp_path("notebook.txt");
  2681. fs::write(&text_path, "not a notebook").expect("write text file");
  2682. let wrong_extension = execute_tool(
  2683. "NotebookEdit",
  2684. &json!({
  2685. "notebook_path": text_path.display().to_string(),
  2686. "new_source": "print(1)\n"
  2687. }),
  2688. )
  2689. .expect_err("non-ipynb file should fail");
  2690. assert!(wrong_extension.contains("Jupyter notebook"));
  2691. let _ = fs::remove_file(&text_path);
  2692. let empty_notebook = temp_path("empty.ipynb");
  2693. fs::write(
  2694. &empty_notebook,
  2695. r#"{"cells":[],"metadata":{"kernelspec":{"language":"python"}},"nbformat":4,"nbformat_minor":5}"#,
  2696. )
  2697. .expect("write empty notebook");
  2698. let missing_source = execute_tool(
  2699. "NotebookEdit",
  2700. &json!({
  2701. "notebook_path": empty_notebook.display().to_string(),
  2702. "edit_mode": "insert"
  2703. }),
  2704. )
  2705. .expect_err("insert without source should fail");
  2706. assert!(missing_source.contains("new_source is required"));
  2707. let missing_cell = execute_tool(
  2708. "NotebookEdit",
  2709. &json!({
  2710. "notebook_path": empty_notebook.display().to_string(),
  2711. "edit_mode": "delete"
  2712. }),
  2713. )
  2714. .expect_err("delete on empty notebook should fail");
  2715. assert!(missing_cell.contains("Notebook has no cells to edit"));
  2716. let _ = fs::remove_file(empty_notebook);
  2717. }
  2718. #[test]
  2719. fn bash_tool_reports_success_exit_failure_timeout_and_background() {
  2720. let success = execute_tool("bash", &json!({ "command": "printf 'hello'" }))
  2721. .expect("bash should succeed");
  2722. let success_output: serde_json::Value = serde_json::from_str(&success).expect("json");
  2723. assert_eq!(success_output["stdout"], "hello");
  2724. assert_eq!(success_output["interrupted"], false);
  2725. let failure = execute_tool("bash", &json!({ "command": "printf 'oops' >&2; exit 7" }))
  2726. .expect("bash failure should still return structured output");
  2727. let failure_output: serde_json::Value = serde_json::from_str(&failure).expect("json");
  2728. assert_eq!(failure_output["returnCodeInterpretation"], "exit_code:7");
  2729. assert!(failure_output["stderr"]
  2730. .as_str()
  2731. .expect("stderr")
  2732. .contains("oops"));
  2733. let timeout = execute_tool("bash", &json!({ "command": "sleep 1", "timeout": 10 }))
  2734. .expect("bash timeout should return output");
  2735. let timeout_output: serde_json::Value = serde_json::from_str(&timeout).expect("json");
  2736. assert_eq!(timeout_output["interrupted"], true);
  2737. assert_eq!(timeout_output["returnCodeInterpretation"], "timeout");
  2738. assert!(timeout_output["stderr"]
  2739. .as_str()
  2740. .expect("stderr")
  2741. .contains("Command exceeded timeout"));
  2742. let background = execute_tool(
  2743. "bash",
  2744. &json!({ "command": "sleep 1", "run_in_background": true }),
  2745. )
  2746. .expect("bash background should succeed");
  2747. let background_output: serde_json::Value = serde_json::from_str(&background).expect("json");
  2748. assert!(background_output["backgroundTaskId"].as_str().is_some());
  2749. assert_eq!(background_output["noOutputExpected"], true);
  2750. }
  2751. #[test]
  2752. fn file_tools_cover_read_write_and_edit_behaviors() {
  2753. let _guard = env_lock()
  2754. .lock()
  2755. .unwrap_or_else(std::sync::PoisonError::into_inner);
  2756. let root = temp_path("fs-suite");
  2757. fs::create_dir_all(&root).expect("create root");
  2758. let original_dir = std::env::current_dir().expect("cwd");
  2759. std::env::set_current_dir(&root).expect("set cwd");
  2760. let write_create = execute_tool(
  2761. "write_file",
  2762. &json!({ "path": "nested/demo.txt", "content": "alpha\nbeta\nalpha\n" }),
  2763. )
  2764. .expect("write create should succeed");
  2765. let write_create_output: serde_json::Value =
  2766. serde_json::from_str(&write_create).expect("json");
  2767. assert_eq!(write_create_output["type"], "create");
  2768. assert!(root.join("nested/demo.txt").exists());
  2769. let write_update = execute_tool(
  2770. "write_file",
  2771. &json!({ "path": "nested/demo.txt", "content": "alpha\nbeta\ngamma\n" }),
  2772. )
  2773. .expect("write update should succeed");
  2774. let write_update_output: serde_json::Value =
  2775. serde_json::from_str(&write_update).expect("json");
  2776. assert_eq!(write_update_output["type"], "update");
  2777. assert_eq!(write_update_output["originalFile"], "alpha\nbeta\nalpha\n");
  2778. let read_full = execute_tool("read_file", &json!({ "path": "nested/demo.txt" }))
  2779. .expect("read full should succeed");
  2780. let read_full_output: serde_json::Value = serde_json::from_str(&read_full).expect("json");
  2781. assert_eq!(read_full_output["file"]["content"], "alpha\nbeta\ngamma");
  2782. assert_eq!(read_full_output["file"]["startLine"], 1);
  2783. let read_slice = execute_tool(
  2784. "read_file",
  2785. &json!({ "path": "nested/demo.txt", "offset": 1, "limit": 1 }),
  2786. )
  2787. .expect("read slice should succeed");
  2788. let read_slice_output: serde_json::Value = serde_json::from_str(&read_slice).expect("json");
  2789. assert_eq!(read_slice_output["file"]["content"], "beta");
  2790. assert_eq!(read_slice_output["file"]["startLine"], 2);
  2791. let read_past_end = execute_tool(
  2792. "read_file",
  2793. &json!({ "path": "nested/demo.txt", "offset": 50 }),
  2794. )
  2795. .expect("read past EOF should succeed");
  2796. let read_past_end_output: serde_json::Value =
  2797. serde_json::from_str(&read_past_end).expect("json");
  2798. assert_eq!(read_past_end_output["file"]["content"], "");
  2799. assert_eq!(read_past_end_output["file"]["startLine"], 4);
  2800. let read_error = execute_tool("read_file", &json!({ "path": "missing.txt" }))
  2801. .expect_err("missing file should fail");
  2802. assert!(!read_error.is_empty());
  2803. let edit_once = execute_tool(
  2804. "edit_file",
  2805. &json!({ "path": "nested/demo.txt", "old_string": "alpha", "new_string": "omega" }),
  2806. )
  2807. .expect("single edit should succeed");
  2808. let edit_once_output: serde_json::Value = serde_json::from_str(&edit_once).expect("json");
  2809. assert_eq!(edit_once_output["replaceAll"], false);
  2810. assert_eq!(
  2811. fs::read_to_string(root.join("nested/demo.txt")).expect("read file"),
  2812. "omega\nbeta\ngamma\n"
  2813. );
  2814. execute_tool(
  2815. "write_file",
  2816. &json!({ "path": "nested/demo.txt", "content": "alpha\nbeta\nalpha\n" }),
  2817. )
  2818. .expect("reset file");
  2819. let edit_all = execute_tool(
  2820. "edit_file",
  2821. &json!({
  2822. "path": "nested/demo.txt",
  2823. "old_string": "alpha",
  2824. "new_string": "omega",
  2825. "replace_all": true
  2826. }),
  2827. )
  2828. .expect("replace all should succeed");
  2829. let edit_all_output: serde_json::Value = serde_json::from_str(&edit_all).expect("json");
  2830. assert_eq!(edit_all_output["replaceAll"], true);
  2831. assert_eq!(
  2832. fs::read_to_string(root.join("nested/demo.txt")).expect("read file"),
  2833. "omega\nbeta\nomega\n"
  2834. );
  2835. let edit_same = execute_tool(
  2836. "edit_file",
  2837. &json!({ "path": "nested/demo.txt", "old_string": "omega", "new_string": "omega" }),
  2838. )
  2839. .expect_err("identical old/new should fail");
  2840. assert!(edit_same.contains("must differ"));
  2841. let edit_missing = execute_tool(
  2842. "edit_file",
  2843. &json!({ "path": "nested/demo.txt", "old_string": "missing", "new_string": "omega" }),
  2844. )
  2845. .expect_err("missing substring should fail");
  2846. assert!(edit_missing.contains("old_string not found"));
  2847. std::env::set_current_dir(&original_dir).expect("restore cwd");
  2848. let _ = fs::remove_dir_all(root);
  2849. }
  2850. #[test]
  2851. fn glob_and_grep_tools_cover_success_and_errors() {
  2852. let _guard = env_lock()
  2853. .lock()
  2854. .unwrap_or_else(std::sync::PoisonError::into_inner);
  2855. let root = temp_path("search-suite");
  2856. fs::create_dir_all(root.join("nested")).expect("create root");
  2857. let original_dir = std::env::current_dir().expect("cwd");
  2858. std::env::set_current_dir(&root).expect("set cwd");
  2859. fs::write(
  2860. root.join("nested/lib.rs"),
  2861. "fn main() {}\nlet alpha = 1;\nlet alpha = 2;\n",
  2862. )
  2863. .expect("write rust file");
  2864. fs::write(root.join("nested/notes.txt"), "alpha\nbeta\n").expect("write txt file");
  2865. let globbed = execute_tool("glob_search", &json!({ "pattern": "nested/*.rs" }))
  2866. .expect("glob should succeed");
  2867. let globbed_output: serde_json::Value = serde_json::from_str(&globbed).expect("json");
  2868. assert_eq!(globbed_output["numFiles"], 1);
  2869. assert!(globbed_output["filenames"][0]
  2870. .as_str()
  2871. .expect("filename")
  2872. .ends_with("nested/lib.rs"));
  2873. let glob_error = execute_tool("glob_search", &json!({ "pattern": "[" }))
  2874. .expect_err("invalid glob should fail");
  2875. assert!(!glob_error.is_empty());
  2876. let grep_content = execute_tool(
  2877. "grep_search",
  2878. &json!({
  2879. "pattern": "alpha",
  2880. "path": "nested",
  2881. "glob": "*.rs",
  2882. "output_mode": "content",
  2883. "-n": true,
  2884. "head_limit": 1,
  2885. "offset": 1
  2886. }),
  2887. )
  2888. .expect("grep content should succeed");
  2889. let grep_content_output: serde_json::Value =
  2890. serde_json::from_str(&grep_content).expect("json");
  2891. assert_eq!(grep_content_output["numFiles"], 0);
  2892. assert!(grep_content_output["appliedLimit"].is_null());
  2893. assert_eq!(grep_content_output["appliedOffset"], 1);
  2894. assert!(grep_content_output["content"]
  2895. .as_str()
  2896. .expect("content")
  2897. .contains("let alpha = 2;"));
  2898. let grep_count = execute_tool(
  2899. "grep_search",
  2900. &json!({ "pattern": "alpha", "path": "nested", "output_mode": "count" }),
  2901. )
  2902. .expect("grep count should succeed");
  2903. let grep_count_output: serde_json::Value = serde_json::from_str(&grep_count).expect("json");
  2904. assert_eq!(grep_count_output["numMatches"], 3);
  2905. let grep_error = execute_tool(
  2906. "grep_search",
  2907. &json!({ "pattern": "(alpha", "path": "nested" }),
  2908. )
  2909. .expect_err("invalid regex should fail");
  2910. assert!(!grep_error.is_empty());
  2911. std::env::set_current_dir(&original_dir).expect("restore cwd");
  2912. let _ = fs::remove_dir_all(root);
  2913. }
  2914. #[test]
  2915. fn sleep_waits_and_reports_duration() {
  2916. let started = std::time::Instant::now();
  2917. let result =
  2918. execute_tool("Sleep", &json!({"duration_ms": 20})).expect("Sleep should succeed");
  2919. let elapsed = started.elapsed();
  2920. let output: serde_json::Value = serde_json::from_str(&result).expect("json");
  2921. assert_eq!(output["duration_ms"], 20);
  2922. assert!(output["message"]
  2923. .as_str()
  2924. .expect("message")
  2925. .contains("Slept for 20ms"));
  2926. assert!(elapsed >= Duration::from_millis(15));
  2927. }
  2928. #[test]
  2929. fn brief_returns_sent_message_and_attachment_metadata() {
  2930. let attachment = std::env::temp_dir().join(format!(
  2931. "clawd-brief-{}.png",
  2932. std::time::SystemTime::now()
  2933. .duration_since(std::time::UNIX_EPOCH)
  2934. .expect("time")
  2935. .as_nanos()
  2936. ));
  2937. std::fs::write(&attachment, b"png-data").expect("write attachment");
  2938. let result = execute_tool(
  2939. "SendUserMessage",
  2940. &json!({
  2941. "message": "hello user",
  2942. "attachments": [attachment.display().to_string()],
  2943. "status": "normal"
  2944. }),
  2945. )
  2946. .expect("SendUserMessage should succeed");
  2947. let output: serde_json::Value = serde_json::from_str(&result).expect("json");
  2948. assert_eq!(output["message"], "hello user");
  2949. assert!(output["sentAt"].as_str().is_some());
  2950. assert_eq!(output["attachments"][0]["isImage"], true);
  2951. let _ = std::fs::remove_file(attachment);
  2952. }
  2953. #[test]
  2954. fn config_reads_and_writes_supported_values() {
  2955. let _guard = env_lock()
  2956. .lock()
  2957. .unwrap_or_else(std::sync::PoisonError::into_inner);
  2958. let root = std::env::temp_dir().join(format!(
  2959. "clawd-config-{}",
  2960. std::time::SystemTime::now()
  2961. .duration_since(std::time::UNIX_EPOCH)
  2962. .expect("time")
  2963. .as_nanos()
  2964. ));
  2965. let home = root.join("home");
  2966. let cwd = root.join("cwd");
  2967. std::fs::create_dir_all(home.join(".claude")).expect("home dir");
  2968. std::fs::create_dir_all(cwd.join(".claude")).expect("cwd dir");
  2969. std::fs::write(
  2970. home.join(".claude").join("settings.json"),
  2971. r#"{"verbose":false}"#,
  2972. )
  2973. .expect("write global settings");
  2974. let original_home = std::env::var("HOME").ok();
  2975. let original_claude_home = std::env::var("CLAUDE_CONFIG_HOME").ok();
  2976. let original_dir = std::env::current_dir().expect("cwd");
  2977. std::env::set_var("HOME", &home);
  2978. std::env::remove_var("CLAUDE_CONFIG_HOME");
  2979. std::env::set_current_dir(&cwd).expect("set cwd");
  2980. let get = execute_tool("Config", &json!({"setting": "verbose"})).expect("get config");
  2981. let get_output: serde_json::Value = serde_json::from_str(&get).expect("json");
  2982. assert_eq!(get_output["value"], false);
  2983. let set = execute_tool(
  2984. "Config",
  2985. &json!({"setting": "permissions.defaultMode", "value": "plan"}),
  2986. )
  2987. .expect("set config");
  2988. let set_output: serde_json::Value = serde_json::from_str(&set).expect("json");
  2989. assert_eq!(set_output["operation"], "set");
  2990. assert_eq!(set_output["newValue"], "plan");
  2991. let invalid = execute_tool(
  2992. "Config",
  2993. &json!({"setting": "permissions.defaultMode", "value": "bogus"}),
  2994. )
  2995. .expect_err("invalid config value should error");
  2996. assert!(invalid.contains("Invalid value"));
  2997. let unknown =
  2998. execute_tool("Config", &json!({"setting": "nope"})).expect("unknown setting result");
  2999. let unknown_output: serde_json::Value = serde_json::from_str(&unknown).expect("json");
  3000. assert_eq!(unknown_output["success"], false);
  3001. std::env::set_current_dir(&original_dir).expect("restore cwd");
  3002. match original_home {
  3003. Some(value) => std::env::set_var("HOME", value),
  3004. None => std::env::remove_var("HOME"),
  3005. }
  3006. match original_claude_home {
  3007. Some(value) => std::env::set_var("CLAUDE_CONFIG_HOME", value),
  3008. None => std::env::remove_var("CLAUDE_CONFIG_HOME"),
  3009. }
  3010. let _ = std::fs::remove_dir_all(root);
  3011. }
  3012. #[test]
  3013. fn structured_output_echoes_input_payload() {
  3014. let result = execute_tool("StructuredOutput", &json!({"ok": true, "items": [1, 2, 3]}))
  3015. .expect("StructuredOutput should succeed");
  3016. let output: serde_json::Value = serde_json::from_str(&result).expect("json");
  3017. assert_eq!(output["data"], "Structured output provided successfully");
  3018. assert_eq!(output["structured_output"]["ok"], true);
  3019. assert_eq!(output["structured_output"]["items"][1], 2);
  3020. }
  3021. #[test]
  3022. fn repl_executes_python_code() {
  3023. let result = execute_tool(
  3024. "REPL",
  3025. &json!({"language": "python", "code": "print(1 + 1)", "timeout_ms": 500}),
  3026. )
  3027. .expect("REPL should succeed");
  3028. let output: serde_json::Value = serde_json::from_str(&result).expect("json");
  3029. assert_eq!(output["language"], "python");
  3030. assert_eq!(output["exitCode"], 0);
  3031. assert!(output["stdout"].as_str().expect("stdout").contains('2'));
  3032. }
  3033. #[test]
  3034. fn powershell_runs_via_stub_shell() {
  3035. let _guard = env_lock()
  3036. .lock()
  3037. .unwrap_or_else(std::sync::PoisonError::into_inner);
  3038. let dir = std::env::temp_dir().join(format!(
  3039. "clawd-pwsh-bin-{}",
  3040. std::time::SystemTime::now()
  3041. .duration_since(std::time::UNIX_EPOCH)
  3042. .expect("time")
  3043. .as_nanos()
  3044. ));
  3045. std::fs::create_dir_all(&dir).expect("create dir");
  3046. let script = dir.join("pwsh");
  3047. std::fs::write(
  3048. &script,
  3049. r#"#!/bin/sh
  3050. while [ "$1" != "-Command" ] && [ $# -gt 0 ]; do shift; done
  3051. shift
  3052. printf 'pwsh:%s' "$1"
  3053. "#,
  3054. )
  3055. .expect("write script");
  3056. std::process::Command::new("/bin/chmod")
  3057. .arg("+x")
  3058. .arg(&script)
  3059. .status()
  3060. .expect("chmod");
  3061. let original_path = std::env::var("PATH").unwrap_or_default();
  3062. std::env::set_var("PATH", format!("{}:{}", dir.display(), original_path));
  3063. let result = execute_tool(
  3064. "PowerShell",
  3065. &json!({"command": "Write-Output hello", "timeout": 1000}),
  3066. )
  3067. .expect("PowerShell should succeed");
  3068. let background = execute_tool(
  3069. "PowerShell",
  3070. &json!({"command": "Write-Output hello", "run_in_background": true}),
  3071. )
  3072. .expect("PowerShell background should succeed");
  3073. std::env::set_var("PATH", original_path);
  3074. let _ = std::fs::remove_dir_all(dir);
  3075. let output: serde_json::Value = serde_json::from_str(&result).expect("json");
  3076. assert_eq!(output["stdout"], "pwsh:Write-Output hello");
  3077. assert!(output["stderr"].as_str().expect("stderr").is_empty());
  3078. let background_output: serde_json::Value = serde_json::from_str(&background).expect("json");
  3079. assert!(background_output["backgroundTaskId"].as_str().is_some());
  3080. assert_eq!(background_output["backgroundedByUser"], true);
  3081. assert_eq!(background_output["assistantAutoBackgrounded"], false);
  3082. }
  3083. #[test]
  3084. fn powershell_errors_when_shell_is_missing() {
  3085. let _guard = env_lock()
  3086. .lock()
  3087. .unwrap_or_else(std::sync::PoisonError::into_inner);
  3088. let original_path = std::env::var("PATH").unwrap_or_default();
  3089. let empty_dir = std::env::temp_dir().join(format!(
  3090. "clawd-empty-bin-{}",
  3091. std::time::SystemTime::now()
  3092. .duration_since(std::time::UNIX_EPOCH)
  3093. .expect("time")
  3094. .as_nanos()
  3095. ));
  3096. std::fs::create_dir_all(&empty_dir).expect("create empty dir");
  3097. std::env::set_var("PATH", empty_dir.display().to_string());
  3098. let err = execute_tool("PowerShell", &json!({"command": "Write-Output hello"}))
  3099. .expect_err("PowerShell should fail when shell is missing");
  3100. std::env::set_var("PATH", original_path);
  3101. let _ = std::fs::remove_dir_all(empty_dir);
  3102. assert!(err.contains("PowerShell executable not found"));
  3103. }
  3104. struct TestServer {
  3105. addr: SocketAddr,
  3106. shutdown: Option<std::sync::mpsc::Sender<()>>,
  3107. handle: Option<thread::JoinHandle<()>>,
  3108. }
  3109. impl TestServer {
  3110. fn spawn(handler: Arc<dyn Fn(&str) -> HttpResponse + Send + Sync + 'static>) -> Self {
  3111. let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
  3112. listener
  3113. .set_nonblocking(true)
  3114. .expect("set nonblocking listener");
  3115. let addr = listener.local_addr().expect("local addr");
  3116. let (tx, rx) = std::sync::mpsc::channel::<()>();
  3117. let handle = thread::spawn(move || loop {
  3118. if rx.try_recv().is_ok() {
  3119. break;
  3120. }
  3121. match listener.accept() {
  3122. Ok((mut stream, _)) => {
  3123. let mut buffer = [0_u8; 4096];
  3124. let size = stream.read(&mut buffer).expect("read request");
  3125. let request = String::from_utf8_lossy(&buffer[..size]).into_owned();
  3126. let request_line = request.lines().next().unwrap_or_default().to_string();
  3127. let response = handler(&request_line);
  3128. stream
  3129. .write_all(response.to_bytes().as_slice())
  3130. .expect("write response");
  3131. }
  3132. Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
  3133. thread::sleep(Duration::from_millis(10));
  3134. }
  3135. Err(error) => panic!("server accept failed: {error}"),
  3136. }
  3137. });
  3138. Self {
  3139. addr,
  3140. shutdown: Some(tx),
  3141. handle: Some(handle),
  3142. }
  3143. }
  3144. fn addr(&self) -> SocketAddr {
  3145. self.addr
  3146. }
  3147. }
  3148. impl Drop for TestServer {
  3149. fn drop(&mut self) {
  3150. if let Some(tx) = self.shutdown.take() {
  3151. let _ = tx.send(());
  3152. }
  3153. if let Some(handle) = self.handle.take() {
  3154. handle.join().expect("join test server");
  3155. }
  3156. }
  3157. }
  3158. struct HttpResponse {
  3159. status: u16,
  3160. reason: &'static str,
  3161. content_type: &'static str,
  3162. body: String,
  3163. }
  3164. impl HttpResponse {
  3165. fn html(status: u16, reason: &'static str, body: &str) -> Self {
  3166. Self {
  3167. status,
  3168. reason,
  3169. content_type: "text/html; charset=utf-8",
  3170. body: body.to_string(),
  3171. }
  3172. }
  3173. fn text(status: u16, reason: &'static str, body: &str) -> Self {
  3174. Self {
  3175. status,
  3176. reason,
  3177. content_type: "text/plain; charset=utf-8",
  3178. body: body.to_string(),
  3179. }
  3180. }
  3181. fn to_bytes(&self) -> Vec<u8> {
  3182. format!(
  3183. "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
  3184. self.status,
  3185. self.reason,
  3186. self.content_type,
  3187. self.body.len(),
  3188. self.body
  3189. )
  3190. .into_bytes()
  3191. }
  3192. }
  3193. }
备用站点 当前处于降级运行的备用站点,仅供应急访问,数据和功能可能不是最新。