//! Local tools: they run on this machine in the session's working directory. use crate::server::Server; use anyhow::{Result, anyhow, bail}; use base64::Engine; use codecraft_core::permission::ToolInfo; use codecraft_core::provider::ToolDef; use codecraft_core::types::ImageData; use serde_json::{Value, json}; use std::collections::{BTreeMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::io::AsyncReadExt; use tokio_util::sync::CancellationToken; const MAX_OUTPUT: usize = 30_000; /// Built-in skills and commands (also available without a server). pub const BUILTIN: &[(&str, &str)] = &[ ("skills/cad/SKILL.md", include_str!("../../server/resources/skills/cad/SKILL.md")), ("skills/recreate/SKILL.md", include_str!("../../server/resources/skills/recreate/SKILL.md")), ("skills/recreate/templates/01-inventory.md", include_str!("../../server/resources/skills/recreate/templates/01-inventory.md")), ("skills/recreate/templates/02-overview.md", include_str!("../../server/resources/skills/recreate/templates/02-overview.md")), ("skills/recreate/templates/03-architecture.md", include_str!("../../server/resources/skills/recreate/templates/03-architecture.md")), ("skills/recreate/templates/04-file-formats.md", include_str!("../../server/resources/skills/recreate/templates/04-file-formats.md")), ("skills/recreate/templates/05-ui-and-behaviour.md", include_str!("../../server/resources/skills/recreate/templates/05-ui-and-behaviour.md")), ("skills/recreate/templates/06-assets.md", include_str!("../../server/resources/skills/recreate/templates/06-assets.md")), ("skills/recreate/templates/recreation-plan.md", include_str!("../../server/resources/skills/recreate/templates/recreation-plan.md")), ("commands/cad.md", include_str!("../../server/resources/commands/cad.md")), ("commands/recreate-init.md", include_str!("../../server/resources/commands/recreate-init.md")), ("commands/recreate-start.md", include_str!("../../server/resources/commands/recreate-start.md")), ]; pub struct ToolSpec { pub name: String, pub description: String, pub schema: Value, pub read_only: bool, pub is_edit: bool, /// Executed by the server (its MCP tools). pub remote: bool, } impl ToolInfo for ToolSpec { fn name(&self) -> &str { &self.name } fn read_only(&self) -> bool { self.read_only } fn is_edit(&self) -> bool { self.is_edit } } impl ToolSpec { pub fn def(&self) -> ToolDef { ToolDef { name: self.name.clone(), description: self.description.clone(), input_schema: self.schema.clone() } } } fn spec(name: &str, description: &str, schema: Value, read_only: bool, is_edit: bool) -> ToolSpec { ToolSpec { name: name.into(), description: description.into(), schema, read_only, is_edit, remote: false } } /// The local tool set. pub fn local_tools() -> Vec { vec![ spec("read", "Read a file (text with line numbers; images are shown to you). Use offset/limit (lines) for large files.", json!({"type":"object","properties":{"path":{"type":"string"},"offset":{"type":"integer"},"limit":{"type":"integer"}},"required":["path"]}), true, false), spec("write", "Create or overwrite a file with the given content. Read existing files first.", json!({"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}), false, true), spec("edit", "Replace an exact text in a file. old_string must match exactly (including indentation) and be unique unless replace_all is true. Read the file first.", json!({"type":"object","properties":{"path":{"type":"string"},"old_string":{"type":"string"},"new_string":{"type":"string"},"replace_all":{"type":"boolean"}},"required":["path","old_string","new_string"]}), false, true), spec("list", "List a directory (respects .gitignore).", json!({"type":"object","properties":{"path":{"type":"string"}}}), true, false), spec("glob", "Find files by glob pattern, e.g. \"**/*.rs\" (respects .gitignore).", json!({"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string"}},"required":["pattern"]}), true, false), spec("grep", "Search file contents with a regular expression (respects .gitignore). Returns file:line: text.", json!({"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string"},"glob":{"type":"string"},"ignore_case":{"type":"boolean"}},"required":["pattern"]}), true, false), spec("bash", "Run a shell command in the working directory (stdout and stderr combined). Default timeout 2 min, max 10 min (timeout_ms). For servers/watchers set run_in_background=true and read output with bash_output.", json!({"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"},"timeout_ms":{"type":"integer"},"run_in_background":{"type":"boolean"}},"required":["command"]}), false, false), spec("bash_output", "Read new output of a background shell.", json!({"type":"object","properties":{"shell_id":{"type":"string"}},"required":["shell_id"]}), true, false), spec("kill_shell", "Stop a background shell.", json!({"type":"object","properties":{"shell_id":{"type":"string"}},"required":["shell_id"]}), false, false), spec("todo_write", "Maintain a task list for the current work, shown live to the user. Send the complete list each time; exactly one item in_progress.", json!({"type":"object","properties":{"todos":{"type":"array","items":{"type":"object","properties":{"content":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}), true, false), spec("ask_user", "Ask the user one or more questions and wait for the answers. Only for decisions that are genuinely the user's. Offer 2-4 options per question.", json!({"type":"object","properties":{"questions":{"type":"array","items":{"type":"object","properties":{"question":{"type":"string"},"options":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string"},"description":{"type":"string"}},"required":["label"]}}},"required":["question"]}}},"required":["questions"]}), true, false), spec("task", "Delegate a self-contained task to a subagent (fresh context). It returns only its final report.", json!({"type":"object","properties":{"description":{"type":"string"},"prompt":{"type":"string"},"subagent_type":{"type":"string"}},"required":["description","prompt"]}), true, false), spec("skill", "Load a skill (packaged instructions for a kind of task) by name and follow it.", json!({"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}), true, false), spec("remember", "Save a lasting insight about this project (build/test commands, conventions, pitfalls, decisions) to .codecraft/memory.md – it is loaded into every future session here. Keep entries short; don't store secrets or temporary state.", json!({"type":"object","properties":{"note":{"type":"string"}},"required":["note"]}), false, true), ] } /// What tools may touch and use. pub struct Ctx { pub cwd: PathBuf, pub cancel: CancellationToken, pub read_files: Arc>>, pub shells: Arc>>>, pub server: Option, /// Server skills (name -> description), fetched on demand. pub server_skills: Vec, } pub struct Output { pub content: String, pub is_error: bool, pub images: Vec, /// Unified diff of a file change (shown in the UI). pub diff: Option, } impl Output { fn text(s: impl Into) -> Self { Self { content: s.into(), is_error: false, images: vec![], diff: None } } pub fn error(s: impl Into) -> Self { Self { content: s.into(), is_error: true, images: vec![], diff: None } } } fn arg<'a>(v: &'a Value, k: &str) -> Result<&'a str> { v[k].as_str().ok_or_else(|| anyhow!("missing argument '{k}'")) } fn truncate(s: &str, max: usize) -> String { if s.len() <= max { return s.to_string(); } let mut end = max; while !s.is_char_boundary(end) { end -= 1; } format!("{}\n… [truncated, {} more bytes]", &s[..end], s.len() - end) } pub fn resolve(cwd: &Path, p: &str) -> PathBuf { let p = p.trim(); let raw = if Path::new(p).is_absolute() { PathBuf::from(p) } else if let Some(rest) = p.strip_prefix("~/") { crate::conf::home().join(rest) } else { cwd.join(p) }; let mut out = PathBuf::new(); for c in raw.components() { match c { std::path::Component::ParentDir => { out.pop(); } std::path::Component::CurDir => {} other => out.push(other), } } out } pub fn unified_diff(path: &str, old: &str, new: &str) -> String { similar::TextDiff::from_lines(old, new).unified_diff().context_radius(3).header(&format!("a/{path}"), &format!("b/{path}")).to_string() } fn image_type(p: &Path) -> Option<&'static str> { match p.extension()?.to_str()?.to_ascii_lowercase().as_str() { "png" => Some("image/png"), "jpg" | "jpeg" => Some("image/jpeg"), "gif" => Some("image/gif"), "webp" => Some("image/webp"), _ => None, } } pub fn apply_edit(old: &str, input: &Value) -> Result { let from = arg(input, "old_string")?; let to = arg(input, "new_string")?; if from.is_empty() { bail!("old_string must not be empty (use write to create files)"); } let n = old.matches(from).count(); if n == 0 { bail!("old_string not found in the file – read it again and copy the exact text"); } if n > 1 && !input["replace_all"].as_bool().unwrap_or(false) { bail!("old_string occurs {n} times – add more context or set replace_all"); } Ok(if input["replace_all"].as_bool().unwrap_or(false) { old.replace(from, to) } else { old.replacen(from, to, 1) }) } /// The proposed change of a write/edit as a diff (for the permission dialog). pub fn preview(name: &str, input: &Value, cwd: &Path) -> Option { let path = resolve(cwd, input["path"].as_str()?); let old = std::fs::read_to_string(&path).unwrap_or_default(); let new = match name { "write" => input["content"].as_str()?.to_string(), "edit" => apply_edit(&old, input).ok()?, _ => return None, }; Some(unified_diff(&rel(cwd, &path), &old, &new)) } pub fn rel(cwd: &Path, p: &Path) -> String { p.strip_prefix(cwd).map(|r| r.display().to_string()).unwrap_or_else(|_| p.display().to_string()) } // --- background shells --------------------------------------------------------------- pub struct Shell { #[allow(dead_code)] pub command: String, output: Mutex, read_pos: Mutex, child: Mutex>, pub done: Mutex>, } fn shell_cmd(cwd: &Path, command: &str) -> tokio::process::Command { #[cfg(windows)] let mut c = { // Git Bash if available, else PowerShell. let bash = std::env::var("CODECRAFT_SHELL").ok().or_else(|| which("bash")); match bash { Some(b) => { let mut c = tokio::process::Command::new(b); c.arg("-c").arg(format!("exec 2>&1\n{command}")); c } None => { let mut c = tokio::process::Command::new("powershell"); c.args(["-NoProfile", "-Command", &format!("& {{ {command} }} 2>&1")]); c } } }; #[cfg(not(windows))] let mut c = { let shell = std::env::var("CODECRAFT_SHELL").unwrap_or_else(|_| "bash".into()); let mut c = tokio::process::Command::new(shell); c.arg("-c").arg(format!("exec 2>&1\n{command}")); c }; c.current_dir(cwd).env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat"); c.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()).kill_on_drop(true); #[cfg(unix)] c.process_group(0); c } #[cfg(windows)] fn which(name: &str) -> Option { std::env::var_os("PATH").and_then(|p| std::env::split_paths(&p).map(|d| d.join(format!("{name}.exe"))).find(|p| p.is_file()).map(|p| p.display().to_string())) } fn kill_tree(child: &tokio::process::Child) { #[cfg(unix)] if let Some(pid) = child.id() { unsafe extern "C" { fn kill(pid: i32, sig: i32) -> i32; } // SAFETY: plain syscall; negative pid = process group. unsafe { kill(-(pid as i32), 9); } } #[cfg(not(unix))] let _ = child; } async fn run_bash(ctx: &Ctx, input: &Value) -> Result { let command = arg(input, "command")?; let timeout = Duration::from_millis(input["timeout_ms"].as_u64().unwrap_or(120_000).min(600_000)); let mut cmd = shell_cmd(&ctx.cwd, command); if input["run_in_background"].as_bool().unwrap_or(false) { let mut child = cmd.spawn()?; let mut stdout = child.stdout.take().unwrap(); let id = format!("sh{}", ctx.shells.lock().unwrap().len() + 1); let shell = Arc::new(Shell { command: command.into(), output: Mutex::default(), read_pos: Mutex::new(0), child: Mutex::new(Some(child)), done: Mutex::new(None) }); ctx.shells.lock().unwrap().insert(id.clone(), shell.clone()); tokio::spawn(async move { let mut buf = vec![0u8; 8192]; loop { match stdout.read(&mut buf).await { Ok(0) | Err(_) => break, Ok(n) => { let mut out = shell.output.lock().unwrap(); out.push_str(&String::from_utf8_lossy(&buf[..n])); if out.len() > 2_000_000 { let cut = out.len() - 1_000_000; let mut c = cut; while !out.is_char_boundary(c) { c += 1; } out.drain(..c); *shell.read_pos.lock().unwrap() = 0; } } } } let child = shell.child.lock().unwrap().take(); if let Some(mut c) = child { let code = c.wait().await.ok().and_then(|s| s.code()).unwrap_or(-1); *shell.done.lock().unwrap() = Some(code); } }); return Ok(Output::text(format!("Started in the background with shell id {id}. Read its output with bash_output, stop it with kill_shell."))); } let mut child = cmd.spawn()?; let mut stdout = child.stdout.take().unwrap(); let mut out = Vec::new(); let read = async { let _ = stdout.read_to_end(&mut out).await; }; let status = tokio::select! { _ = read => child.wait().await.ok(), _ = tokio::time::sleep(timeout) => { kill_tree(&child); let _ = child.kill().await; return Ok(Output::error(format!("[command timed out after {}s and was killed]", timeout.as_secs()))); } _ = ctx.cancel.cancelled() => { kill_tree(&child); let _ = child.kill().await; return Ok(Output::error("[command aborted by user]")); } }; let code = status.and_then(|s| s.code()).unwrap_or(-1); let mut text = truncate(String::from_utf8_lossy(&out).trim_end(), MAX_OUTPUT); if text.is_empty() { text = "(no output)".into(); } if code != 0 { text.push_str(&format!("\n[exit code {code}]")); } Ok(Output { content: text, is_error: code != 0, images: vec![], diff: None }) } // --- skills ------------------------------------------------------------------------------ /// Local skills of the project (.codecraft/skills, .claude/skills): name -> SKILL.md path. pub fn project_skills(cwd: &Path) -> BTreeMap { let mut out = BTreeMap::new(); for base in [".claude", ".codecraft"] { let dir = cwd.join(base).join("skills"); let Ok(entries) = std::fs::read_dir(&dir) else { continue }; for e in entries.flatten() { let p = e.path().join("SKILL.md"); if p.is_file() { out.insert(e.file_name().to_string_lossy().to_string(), p); } } } out } pub fn builtin_skill_names() -> Vec<&'static str> { BUILTIN.iter().filter_map(|(p, _)| p.strip_prefix("skills/").and_then(|r| r.strip_suffix("/SKILL.md"))).collect() } fn skill_cache(name: &str) -> PathBuf { crate::conf::data_dir().join("skills").join(name) } async fn load_skill(ctx: &Ctx, name: &str) -> Result { // Project skills win, then the server's, then the built-in ones. if let Some(p) = project_skills(&ctx.cwd).get(name) { let text = std::fs::read_to_string(p)?; let dir = p.parent().unwrap_or(&ctx.cwd); return Ok(Output::text(format!("{text}\n\n(Skill files are in {})", dir.display()))); } if let Some(server) = ctx.server.as_ref().filter(|_| ctx.server_skills.iter().any(|s| s == name)) { let v = server.get(&format!("/cli/skill/{name}")).await?; let dir = skill_cache(name); for f in v["files"].as_array().into_iter().flatten() { let rel = f["path"].as_str().unwrap_or(""); if rel.contains("..") { continue; } let p = dir.join(rel); std::fs::create_dir_all(p.parent().unwrap())?; std::fs::write(&p, f["content"].as_str().unwrap_or(""))?; } return Ok(Output::text(format!("{}\n\n(Skill files are in {})", v["content"].as_str().unwrap_or(""), dir.display()))); } let prefix = format!("skills/{name}/"); let files: Vec<&(&str, &str)> = BUILTIN.iter().filter(|(p, _)| p.starts_with(&prefix)).collect(); if files.is_empty() { bail!("unknown skill '{name}'"); } let dir = skill_cache(name); let mut main = String::new(); for (p, content) in files { let rel = &p[prefix.len()..]; if rel == "SKILL.md" { main = content.to_string(); } let target = dir.join(rel); std::fs::create_dir_all(target.parent().unwrap())?; std::fs::write(&target, content)?; } Ok(Output::text(format!("{main}\n\n(Skill files are in {})", dir.display()))) } // --- dispatcher ----------------------------------------------------------------------------- /// Run a local tool (ask_user and task are handled by the agent loop). pub async fn run(ctx: &Ctx, name: &str, input: &Value) -> Result { match name { "read" => { let path = resolve(&ctx.cwd, arg(input, "path")?); if path.is_dir() { bail!("{} is a directory – use list", path.display()); } let bytes = tokio::fs::read(&path).await.map_err(|e| anyhow!("{}: {e}", path.display()))?; ctx.read_files.lock().unwrap().insert(path.clone()); if let Some(mt) = image_type(&path) { return Ok(Output { content: format!("Image {} ({} bytes)", rel(&ctx.cwd, &path), bytes.len()), is_error: false, images: vec![ImageData { media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) }], diff: None, }); } if bytes.iter().take(8000).any(|b| *b == 0) { return Ok(Output::text(format!("{} is a binary file ({} bytes)", path.display(), bytes.len()))); } let text = String::from_utf8_lossy(&bytes); let offset = input["offset"].as_u64().unwrap_or(1).max(1) as usize; let limit = input["limit"].as_u64().unwrap_or(2000) as usize; let mut out = String::new(); let total = text.lines().count(); for (i, l) in text.lines().enumerate().skip(offset - 1).take(limit) { out.push_str(&format!("{:>6}\t{}\n", i + 1, if l.len() > 2000 { &l[..l.char_indices().nth(2000).map(|(i, _)| i).unwrap_or(l.len())] } else { l })); } if offset - 1 + limit < total { out.push_str(&format!("… ({} more lines, use offset)\n", total - (offset - 1 + limit))); } Ok(Output::text(if out.is_empty() { "(file is empty)".into() } else { truncate(&out, MAX_OUTPUT * 2) })) } "write" => { let path = resolve(&ctx.cwd, arg(input, "path")?); let content = arg(input, "content")?; let old = if path.exists() { if !ctx.read_files.lock().unwrap().contains(&path) { return Ok(Output::error("File exists but has not been read in this session. Read it first.")); } std::fs::read_to_string(&path).unwrap_or_default() } else { String::new() }; if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; } std::fs::write(&path, content)?; ctx.read_files.lock().unwrap().insert(path.clone()); let diff = unified_diff(&rel(&ctx.cwd, &path), &old, content); Ok(Output { content: format!("Wrote {} ({} lines)", rel(&ctx.cwd, &path), content.lines().count()), is_error: false, images: vec![], diff: Some(diff) }) } "edit" => { let path = resolve(&ctx.cwd, arg(input, "path")?); if !ctx.read_files.lock().unwrap().contains(&path) { return Ok(Output::error("File has not been read in this session. Read it first.")); } let old = std::fs::read_to_string(&path)?; let new = match apply_edit(&old, input) { Ok(n) => n, Err(e) => return Ok(Output::error(e.to_string())), }; std::fs::write(&path, &new)?; let diff = unified_diff(&rel(&ctx.cwd, &path), &old, &new); Ok(Output { content: format!("Edited {}", rel(&ctx.cwd, &path)), is_error: false, images: vec![], diff: Some(diff) }) } "list" => { let path = resolve(&ctx.cwd, input["path"].as_str().unwrap_or(".")); let mut entries: Vec = Vec::new(); for e in ignore::WalkBuilder::new(&path).max_depth(Some(1)).hidden(false).build().flatten() { if e.depth() == 0 || e.file_name() == ".git" { continue; } let dir = e.file_type().is_some_and(|t| t.is_dir()); entries.push(format!("{}{}", e.file_name().to_string_lossy(), if dir { "/" } else { "" })); } entries.sort(); Ok(Output::text(if entries.is_empty() { "(empty)".into() } else { entries.join("\n") })) } "glob" => { let base = resolve(&ctx.cwd, input["path"].as_str().unwrap_or(".")); let matcher = globset::Glob::new(arg(input, "pattern")?)?.compile_matcher(); let mut hits = Vec::new(); for e in ignore::WalkBuilder::new(&base).hidden(false).build().flatten() { if e.file_type().is_some_and(|t| t.is_file()) { let r = e.path().strip_prefix(&base).unwrap_or(e.path()); if matcher.is_match(r) { hits.push(rel(&ctx.cwd, e.path())); if hits.len() >= 500 { break; } } } } Ok(Output::text(if hits.is_empty() { "No files found.".into() } else { hits.join("\n") })) } "grep" => { let base = resolve(&ctx.cwd, input["path"].as_str().unwrap_or(".")); let re = regex::RegexBuilder::new(arg(input, "pattern")?).case_insensitive(input["ignore_case"].as_bool().unwrap_or(false)).build()?; let filter = input["glob"].as_str().map(|g| globset::Glob::new(g).map(|g| g.compile_matcher())).transpose()?; let mut out = String::new(); let mut n = 0; 'files: for e in ignore::WalkBuilder::new(&base).hidden(false).build().flatten() { if !e.file_type().is_some_and(|t| t.is_file()) { continue; } if let Some(f) = &filter { if !f.is_match(e.file_name()) && !f.is_match(e.path().strip_prefix(&base).unwrap_or(e.path())) { continue; } } let Ok(text) = std::fs::read_to_string(e.path()) else { continue }; for (i, line) in text.lines().enumerate() { if re.is_match(line) { out.push_str(&format!("{}:{}: {}\n", rel(&ctx.cwd, e.path()), i + 1, line.chars().take(300).collect::())); n += 1; if n >= 300 { out.push_str("… (more matches – narrow the search)\n"); break 'files; } } } } Ok(Output::text(if out.is_empty() { "No matches.".into() } else { out })) } "bash" => run_bash(ctx, input).await, "bash_output" => { let id = arg(input, "shell_id")?; let shell = ctx.shells.lock().unwrap().get(id).cloned().ok_or_else(|| anyhow!("no shell {id}"))?; let out = shell.output.lock().unwrap(); let mut pos = shell.read_pos.lock().unwrap(); let new = out[(*pos).min(out.len())..].to_string(); *pos = out.len(); let state = match *shell.done.lock().unwrap() { Some(c) => format!("[exited with code {c}]"), None => "[running]".into(), }; Ok(Output::text(format!("{}\n{state}", truncate(&new, MAX_OUTPUT)))) } "kill_shell" => { let id = arg(input, "shell_id")?; let shell = ctx.shells.lock().unwrap().get(id).cloned().ok_or_else(|| anyhow!("no shell {id}"))?; if let Some(c) = shell.child.lock().unwrap().as_ref() { kill_tree(c); } if let Some(mut c) = shell.child.lock().unwrap().take() { let _ = c.start_kill(); } Ok(Output::text(format!("Shell {id} stopped."))) } "todo_write" => { let todos = input["todos"].as_array().cloned().unwrap_or_default(); let open = todos.iter().filter(|t| t["status"] != "completed").count(); Ok(Output::text(format!("Todo list updated ({open} open)."))) } "skill" => load_skill(ctx, arg(input, "name")?).await, "remember" => { let note = arg(input, "note")?.trim().replace('\n', " "); let path = ctx.cwd.join(".codecraft").join("memory.md"); std::fs::create_dir_all(path.parent().unwrap())?; let mut text = std::fs::read_to_string(&path).unwrap_or_else(|_| "# Project memory\n\nLearned facts about this project (maintained by CodeCraft; edit freely).\n\n".into()); if !text.ends_with('\n') { text.push('\n'); } text.push_str(&format!("- {note}\n")); std::fs::write(&path, &text)?; Ok(Output::text(format!("Saved to .codecraft/memory.md: {note}"))) } other => bail!("unknown tool '{other}'"), } }