lib.rs 101 KB

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