1use std::path::Path;
2use std::process::Command;
3use std::time::{Duration, Instant};
4
5use serde::Deserialize;
6use serde_json::Value;
7
8use runtime::{
9 edit_file, execute_bash, glob_search, grep_search, read_file, write_file, BashCommandInput,
10 GrepSearchInput,
11};
12
13mod agent;
14mod config_tool;
15mod notebook;
16mod powershell;
17mod registry;
18mod specs;
19mod types;
20mod web;
21
22pub use registry::{GlobalToolRegistry, ToolManifestEntry, ToolRegistry, ToolSource, ToolSpec};
23pub use specs::mvp_tool_specs;
24pub use types::ToolSearchInput;
25
26#[cfg(test)]
27pub(crate) use agent::{
28 agent_permission_policy, allowed_tools_for_subagent, execute_agent_with_spawn,
29 final_assistant_text, persist_agent_terminal_state, push_output_block, SubagentToolExecutor,
30};
31pub(crate) use types::AgentInput;
32#[cfg(test)]
33pub(crate) use types::AgentJob;
34
35use crate::types::{
36 BriefInput, BriefOutput, BriefStatus, ConfigInput, EditFileInput, GlobSearchInputValue,
37 NotebookEditInput, PowerShellInput, ReadFileInput, ReplInput, ReplOutput, ResolvedAttachment,
38 SkillInput, SkillOutput, SleepInput, SleepOutput, StructuredOutputInput,
39 StructuredOutputResult, TodoItem, TodoStatus, TodoWriteInput, TodoWriteOutput,
40 ToolSearchOutput, WebFetchInput, WebSearchInput, WriteFileInput,
41};
42
43pub fn execute_tool(name: &str, input: &Value) -> Result<String, String> {
44 match name {
45 "bash" => from_value::<BashCommandInput>(input).and_then(run_bash),
46 "read_file" => from_value::<ReadFileInput>(input).and_then(run_read_file),
47 "write_file" => from_value::<WriteFileInput>(input).and_then(run_write_file),
48 "edit_file" => from_value::<EditFileInput>(input).and_then(run_edit_file),
49 "glob_search" => from_value::<GlobSearchInputValue>(input).and_then(run_glob_search),
50 "grep_search" => from_value::<GrepSearchInput>(input).and_then(run_grep_search),
51 "WebFetch" => from_value::<WebFetchInput>(input).and_then(run_web_fetch),
52 "WebSearch" => from_value::<WebSearchInput>(input).and_then(run_web_search),
53 "TodoWrite" => from_value::<TodoWriteInput>(input).and_then(run_todo_write),
54 "Skill" => from_value::<SkillInput>(input).and_then(run_skill),
55 "Agent" => from_value::<AgentInput>(input).and_then(run_agent),
56 "ToolSearch" => from_value::<ToolSearchInput>(input).and_then(run_tool_search),
57 "NotebookEdit" => from_value::<NotebookEditInput>(input).and_then(run_notebook_edit),
58 "Sleep" => from_value::<SleepInput>(input).and_then(run_sleep),
59 "SendUserMessage" | "Brief" => from_value::<BriefInput>(input).and_then(run_brief),
60 "Config" => from_value::<ConfigInput>(input).and_then(run_config),
61 "StructuredOutput" => {
62 from_value::<StructuredOutputInput>(input).and_then(run_structured_output)
63 }
64 "REPL" => from_value::<ReplInput>(input).and_then(run_repl),
65 "PowerShell" => from_value::<PowerShellInput>(input).and_then(run_powershell),
66 _ => Err(format!("unsupported tool: {name}")),
67 }
68}
69
70fn from_value<T: for<'de> Deserialize<'de>>(input: &Value) -> Result<T, String> {
71 serde_json::from_value(input.clone()).map_err(|error| error.to_string())
72}
73
74fn run_bash(input: BashCommandInput) -> Result<String, String> {
75 serde_json::to_string_pretty(&execute_bash(input).map_err(|error| error.to_string())?)
76 .map_err(|error| error.to_string())
77}
78
79#[allow(clippy::needless_pass_by_value)]
80fn run_read_file(input: ReadFileInput) -> Result<String, String> {
81 to_pretty_json(read_file(&input.path, input.offset, input.limit).map_err(io_to_string)?)
82}
83
84#[allow(clippy::needless_pass_by_value)]
85fn run_write_file(input: WriteFileInput) -> Result<String, String> {
86 to_pretty_json(write_file(&input.path, &input.content).map_err(io_to_string)?)
87}
88
89#[allow(clippy::needless_pass_by_value)]
90fn run_edit_file(input: EditFileInput) -> Result<String, String> {
91 to_pretty_json(
92 edit_file(
93 &input.path,
94 &input.old_string,
95 &input.new_string,
96 input.replace_all.unwrap_or(false),
97 )
98 .map_err(io_to_string)?,
99 )
100}
101
102#[allow(clippy::needless_pass_by_value)]
103fn run_glob_search(input: GlobSearchInputValue) -> Result<String, String> {
104 to_pretty_json(glob_search(&input.pattern, input.path.as_deref()).map_err(io_to_string)?)
105}
106
107#[allow(clippy::needless_pass_by_value)]
108fn run_grep_search(input: GrepSearchInput) -> Result<String, String> {
109 to_pretty_json(grep_search(&input).map_err(io_to_string)?)
110}
111
112#[allow(clippy::needless_pass_by_value)]
113fn run_web_fetch(input: WebFetchInput) -> Result<String, String> {
114 to_pretty_json(crate::web::execute_web_fetch(&input)?)
115}
116
117#[allow(clippy::needless_pass_by_value)]
118fn run_web_search(input: WebSearchInput) -> Result<String, String> {
119 to_pretty_json(crate::web::execute_web_search(&input)?)
120}
121
122fn run_todo_write(input: TodoWriteInput) -> Result<String, String> {
123 to_pretty_json(execute_todo_write(input)?)
124}
125
126fn run_skill(input: SkillInput) -> Result<String, String> {
127 to_pretty_json(execute_skill(input)?)
128}
129
130fn run_agent(input: AgentInput) -> Result<String, String> {
131 to_pretty_json(crate::agent::execute_agent(input)?)
132}
133
134fn run_tool_search(input: ToolSearchInput) -> Result<String, String> {
135 to_pretty_json(execute_tool_search(input))
136}
137
138fn run_notebook_edit(input: NotebookEditInput) -> Result<String, String> {
139 to_pretty_json(crate::notebook::execute_notebook_edit(input)?)
140}
141
142fn run_sleep(input: SleepInput) -> Result<String, String> {
143 to_pretty_json(execute_sleep(input))
144}
145
146fn run_brief(input: BriefInput) -> Result<String, String> {
147 to_pretty_json(execute_brief(input)?)
148}
149
150fn run_config(input: ConfigInput) -> Result<String, String> {
151 to_pretty_json(crate::config_tool::execute_config(input)?)
152}
153
154fn run_structured_output(input: StructuredOutputInput) -> Result<String, String> {
155 to_pretty_json(execute_structured_output(input))
156}
157
158fn run_repl(input: ReplInput) -> Result<String, String> {
159 to_pretty_json(execute_repl(input)?)
160}
161
162fn run_powershell(input: PowerShellInput) -> Result<String, String> {
163 to_pretty_json(crate::powershell::execute_powershell(input).map_err(|error| error.to_string())?)
164}
165
166fn to_pretty_json<T: serde::Serialize>(value: T) -> Result<String, String> {
167 serde_json::to_string_pretty(&value).map_err(|error| error.to_string())
168}
169
170#[allow(clippy::needless_pass_by_value)]
171fn io_to_string(error: std::io::Error) -> String {
172 error.to_string()
173}
174
175fn execute_todo_write(input: TodoWriteInput) -> Result<TodoWriteOutput, String> {
176 validate_todos(&input.todos)?;
177 let store_path = todo_store_path()?;
178 let old_todos = if store_path.exists() {
179 serde_json::from_str::<Vec<TodoItem>>(
180 &std::fs::read_to_string(&store_path).map_err(|error| error.to_string())?,
181 )
182 .map_err(|error| error.to_string())?
183 } else {
184 Vec::new()
185 };
186
187 let all_done = input
188 .todos
189 .iter()
190 .all(|todo| matches!(todo.status, TodoStatus::Completed));
191 let persisted = if all_done {
192 Vec::new()
193 } else {
194 input.todos.clone()
195 };
196
197 if let Some(parent) = store_path.parent() {
198 std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
199 }
200 std::fs::write(
201 &store_path,
202 serde_json::to_string_pretty(&persisted).map_err(|error| error.to_string())?,
203 )
204 .map_err(|error| error.to_string())?;
205
206 let verification_nudge_needed = (all_done
207 && input.todos.len() >= 3
208 && !input
209 .todos
210 .iter()
211 .any(|todo| todo.content.to_lowercase().contains("verif")))
212 .then_some(true);
213
214 Ok(TodoWriteOutput {
215 old_todos,
216 new_todos: input.todos,
217 verification_nudge_needed,
218 })
219}
220
221fn execute_skill(input: SkillInput) -> Result<SkillOutput, String> {
222 let skill_path = resolve_skill_path(&input.skill)?;
223 let prompt = std::fs::read_to_string(&skill_path).map_err(|error| error.to_string())?;
224 let description = parse_skill_description(&prompt);
225
226 Ok(SkillOutput {
227 skill: input.skill,
228 path: skill_path.display().to_string(),
229 args: input.args,
230 description,
231 prompt,
232 })
233}
234
235fn validate_todos(todos: &[TodoItem]) -> Result<(), String> {
236 if todos.is_empty() {
237 return Err(String::from("todos must not be empty"));
238 }
239 if todos.iter().any(|todo| todo.content.trim().is_empty()) {
241 return Err(String::from("todo content must not be empty"));
242 }
243 if todos.iter().any(|todo| todo.active_form.trim().is_empty()) {
244 return Err(String::from("todo activeForm must not be empty"));
245 }
246 Ok(())
247}
248
249fn todo_store_path() -> Result<std::path::PathBuf, String> {
250 if let Ok(path) = std::env::var("CODINEER_TODO_STORE") {
251 return Ok(std::path::PathBuf::from(path));
252 }
253 let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
254 Ok(cwd.join(".codineer-todos.json"))
255}
256
257fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
258 let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
259 if requested.is_empty() {
260 return Err(String::from("skill must not be empty"));
261 }
262
263 let mut candidates = Vec::new();
264 if let Ok(cwd) = std::env::current_dir() {
265 candidates.push(cwd.join(".codineer").join("skills"));
266 }
267 if let Ok(codineer_home) = std::env::var("CODINEER_CONFIG_HOME") {
268 candidates.push(std::path::PathBuf::from(codineer_home).join("skills"));
269 }
270 if let Some(home) = runtime::home_dir() {
271 candidates.push(home.join(".codineer").join("skills"));
272 candidates.push(home.join(".agents").join("skills"));
273 }
274
275 for root in candidates {
276 let direct = root.join(requested).join("SKILL.md");
277 if direct.exists() {
278 return Ok(direct);
279 }
280
281 if let Ok(entries) = std::fs::read_dir(&root) {
282 for entry in entries.flatten() {
283 let path = entry.path().join("SKILL.md");
284 if !path.exists() {
285 continue;
286 }
287 if entry
288 .file_name()
289 .to_string_lossy()
290 .eq_ignore_ascii_case(requested)
291 {
292 return Ok(path);
293 }
294 }
295 }
296 }
297
298 Err(format!("unknown skill: {requested}"))
299}
300
301fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput {
302 execute_tool_search_with_context(input, None)
303}
304
305pub fn execute_tool_search_with_context(
306 input: ToolSearchInput,
307 pending_mcp_servers: Option<Vec<String>>,
308) -> ToolSearchOutput {
309 let deferred = deferred_tool_specs();
310 let max_results = input.max_results.unwrap_or(5).max(1);
311 let query = input.query.trim().to_string();
312 let normalized_query = normalize_tool_search_query(&query);
313 let matches = search_tool_specs(&query, max_results, &deferred);
314
315 ToolSearchOutput {
316 matches,
317 query,
318 normalized_query,
319 total_deferred_tools: deferred.len(),
320 pending_mcp_servers: pending_mcp_servers.filter(|servers| !servers.is_empty()),
321 }
322}
323
324fn deferred_tool_specs() -> Vec<ToolSpec> {
325 mvp_tool_specs()
326 .into_iter()
327 .filter(|spec| {
328 !matches!(
329 spec.name,
330 "bash" | "read_file" | "write_file" | "edit_file" | "glob_search" | "grep_search"
331 )
332 })
333 .collect()
334}
335
336fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec<String> {
337 if query.trim().is_empty() {
338 return Vec::new();
339 }
340 let lowered = query.to_lowercase();
341 if let Some(selection) = lowered.strip_prefix("select:") {
342 return selection
343 .split(',')
344 .map(str::trim)
345 .filter(|part| !part.is_empty())
346 .filter_map(|wanted| {
347 let wanted = canonical_tool_token(wanted);
348 specs
349 .iter()
350 .find(|spec| canonical_tool_token(spec.name) == wanted)
351 .map(|spec| spec.name.to_string())
352 })
353 .take(max_results)
354 .collect();
355 }
356
357 let mut required = Vec::new();
358 let mut optional = Vec::new();
359 for term in lowered.split_whitespace() {
360 if let Some(rest) = term.strip_prefix('+') {
361 if !rest.is_empty() {
362 required.push(rest);
363 }
364 } else {
365 optional.push(term);
366 }
367 }
368 let terms = if required.is_empty() {
369 optional.clone()
370 } else {
371 required.iter().chain(optional.iter()).copied().collect()
372 };
373
374 let mut scored = specs
375 .iter()
376 .filter_map(|spec| {
377 let name = spec.name.to_lowercase();
378 let canonical_name = canonical_tool_token(spec.name);
379 let normalized_description = normalize_tool_search_query(spec.description);
380 let haystack = format!(
381 "{name} {} {canonical_name}",
382 spec.description.to_lowercase()
383 );
384 let normalized_haystack = format!("{canonical_name} {normalized_description}");
385 if required.iter().any(|term| !haystack.contains(term)) {
386 return None;
387 }
388
389 let mut score = 0_i32;
390 for term in &terms {
391 let canonical_term = canonical_tool_token(term);
392 if haystack.contains(term) {
393 score += 2;
394 }
395 if name == *term {
396 score += 8;
397 }
398 if name.contains(term) {
399 score += 4;
400 }
401 if canonical_name == canonical_term {
402 score += 12;
403 }
404 if normalized_haystack.contains(&canonical_term) {
405 score += 3;
406 }
407 }
408
409 if score == 0 && !lowered.is_empty() {
410 return None;
411 }
412 Some((score, spec.name.to_string()))
413 })
414 .collect::<Vec<_>>();
415
416 scored.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
417 scored
418 .into_iter()
419 .map(|(_, name)| name)
420 .take(max_results)
421 .collect()
422}
423
424fn normalize_tool_search_query(query: &str) -> String {
425 query
426 .trim()
427 .split(|ch: char| ch.is_whitespace() || ch == ',')
428 .filter(|term| !term.is_empty())
429 .map(canonical_tool_token)
430 .collect::<Vec<_>>()
431 .join(" ")
432}
433
434pub(crate) fn canonical_tool_token(value: &str) -> String {
435 let mut canonical = value
436 .chars()
437 .filter(char::is_ascii_alphanumeric)
438 .flat_map(char::to_lowercase)
439 .collect::<String>();
440 if let Some(stripped) = canonical.strip_suffix("tool") {
441 canonical = stripped.to_string();
442 }
443 canonical
444}
445
446#[allow(clippy::needless_pass_by_value)]
447fn execute_sleep(input: SleepInput) -> SleepOutput {
448 std::thread::sleep(Duration::from_millis(input.duration_ms));
449 SleepOutput {
450 duration_ms: input.duration_ms,
451 message: format!("Slept for {}ms", input.duration_ms),
452 }
453}
454
455fn execute_brief(input: BriefInput) -> Result<BriefOutput, String> {
456 if input.message.trim().is_empty() {
457 return Err(String::from("message must not be empty"));
458 }
459
460 let attachments = input
461 .attachments
462 .as_ref()
463 .map(|paths| {
464 paths
465 .iter()
466 .map(|path| resolve_attachment(path))
467 .collect::<Result<Vec<_>, String>>()
468 })
469 .transpose()?;
470
471 let message = match input.status {
472 BriefStatus::Normal | BriefStatus::Proactive => input.message,
473 };
474
475 Ok(BriefOutput {
476 message,
477 attachments,
478 sent_at: crate::config_tool::iso8601_timestamp(),
479 })
480}
481
482fn resolve_attachment(path: &str) -> Result<ResolvedAttachment, String> {
483 let resolved = std::fs::canonicalize(path).map_err(|error| error.to_string())?;
484 let metadata = std::fs::metadata(&resolved).map_err(|error| error.to_string())?;
485 Ok(ResolvedAttachment {
486 path: resolved.display().to_string(),
487 size: metadata.len(),
488 is_image: is_image_path(&resolved),
489 })
490}
491
492fn is_image_path(path: &Path) -> bool {
493 matches!(
494 path.extension()
495 .and_then(|ext| ext.to_str())
496 .map(str::to_ascii_lowercase)
497 .as_deref(),
498 Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg")
499 )
500}
501
502fn execute_structured_output(input: StructuredOutputInput) -> StructuredOutputResult {
503 StructuredOutputResult {
504 data: String::from("Structured output provided successfully"),
505 structured_output: input.0,
506 }
507}
508
509fn execute_repl(input: ReplInput) -> Result<ReplOutput, String> {
510 if input.code.trim().is_empty() {
511 return Err(String::from("code must not be empty"));
512 }
513 let timeout_ms = input.timeout_ms.unwrap_or(30_000).max(1_000);
514 let runtime = resolve_repl_runtime(&input.language)?;
515 let started = Instant::now();
516 let child = Command::new(runtime.program)
517 .args(runtime.args)
518 .arg(&input.code)
519 .stdout(std::process::Stdio::piped())
520 .stderr(std::process::Stdio::piped())
521 .spawn()
522 .map_err(|error| error.to_string())?;
523
524 let pid = child.id();
525 let timeout = Duration::from_millis(timeout_ms);
526 let (tx, rx) = std::sync::mpsc::channel();
527 std::thread::spawn(move || {
528 let _ = tx.send(child.wait_with_output());
529 });
530
531 match rx.recv_timeout(timeout) {
532 Ok(Ok(output)) => Ok(ReplOutput {
533 language: input.language,
534 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
535 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
536 exit_code: output.status.code().unwrap_or(1),
537 duration_ms: started.elapsed().as_millis(),
538 }),
539 Ok(Err(error)) => Err(error.to_string()),
540 Err(_) => {
541 kill_process(pid);
542 Ok(ReplOutput {
543 language: input.language,
544 stdout: String::new(),
545 stderr: format!("REPL execution timed out after {timeout_ms}ms"),
546 exit_code: 124,
547 duration_ms: started.elapsed().as_millis(),
548 })
549 }
550 }
551}
552
553#[derive(Debug, Clone, Copy, PartialEq, Eq)]
554enum ReplLanguage {
555 Python,
556 JavaScript,
557 Shell,
558}
559
560impl ReplLanguage {
561 fn parse(input: &str) -> Result<Self, String> {
562 match input.trim().to_ascii_lowercase().as_str() {
563 "python" | "py" => Ok(Self::Python),
564 "javascript" | "js" | "node" => Ok(Self::JavaScript),
565 "sh" | "shell" | "bash" => Ok(Self::Shell),
566 other => Err(format!("unsupported REPL language: {other}")),
567 }
568 }
569
570 fn command_candidates(self) -> &'static [&'static str] {
571 match self {
572 Self::Python => &["python3", "python"],
573 Self::JavaScript => &["node"],
574 Self::Shell => &["bash", "sh"],
575 }
576 }
577
578 fn eval_args(self) -> &'static [&'static str] {
579 match self {
580 Self::Python => &["-c"],
581 Self::JavaScript => &["-e"],
582 Self::Shell => &["-lc"],
583 }
584 }
585}
586
587struct ReplRuntime {
588 program: &'static str,
589 args: &'static [&'static str],
590}
591
592fn resolve_repl_runtime(language: &str) -> Result<ReplRuntime, String> {
593 let lang = ReplLanguage::parse(language)?;
594 let program = detect_first_command(lang.command_candidates())
595 .ok_or_else(|| format!("{language} runtime not found"))?;
596 Ok(ReplRuntime {
597 program,
598 args: lang.eval_args(),
599 })
600}
601
602fn detect_first_command(commands: &[&'static str]) -> Option<&'static str> {
603 commands
604 .iter()
605 .copied()
606 .find(|command| crate::powershell::command_exists(command))
607}
608
609fn parse_skill_description(contents: &str) -> Option<String> {
610 for line in contents.lines() {
611 if let Some(value) = line.strip_prefix("description:") {
612 let trimmed = value.trim();
613 if !trimmed.is_empty() {
614 return Some(trimmed.to_string());
615 }
616 }
617 }
618 None
619}
620
621pub(crate) fn kill_process(pid: u32) {
622 #[cfg(unix)]
623 {
624 let _ = Command::new("kill")
625 .args(["-9", &pid.to_string()])
626 .stdout(std::process::Stdio::null())
627 .stderr(std::process::Stdio::null())
628 .status();
629 }
630 #[cfg(windows)]
631 {
632 let _ = Command::new("taskkill")
633 .args(["/F", "/PID", &pid.to_string()])
634 .stdout(std::process::Stdio::null())
635 .stderr(std::process::Stdio::null())
636 .status();
637 }
638}
639
640#[cfg(test)]
641mod tests;