- codecraft: full-screen terminal client (ratatui) – local tools and files, models via the CodeCraft server gateway or own providers (standalone with a guided setup), slash commands, @files, permission dialogs, resume, update - Static Linux CLI builds (x86_64/aarch64, cargo-zigbuild) served by the server: /cli/install.sh and /cli/download/<target> - Project memory (remember tool, .codecraft/memory.md) and personal memory - Model fallback when a host is unreachable or a budget is used up - Version/commit in UI and health, daily release check, "What's new" dialog - PWA: install button, maskable icon, manifest id and screenshots - Case-insensitive detection of connection errors for retries Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
569 lines
27 KiB
Rust
569 lines
27 KiB
Rust
//! 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<ToolSpec> {
|
||
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<Mutex<HashSet<PathBuf>>>,
|
||
pub shells: Arc<Mutex<BTreeMap<String, Arc<Shell>>>>,
|
||
pub server: Option<Server>,
|
||
/// Server skills (name -> description), fetched on demand.
|
||
pub server_skills: Vec<String>,
|
||
}
|
||
|
||
pub struct Output {
|
||
pub content: String,
|
||
pub is_error: bool,
|
||
pub images: Vec<ImageData>,
|
||
/// Unified diff of a file change (shown in the UI).
|
||
pub diff: Option<String>,
|
||
}
|
||
|
||
impl Output {
|
||
fn text(s: impl Into<String>) -> Self {
|
||
Self { content: s.into(), is_error: false, images: vec![], diff: None }
|
||
}
|
||
pub fn error(s: impl Into<String>) -> 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<String> {
|
||
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<String> {
|
||
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<String>,
|
||
read_pos: Mutex<usize>,
|
||
child: Mutex<Option<tokio::process::Child>>,
|
||
pub done: Mutex<Option<i32>>,
|
||
}
|
||
|
||
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<String> {
|
||
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<Output> {
|
||
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<String, PathBuf> {
|
||
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<Output> {
|
||
// 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<Output> {
|
||
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<String> = 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::<String>()));
|
||
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}'"),
|
||
}
|
||
}
|