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    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;