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 if requested.contains("..") || requested.contains('/') || requested.contains('\\') {
264 return Err(format!(
265 "invalid skill name '{requested}': must not contain path separators or '..'"
266 ));
267 }
268
269 let mut candidates = Vec::new();
270 if let Ok(cwd) = std::env::current_dir() {
271 candidates.push(cwd.join(".codineer").join("skills"));
272 }
273 if let Ok(codineer_home) = std::env::var("CODINEER_CONFIG_HOME") {
274 candidates.push(std::path::PathBuf::from(codineer_home).join("skills"));
275 }
276 if let Some(home) = runtime::home_dir() {
277 candidates.push(home.join(".codineer").join("skills"));
278 candidates.push(home.join(".agents").join("skills"));
279 }
280
281 for root in candidates {
282 let direct = root.join(requested).join("SKILL.md");
283 if direct.exists() {
284 return Ok(direct);
285 }
286
287 if let Ok(entries) = std::fs::read_dir(&root) {
288 for entry in entries.flatten() {
289 let path = entry.path().join("SKILL.md");
290 if !path.exists() {
291 continue;
292 }
293 if entry
294 .file_name()
295 .to_string_lossy()
296 .eq_ignore_ascii_case(requested)
297 {
298 return Ok(path);
299 }
300 }
301 }
302 }
303
304 Err(format!("unknown skill: {requested}"))
305}
306
307fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput {
308 execute_tool_search_with_context(input, None)
309}
310
311pub fn execute_tool_search_with_context(
312 input: ToolSearchInput,
313 pending_mcp_servers: Option<Vec<String>>,
314) -> ToolSearchOutput {
315 let deferred = deferred_tool_specs();
316 let max_results = input.max_results.unwrap_or(5).max(1);
317 let query = input.query.trim().to_string();
318 let normalized_query = normalize_tool_search_query(&query);
319 let matches = search_tool_specs(&query, max_results, &deferred);
320
321 ToolSearchOutput {
322 matches,
323 query,
324 normalized_query,
325 total_deferred_tools: deferred.len(),
326 pending_mcp_servers: pending_mcp_servers.filter(|servers| !servers.is_empty()),
327 }
328}
329
330fn deferred_tool_specs() -> Vec<ToolSpec> {
331 mvp_tool_specs()
332 .into_iter()
333 .filter(|spec| {
334 !matches!(
335 spec.name,
336 "bash" | "read_file" | "write_file" | "edit_file" | "glob_search" | "grep_search"
337 )
338 })
339 .collect()
340}
341
342fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec<String> {
343 if query.trim().is_empty() {
344 return Vec::new();
345 }
346 let lowered = query.to_lowercase();
347 if let Some(selection) = lowered.strip_prefix("select:") {
348 return selection
349 .split(',')
350 .map(str::trim)
351 .filter(|part| !part.is_empty())
352 .filter_map(|wanted| {
353 let wanted = canonical_tool_token(wanted);
354 specs
355 .iter()
356 .find(|spec| canonical_tool_token(spec.name) == wanted)
357 .map(|spec| spec.name.to_string())
358 })
359 .take(max_results)
360 .collect();
361 }
362
363 let mut required = Vec::new();
364 let mut optional = Vec::new();
365 for term in lowered.split_whitespace() {
366 if let Some(rest) = term.strip_prefix('+') {
367 if !rest.is_empty() {
368 required.push(rest);
369 }
370 } else {
371 optional.push(term);
372 }
373 }
374 let terms = if required.is_empty() {
375 optional.clone()
376 } else {
377 required.iter().chain(optional.iter()).copied().collect()
378 };
379
380 let mut scored = specs
381 .iter()
382 .filter_map(|spec| {
383 let name = spec.name.to_lowercase();
384 let canonical_name = canonical_tool_token(spec.name);
385 let normalized_description = normalize_tool_search_query(spec.description);
386 let haystack = format!(
387 "{name} {} {canonical_name}",
388 spec.description.to_lowercase()
389 );
390 let normalized_haystack = format!("{canonical_name} {normalized_description}");
391 if required.iter().any(|term| !haystack.contains(term)) {
392 return None;
393 }
394
395 let mut score = 0_i32;
396 for term in &terms {
397 let canonical_term = canonical_tool_token(term);
398 if haystack.contains(term) {
399 score += 2;
400 }
401 if name == *term {
402 score += 8;
403 }
404 if name.contains(term) {
405 score += 4;
406 }
407 if canonical_name == canonical_term {
408 score += 12;
409 }
410 if normalized_haystack.contains(&canonical_term) {
411 score += 3;
412 }
413 }
414
415 if score == 0 && !lowered.is_empty() {
416 return None;
417 }
418 Some((score, spec.name.to_string()))
419 })
420 .collect::<Vec<_>>();
421
422 scored.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
423 scored
424 .into_iter()
425 .map(|(_, name)| name)
426 .take(max_results)
427 .collect()
428}
429
430fn normalize_tool_search_query(query: &str) -> String {
431 query
432 .trim()
433 .split(|ch: char| ch.is_whitespace() || ch == ',')
434 .filter(|term| !term.is_empty())
435 .map(canonical_tool_token)
436 .collect::<Vec<_>>()
437 .join(" ")
438}
439
440pub(crate) fn canonical_tool_token(value: &str) -> String {
441 let mut canonical = value
442 .chars()
443 .filter(char::is_ascii_alphanumeric)
444 .flat_map(char::to_lowercase)
445 .collect::<String>();
446 if let Some(stripped) = canonical.strip_suffix("tool") {
447 canonical = stripped.to_string();
448 }
449 canonical
450}
451
452#[cfg(test)]
453pub(crate) const MAX_SLEEP_MS: u64 = 5 * 60 * 1000;
454#[cfg(not(test))]
455const MAX_SLEEP_MS: u64 = 5 * 60 * 1000;
456
457#[cfg(test)]
458pub(crate) fn clamp_sleep(requested_ms: u64) -> (u64, String) {
459 clamp_sleep_inner(requested_ms)
460}
461
462fn clamp_sleep_inner(requested_ms: u64) -> (u64, String) {
463 let clamped = requested_ms.min(MAX_SLEEP_MS);
464 let message = if clamped < requested_ms {
465 format!("Slept for {clamped}ms (clamped from {requested_ms}ms)")
466 } else {
467 format!("Slept for {clamped}ms")
468 };
469 (clamped, message)
470}
471
472#[allow(clippy::needless_pass_by_value)]
473fn execute_sleep(input: SleepInput) -> SleepOutput {
474 let (duration_ms, message) = clamp_sleep_inner(input.duration_ms);
475 std::thread::sleep(Duration::from_millis(duration_ms));
476 SleepOutput {
477 duration_ms,
478 message,
479 }
480}
481
482fn execute_brief(input: BriefInput) -> Result<BriefOutput, String> {
483 if input.message.trim().is_empty() {
484 return Err(String::from("message must not be empty"));
485 }
486
487 let attachments = input
488 .attachments
489 .as_ref()
490 .map(|paths| {
491 paths
492 .iter()
493 .map(|path| resolve_attachment(path))
494 .collect::<Result<Vec<_>, String>>()
495 })
496 .transpose()?;
497
498 let message = match input.status {
499 BriefStatus::Normal | BriefStatus::Proactive => input.message,
500 };
501
502 Ok(BriefOutput {
503 message,
504 attachments,
505 sent_at: crate::config_tool::iso8601_timestamp(),
506 })
507}
508
509fn resolve_attachment(path: &str) -> Result<ResolvedAttachment, String> {
510 let resolved = std::fs::canonicalize(path).map_err(|error| error.to_string())?;
511 let metadata = std::fs::metadata(&resolved).map_err(|error| error.to_string())?;
512 Ok(ResolvedAttachment {
513 path: resolved.display().to_string(),
514 size: metadata.len(),
515 is_image: is_image_path(&resolved),
516 })
517}
518
519fn is_image_path(path: &Path) -> bool {
520 matches!(
521 path.extension()
522 .and_then(|ext| ext.to_str())
523 .map(str::to_ascii_lowercase)
524 .as_deref(),
525 Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg")
526 )
527}
528
529fn execute_structured_output(input: StructuredOutputInput) -> StructuredOutputResult {
530 StructuredOutputResult {
531 data: String::from("Structured output provided successfully"),
532 structured_output: input.0,
533 }
534}
535
536fn execute_repl(input: ReplInput) -> Result<ReplOutput, String> {
537 if input.code.trim().is_empty() {
538 return Err(String::from("code must not be empty"));
539 }
540 let timeout_ms = input.timeout_ms.unwrap_or(30_000).max(1_000);
541 let runtime = resolve_repl_runtime(&input.language)?;
542 let started = Instant::now();
543 let child = Command::new(runtime.program)
544 .args(runtime.args)
545 .arg(&input.code)
546 .stdout(std::process::Stdio::piped())
547 .stderr(std::process::Stdio::piped())
548 .spawn()
549 .map_err(|error| error.to_string())?;
550
551 let pid = child.id();
552 let timeout = Duration::from_millis(timeout_ms);
553 let (tx, rx) = std::sync::mpsc::channel();
554 std::thread::spawn(move || {
555 let _ = tx.send(child.wait_with_output());
556 });
557
558 match rx.recv_timeout(timeout) {
559 Ok(Ok(output)) => Ok(ReplOutput {
560 language: input.language,
561 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
562 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
563 exit_code: output.status.code().unwrap_or(1),
564 duration_ms: started.elapsed().as_millis(),
565 }),
566 Ok(Err(error)) => Err(error.to_string()),
567 Err(_) => {
568 kill_process(pid);
569 Ok(ReplOutput {
570 language: input.language,
571 stdout: String::new(),
572 stderr: format!("REPL execution timed out after {timeout_ms}ms"),
573 exit_code: 124,
574 duration_ms: started.elapsed().as_millis(),
575 })
576 }
577 }
578}
579
580#[derive(Debug, Clone, Copy, PartialEq, Eq)]
581enum ReplLanguage {
582 Python,
583 JavaScript,
584 Shell,
585}
586
587impl ReplLanguage {
588 fn parse(input: &str) -> Result<Self, String> {
589 match input.trim().to_ascii_lowercase().as_str() {
590 "python" | "py" => Ok(Self::Python),
591 "javascript" | "js" | "node" => Ok(Self::JavaScript),
592 "sh" | "shell" | "bash" => Ok(Self::Shell),
593 other => Err(format!("unsupported REPL language: {other}")),
594 }
595 }
596
597 fn command_candidates(self) -> &'static [&'static str] {
598 match self {
599 Self::Python => &["python3", "python"],
600 Self::JavaScript => &["node"],
601 Self::Shell => &["bash", "sh"],
602 }
603 }
604
605 fn eval_args(self) -> &'static [&'static str] {
606 match self {
607 Self::Python => &["-c"],
608 Self::JavaScript => &["-e"],
609 Self::Shell => &["-lc"],
610 }
611 }
612}
613
614struct ReplRuntime {
615 program: &'static str,
616 args: &'static [&'static str],
617}
618
619fn resolve_repl_runtime(language: &str) -> Result<ReplRuntime, String> {
620 let lang = ReplLanguage::parse(language)?;
621 let program = detect_first_command(lang.command_candidates())
622 .ok_or_else(|| format!("{language} runtime not found"))?;
623 Ok(ReplRuntime {
624 program,
625 args: lang.eval_args(),
626 })
627}
628
629fn detect_first_command(commands: &[&'static str]) -> Option<&'static str> {
630 commands
631 .iter()
632 .copied()
633 .find(|command| crate::powershell::command_exists(command))
634}
635
636fn parse_skill_description(contents: &str) -> Option<String> {
637 for line in contents.lines() {
638 if let Some(value) = line.strip_prefix("description:") {
639 let trimmed = value.trim();
640 if !trimmed.is_empty() {
641 return Some(trimmed.to_string());
642 }
643 }
644 }
645 None
646}
647
648pub(crate) fn kill_process(pid: u32) {
649 #[cfg(unix)]
650 {
651 let _ = Command::new("kill")
652 .args(["-9", &pid.to_string()])
653 .stdout(std::process::Stdio::null())
654 .stderr(std::process::Stdio::null())
655 .status();
656 }
657 #[cfg(windows)]
658 {
659 let _ = Command::new("taskkill")
660 .args(["/F", "/PID", &pid.to_string()])
661 .stdout(std::process::Stdio::null())
662 .stderr(std::process::Stdio::null())
663 .status();
664 }
665}
666
667#[cfg(test)]
668mod tests;