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:
commit
f3a2fe3816
62 files changed
+21835
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user