Skip to main content

codineer_tools/
lib.rs

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    // Allow multiple in_progress items for parallel workflows
240    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;