Initial CodeCraft: web-based coding harness for AI models

Rust backend (axum/tokio): agent loop with streaming, tools (fs, bash,
web_fetch, todo, ask_user, subagents, skills, craft snapshots/compare),
permissions with modes and rules, Anthropic + OpenAI-compatible providers,
MCP client (stdio/HTTP, elicitation), SQLite sessions, usage statistics,
compaction, commented TOML config editable via toml_edit.

React PWA: chat with live streaming, permission/question cards, modes
(build/plan/craft), settings for providers/models/prices/MCP/permissions,
raw config editor, statistics dashboard, i18n (en/de). Docker setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 14:39:24 +01:00
commit f3a2fe3816
62 files changed
+21835

No files matched your search

+477
View File
@@ -0,0 +1,477 @@
//! File system tools: read, write, edit, list, glob, grep.
use super::{Tool, ToolCtx, ToolOutput, arg_str, truncate};
use crate::types::ImageData;
use anyhow::{Result, bail};
use async_trait::async_trait;
use base64::Engine;
use serde_json::{Value, json};
use std::path::Path;
const MAX_LINES: usize = 2000;
const MAX_LINE_LEN: usize = 2000;
const MAX_OUTPUT: usize = 60_000;
fn image_type(path: &Path) -> Option<&'static str> {
match path.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 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 rel(ctx: &ToolCtx, p: &Path) -> String {
p.strip_prefix(&ctx.cwd).unwrap_or(p).display().to_string()
}
// ---------------------------------------------------------------------------
pub struct Read;
#[async_trait]
impl Tool for Read {
fn name(&self) -> &str {
"read"
}
fn description(&self) -> String {
"Read a file. Returns numbered lines (cat -n style). Use offset/limit for large files \
(default: first 2000 lines). Images (png, jpg, gif, webp) are returned as images. \
Always read a file before editing or overwriting it."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path, absolute or relative to the working directory"},
"offset": {"type": "integer", "description": "1-based line to start from"},
"limit": {"type": "integer", "description": "Number of lines to read"}
},
"required": ["path"]
})
}
fn read_only(&self) -> bool {
true
}
fn title(&self, input: &Value) -> String {
format!("Read {}", input["path"].as_str().unwrap_or("?"))
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let path = ctx.resolve(arg_str(&input, "path")?)?;
if path.is_dir() {
bail!("{} is a directory – use the list tool", path.display());
}
let bytes = tokio::fs::read(&path).await?;
ctx.app.mark_read(&ctx.session_id, &path);
if let Some(mt) = image_type(&path) {
return Ok(ToolOutput {
content: format!("Image {} ({} bytes)", rel(ctx, &path), bytes.len()),
images: vec![ImageData {
media_type: mt.into(),
data: base64::engine::general_purpose::STANDARD.encode(&bytes),
}],
..Default::default()
});
}
if bytes.iter().take(8000).any(|b| *b == 0) {
return Ok(ToolOutput::text(format!("{} is a binary file ({} bytes)", path.display(), bytes.len())));
}
let text = String::from_utf8_lossy(&bytes);
if text.is_empty() {
return Ok(ToolOutput::text("(file is empty)"));
}
let offset = input["offset"].as_u64().unwrap_or(1).max(1) as usize;
let limit = input["limit"].as_u64().map(|l| l as usize).unwrap_or(MAX_LINES);
let total = text.lines().count();
let mut out = String::new();
for (i, line) in text.lines().enumerate().skip(offset - 1).take(limit) {
let line = if line.len() > MAX_LINE_LEN {
let mut cut = MAX_LINE_LEN;
while !line.is_char_boundary(cut) {
cut -= 1;
}
format!("{}…", &line[..cut])
} else {
line.to_string()
};
out.push_str(&format!("{:>6}\t{}\n", i + 1, line));
}
let shown_end = (offset - 1 + limit).min(total);
if shown_end < total {
out.push_str(&format!("\n[showing lines {offset}-{shown_end} of {total}; use offset to read more]\n"));
}
Ok(ToolOutput::text(truncate(&out, MAX_OUTPUT)))
}
}
// ---------------------------------------------------------------------------
pub struct Write;
#[async_trait]
impl Tool for Write {
fn name(&self) -> &str {
"write"
}
fn description(&self) -> String {
"Create or overwrite a file with the given content. Existing files must be read first. \
Prefer the edit tool for changes to existing files."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"}
},
"required": ["path", "content"]
})
}
fn is_edit(&self) -> bool {
true
}
fn title(&self, input: &Value) -> String {
format!("Write {}", input["path"].as_str().unwrap_or("?"))
}
async fn preview(&self, input: &Value, ctx: &ToolCtx) -> Option<String> {
let path = ctx.resolve(input["path"].as_str()?).ok()?;
let old = tokio::fs::read_to_string(&path).await.unwrap_or_default();
Some(unified_diff(&rel(ctx, &path), &old, input["content"].as_str()?))
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let path = ctx.resolve(arg_str(&input, "path")?)?;
let content = arg_str(&input, "content")?;
let old = if path.exists() {
if !ctx.app.was_read(&ctx.session_id, &path) {
return Ok(ToolOutput::error("File exists but has not been read in this session. Read it first."));
}
tokio::fs::read_to_string(&path).await.unwrap_or_default()
} else {
String::new()
};
if let Some(dir) = path.parent() {
tokio::fs::create_dir_all(dir).await?;
}
tokio::fs::write(&path, content).await?;
ctx.app.mark_read(&ctx.session_id, &path);
let diff = unified_diff(&rel(ctx, &path), &old, content);
Ok(ToolOutput::text(format!("Wrote {} ({} lines)", path.display(), content.lines().count()))
.with_meta(json!({"diff": diff, "path": path})))
}
}
// ---------------------------------------------------------------------------
pub struct Edit;
#[async_trait]
impl Tool for Edit {
fn name(&self) -> &str {
"edit"
}
fn description(&self) -> String {
"Replace text in a file. old_string must match the file exactly (including indentation) \
and be unique unless replace_all is true. The file must have been read first."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {"type": "string"},
"old_string": {"type": "string", "description": "Exact text to replace"},
"new_string": {"type": "string", "description": "Replacement text"},
"replace_all": {"type": "boolean", "description": "Replace every occurrence"}
},
"required": ["path", "old_string", "new_string"]
})
}
fn is_edit(&self) -> bool {
true
}
fn title(&self, input: &Value) -> String {
format!("Edit {}", input["path"].as_str().unwrap_or("?"))
}
async fn preview(&self, input: &Value, ctx: &ToolCtx) -> Option<String> {
let path = ctx.resolve(input["path"].as_str()?).ok()?;
let old = tokio::fs::read_to_string(&path).await.ok()?;
let new = apply_edit(&old, input).ok()?;
Some(unified_diff(&rel(ctx, &path), &old, &new))
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let path = ctx.resolve(arg_str(&input, "path")?)?;
if !ctx.app.was_read(&ctx.session_id, &path) {
return Ok(ToolOutput::error("File has not been read in this session. Read it first."));
}
let old = tokio::fs::read_to_string(&path).await?;
let new = match apply_edit(&old, &input) {
Ok(n) => n,
Err(e) => return Ok(ToolOutput::error(e.to_string())),
};
tokio::fs::write(&path, &new).await?;
let diff = unified_diff(&rel(ctx, &path), &old, &new);
Ok(ToolOutput::text(format!("Edited {}", path.display()))
.with_meta(json!({"diff": diff, "path": path})))
}
}
fn apply_edit(old: &str, input: &Value) -> Result<String> {
let from = arg_str(input, "old_string")?;
let to = arg_str(input, "new_string")?;
if from.is_empty() {
bail!("old_string must not be empty (use write to create files)");
}
if from == to {
bail!("old_string and new_string are identical");
}
let count = old.matches(from).count();
let all = input["replace_all"].as_bool().unwrap_or(false);
match count {
0 => bail!("old_string not found in file. Re-read the file and match it exactly."),
1 => Ok(old.replacen(from, to, 1)),
n if all => {
let _ = n;
Ok(old.replace(from, to))
}
n => bail!("old_string occurs {n} times. Add surrounding context to make it unique or set replace_all."),
}
}
// ---------------------------------------------------------------------------
pub struct List;
#[async_trait]
impl Tool for List {
fn name(&self) -> &str {
"list"
}
fn description(&self) -> String {
"List a directory (respects .gitignore). Directories end with '/'.".into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "Directory, default: working directory"},
"depth": {"type": "integer", "description": "Recursion depth (default 1, max 5)"}
}
})
}
fn read_only(&self) -> bool {
true
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let dir = ctx.resolve(input["path"].as_str().unwrap_or("."))?;
let depth = input["depth"].as_u64().unwrap_or(1).clamp(1, 5) as usize;
let cwd = ctx.cwd.clone();
let out = tokio::task::spawn_blocking(move || {
let mut lines = Vec::new();
for e in ignore::WalkBuilder::new(&dir).max_depth(Some(depth)).hidden(false).build().flatten() {
if e.depth() == 0 || e.path().components().any(|c| c.as_os_str() == ".git") {
continue;
}
let p = e.path().strip_prefix(&dir).unwrap_or(e.path());
let is_dir = e.file_type().is_some_and(|t| t.is_dir());
lines.push(format!("{}{}", p.display(), if is_dir { "/" } else { "" }));
if lines.len() >= 1000 {
lines.push("[... truncated at 1000 entries ...]".into());
break;
}
}
lines.sort();
let _ = cwd;
lines.join("\n")
})
.await?;
Ok(ToolOutput::text(if out.is_empty() { "(empty directory)".into() } else { out }))
}
}
// ---------------------------------------------------------------------------
pub struct Glob;
#[async_trait]
impl Tool for Glob {
fn name(&self) -> &str {
"glob"
}
fn description(&self) -> String {
"Find files by glob pattern (e.g. \"**/*.rs\", \"src/**/test_*.py\"). Respects .gitignore. \
Results are sorted by modification time, newest first."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pattern": {"type": "string"},
"path": {"type": "string", "description": "Directory to search, default: working directory"}
},
"required": ["pattern"]
})
}
fn read_only(&self) -> bool {
true
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let pattern = arg_str(&input, "pattern")?.to_string();
let root = ctx.resolve(input["path"].as_str().unwrap_or("."))?;
let out = tokio::task::spawn_blocking(move || -> Result<String> {
let matcher = globset::GlobBuilder::new(&pattern).literal_separator(true).build()?.compile_matcher();
let mut hits = Vec::new();
for e in ignore::WalkBuilder::new(&root).hidden(false).build().flatten() {
if !e.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let relp = e.path().strip_prefix(&root).unwrap_or(e.path());
if relp.components().any(|c| c.as_os_str() == ".git") {
continue;
}
if matcher.is_match(relp) {
let mtime = e.metadata().ok().and_then(|m| m.modified().ok());
hits.push((mtime, e.path().display().to_string()));
}
}
hits.sort_by(|a, b| b.0.cmp(&a.0));
let n = hits.len();
let mut s: Vec<String> = hits.into_iter().take(500).map(|h| h.1).collect();
if n > 500 {
s.push(format!("[... {} more files ...]", n - 500));
}
Ok(if s.is_empty() { "No files found".into() } else { s.join("\n") })
})
.await??;
Ok(ToolOutput::text(out))
}
}
// ---------------------------------------------------------------------------
pub struct Grep;
#[async_trait]
impl Tool for Grep {
fn name(&self) -> &str {
"grep"
}
fn description(&self) -> String {
"Search file contents with a regular expression (Rust regex syntax). Respects .gitignore. \
output_mode: \"files\" (default, matching file paths), \"content\" (matching lines with \
line numbers), \"count\"."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pattern": {"type": "string"},
"path": {"type": "string", "description": "File or directory, default: working directory"},
"glob": {"type": "string", "description": "Only search files matching this glob, e.g. \"*.ts\""},
"output_mode": {"type": "string", "enum": ["files", "content", "count"]},
"case_insensitive": {"type": "boolean"},
"context": {"type": "integer", "description": "Lines of context around matches (content mode)"}
},
"required": ["pattern"]
})
}
fn read_only(&self) -> bool {
true
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let re = regex::RegexBuilder::new(arg_str(&input, "pattern")?)
.case_insensitive(input["case_insensitive"].as_bool().unwrap_or(false))
.build()?;
let root = ctx.resolve(input["path"].as_str().unwrap_or("."))?;
let glob = input["glob"]
.as_str()
.map(|g| globset::Glob::new(g).map(|g| g.compile_matcher()))
.transpose()?;
let mode = input["output_mode"].as_str().unwrap_or("files").to_string();
let context = input["context"].as_u64().unwrap_or(0) as usize;
let cancel = ctx.cancel.clone();
let out = tokio::task::spawn_blocking(move || {
let mut out = String::new();
let mut files = 0usize;
for e in ignore::WalkBuilder::new(&root).hidden(false).build().flatten() {
if cancel.is_cancelled() || out.len() > MAX_OUTPUT {
break;
}
if !e.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let p = e.path();
if p.components().any(|c| c.as_os_str() == ".git") {
continue;
}
if let Some(g) = &glob {
let name = p.file_name().map(Path::new).unwrap_or(p);
if !g.is_match(name) && !g.is_match(p.strip_prefix(&root).unwrap_or(p)) {
continue;
}
}
let Ok(bytes) = std::fs::read(p) else { continue };
if bytes.iter().take(8000).any(|b| *b == 0) {
continue;
}
let text = String::from_utf8_lossy(&bytes);
let lines: Vec<&str> = text.lines().collect();
let hits: Vec<usize> = lines.iter().enumerate().filter(|(_, l)| re.is_match(l)).map(|(i, _)| i).collect();
if hits.is_empty() {
continue;
}
files += 1;
match mode.as_str() {
"count" => out.push_str(&format!("{}:{}\n", p.display(), hits.len())),
"content" => {
let mut last_printed: Option<usize> = None;
for &h in &hits {
let start = h.saturating_sub(context);
let end = (h + context).min(lines.len().saturating_sub(1));
for i in start..=end {
if last_printed.is_some_and(|l| i <= l) {
continue;
}
let sep = if i == h { ':' } else { '-' };
let line: String = lines[i].chars().take(MAX_LINE_LEN).collect();
out.push_str(&format!("{}{sep}{}{sep}{}\n", p.display(), i + 1, line));
last_printed = Some(i);
}
}
}
_ => out.push_str(&format!("{}\n", p.display())),
}
}
if files == 0 { "No matches found".to_string() } else { out }
})
.await?;
Ok(ToolOutput::text(truncate(&out, MAX_OUTPUT)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edit_requires_unique_match() {
let input = json!({"old_string": "a", "new_string": "b"});
assert!(apply_edit("a a", &input).is_err());
assert_eq!(apply_edit("a c", &input).unwrap(), "b c");
let all = json!({"old_string": "a", "new_string": "b", "replace_all": true});
assert_eq!(apply_edit("a a", &all).unwrap(), "b b");
assert!(apply_edit("xyz", &input).is_err());
}
}