Files
codecraft/cli/src/tools.rs
T
Richard ZinkandClaude Opus 5.5 e8e455b779 Store chat images separately; JPEG for large PNGs; gzip/brotli responses
Images of messages (pasted images, screenshots, generated images) were base64
inside the message JSON, so opening a chat downloaded all of them at once
(66 MB for a chat with 120 screenshots). They now live in a `blobs` table,
addressed by SHA-256; messages keep a reference (blob, size). The UI loads
them from /api/blobs/<hash> lazily with an immutable cache header, the runner
fills the bytes in before model requests, JSON exports stay self-contained.

- Store::add_message moves inline images out; large opaque PNGs become JPEG
  (q85) when smaller. Older chats are converted once at start-up in the
  background, unused images are removed (also after deleting a chat) and the
  database is vacuumed when that frees 32 MB or more.
- Responses are compressed with gzip/brotli (not images, SSE streams or
  binary downloads).

Example (copy of production): chat load 66 MB -> 1.5 MB (370 KB brotli),
database 90 MB -> 27 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:21:24 +01:00

569 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 { blob: None, size: None, 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}'"),
}
}