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

+334
View File
@@ -0,0 +1,334 @@
//! Agents (modes), system prompts, project instructions, slash commands and skills.
//!
//! Compatible with Claude Code / opencode layouts: AGENTS.md, CLAUDE.md,
//! .claude/{agents,commands,skills}, .codecraft/{agents,commands,skills}.
use crate::app::App;
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str};
use anyhow::Result;
use async_trait::async_trait;
use serde::Serialize;
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize)]
pub struct Agent {
pub name: String,
pub description: String,
/// "primary" (selectable mode) or "subagent" (task tool only).
pub mode: String,
#[serde(skip)]
pub prompt: String,
/// Allowed tools; None = all.
pub tools: Option<Vec<String>>,
pub read_only: bool,
pub model: Option<String>,
}
const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent working in the user's project through tools.
# Working style
- Be direct and concise. Lead with what you did or found; no filler.
- Understand before changing: read relevant files, search the codebase, follow existing conventions, libraries and patterns.
- Make the change completely: implement, then verify (build, run tests, lint) when possible. Report failures honestly with the relevant output.
- Prefer editing existing files over creating new ones. Never create documentation files unless asked.
- Use todo_write to plan and track multi-step work; keep exactly one item in progress.
- Use the dedicated tools (read, edit, write, glob, grep, list) instead of shell equivalents. Batch independent tool calls in one response.
- Use the task tool for broad searches or independent sub-tasks to keep your own context focused.
- If you are blocked on a decision that is genuinely the user's, use ask_user. Otherwise pick a sensible default and mention it.
- Never commit, push or perform destructive/irreversible actions unless the user asked for it.
- Reference code as path:line.
- Answer in the language the user writes in."#;
const BUILD_PROMPT: &str = "Mode: BUILD. You have full access to read, modify and run things in the project.";
const PLAN_PROMPT: &str = r#"Mode: PLAN. You are in read-only planning mode: you must not modify files, run state-changing commands or make commits.
Investigate the codebase, ask clarifying questions with ask_user where requirements are ambiguous, and finish with a concrete, step-by-step implementation plan (files to change, approach, risks, verification). The user switches to build mode to execute it."#;
const CRAFT_PROMPT: &str = r#"Mode: CRAFT. You recreate the look and behaviour of a target (website, app or game) as a NEW, original project the user owns, working in four phases. Keep the user informed of the current phase with todo_write (one item per phase).
1. STUDY – Inspect the target. For websites use browser_snapshot on each relevant page (it captures every configured screen width plus visible text). Note layout, colours, typography, spacing, components, navigation and behaviour. For apps/games use screenshots the user provides and their description. Summarise your findings briefly.
2. BUILD – Create the new project in the working directory, in the stack the user asked for (otherwise a sensible default: plain HTML/CSS/JS for sites). Write clean, original code and original placeholder content/assets; do not copy proprietary text, logos, images or code.
3. CHECK – Serve or open the build (e.g. `python3 -m http.server PORT` in the background, or file://), snapshot it with browser_snapshot using a 'build-*' label, and compare each width against the original with image_compare. List what does not match yet and the similarity per page/width.
4. READY – Refine until the result is close (or improvements stall), then tell the user how to open/run it and summarise remaining differences. Ask for changes.
Rules: ask the user (ask_user) when a decision is theirs (stack, scope, which pages). {CLEAN_ROOM}"#;
const CLEAN_ROOM_ON: &str = "Clean room is ON: only use what a visitor can see (screenshots, visible text, measured layout). Do not read the target's page source, scripts or files (web_fetch with raw=true is refused).";
const CLEAN_ROOM_OFF: &str = "Deep mode is ON: you may also read the target's page source and scripts for the user's private use.";
const GENERAL_PROMPT: &str = "You are a subagent. Complete the delegated task autonomously and end with a concise, complete report of what you found or did – it is the only thing the calling agent sees.";
const EXPLORE_PROMPT: &str = "You are a fast, read-only exploration subagent. Search broadly (glob, grep, read excerpts) and report the conclusion with precise file:line references. Do not modify anything.";
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
let craft = CRAFT_PROMPT.replace("{CLEAN_ROOM}", if clean_room { CLEAN_ROOM_ON } else { CLEAN_ROOM_OFF });
vec![
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, model: None },
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, model: None },
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, model: None },
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, model: None },
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, model: None },
]
}
/// Parse "---\nkey: value\n---\nbody" frontmatter (flat keys only).
pub fn frontmatter(text: &str) -> (BTreeMap<String, String>, String) {
let mut meta = BTreeMap::new();
let t = text.trim_start_matches('\u{feff}');
if let Some(rest) = t.strip_prefix("---") {
if let Some(end) = rest.find("\n---") {
for line in rest[..end].lines() {
if let Some((k, v)) = line.split_once(':') {
meta.insert(k.trim().to_string(), v.trim().trim_matches('"').trim_matches('\'').to_string());
}
}
let body = rest[end + 4..].trim_start_matches(['-', '\n', '\r']).to_string();
return (meta, body);
}
}
(meta, t.to_string())
}
/// Resource directories, lowest priority first: data dir, workspace, project.
fn resource_dirs(app: &App, cwd: &Path, kind: &str) -> Vec<PathBuf> {
let mut dirs = vec![app.data_dir().join(kind)];
let ws = app.workspace();
let mut chain = vec![ws.clone()];
if cwd != ws {
chain.push(cwd.to_path_buf());
}
for base in chain {
dirs.push(base.join(".claude").join(kind));
dirs.push(base.join(".codecraft").join(kind));
}
dirs
}
pub fn agents(app: &App, cwd: &Path) -> Vec<Agent> {
let cfg = app.config();
let mut map: BTreeMap<String, Agent> =
builtin_agents(cfg.craft.clean_room).into_iter().map(|a| (a.name.clone(), a)).collect();
// Markdown agents (Claude Code format).
for dir in resource_dirs(app, cwd, "agents") {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some("md") {
continue;
}
let Ok(text) = std::fs::read_to_string(&p) else { continue };
let (meta, body) = frontmatter(&text);
let name = meta.get("name").cloned().unwrap_or_else(|| p.file_stem().unwrap().to_string_lossy().into());
let tools = meta.get("tools").map(|t| t.split(',').map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty()).collect());
map.insert(name.clone(), Agent {
name,
description: meta.get("description").cloned().unwrap_or_default(),
mode: meta.get("mode").cloned().unwrap_or_else(|| "subagent".into()),
prompt: body,
tools,
read_only: false,
model: meta.get("model").filter(|m| m.contains('/')).cloned(),
});
}
}
// Config agents override everything.
for (name, a) in &cfg.agents {
let base = map.get(name).cloned();
map.insert(name.clone(), Agent {
name: name.clone(),
description: a.description.clone().or(base.as_ref().map(|b| b.description.clone())).unwrap_or_default(),
mode: a.mode.clone().or(base.as_ref().map(|b| b.mode.clone())).unwrap_or_else(|| "subagent".into()),
prompt: a.prompt.clone().or(base.as_ref().map(|b| b.prompt.clone())).unwrap_or_default(),
tools: a.tools.clone().or(base.as_ref().and_then(|b| b.tools.clone())),
read_only: base.as_ref().is_some_and(|b| b.read_only),
model: a.model.clone().or(base.and_then(|b| b.model)),
});
}
map.into_values().collect()
}
pub fn find_agent(app: &App, cwd: &Path, name: &str) -> Agent {
agents(app, cwd)
.into_iter()
.find(|a| a.name == name)
.unwrap_or_else(|| builtin_agents(true).remove(0))
}
/// AGENTS.md / CLAUDE.md files from the data dir, workspace root down to cwd.
fn instruction_files(app: &App, cwd: &Path) -> Vec<(PathBuf, String)> {
let mut dirs = vec![app.data_dir()];
let ws = app.workspace();
let mut d = cwd.to_path_buf();
let mut chain = Vec::new();
loop {
chain.push(d.clone());
if d == ws || !d.starts_with(&ws) || !d.pop() {
break;
}
}
chain.reverse();
dirs.extend(chain);
let mut out = Vec::new();
for dir in dirs {
for name in ["AGENTS.md", "CLAUDE.md", ".claude/CLAUDE.md"] {
let p = dir.join(name);
if let Ok(text) = std::fs::read_to_string(&p) {
if !text.trim().is_empty() {
out.push((p, text));
}
}
}
}
out
}
pub fn system_prompt(app: &App, agent: &Agent, cwd: &Path, model: &str) -> String {
let mut s = String::from(BASE_PROMPT);
s.push_str("\n\n");
s.push_str(&agent.prompt);
let git = cwd.join(".git").exists();
s.push_str(&format!(
"\n\n# Environment\n- Working directory: {}\n- Workspace root: {}\n- Git repository: {}\n- Platform: {} ({})\n- Model: {model}\n- Date: {}\n",
cwd.display(),
app.workspace().display(),
if git { "yes" } else { "no" },
std::env::consts::OS,
std::env::consts::ARCH,
chrono::Local::now().format("%Y-%m-%d"),
));
let skills = skills(app, cwd);
if !skills.is_empty() {
s.push_str("\n# Skills\nLoad a skill with the skill tool when the task matches its description:\n");
for sk in &skills {
s.push_str(&format!("- {}: {}\n", sk.name, sk.description));
}
}
let files = instruction_files(app, cwd);
if !files.is_empty() {
s.push_str("\n# Project instructions\nThese instructions come from the user's instruction files and OVERRIDE default behaviour.\n");
for (p, text) in files {
s.push_str(&format!("\n<instructions path=\"{}\">\n{}\n</instructions>\n", p.display(), text.trim()));
}
}
s
}
// ---------------------------------------------------------------------------
// Slash commands
#[derive(Debug, Clone, Serialize)]
pub struct Command {
pub name: String,
pub description: String,
#[serde(skip)]
pub body: String,
pub agent: Option<String>,
}
pub fn commands(app: &App, cwd: &Path) -> Vec<Command> {
let mut map = BTreeMap::new();
for dir in resource_dirs(app, cwd, "commands") {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some("md") {
continue;
}
let Ok(text) = std::fs::read_to_string(&p) else { continue };
let (meta, body) = frontmatter(&text);
let name = p.file_stem().unwrap().to_string_lossy().to_string();
let description = meta
.get("description")
.cloned()
.unwrap_or_else(|| body.lines().find(|l| !l.trim().is_empty()).unwrap_or("").chars().take(80).collect());
map.insert(name.clone(), Command { name, description, body, agent: meta.get("agent").cloned() });
}
}
map.into_values().collect()
}
/// Expand "/name args" into the command's prompt. Returns None if not a known command.
pub fn expand_command(app: &App, cwd: &Path, text: &str) -> Option<(String, Option<String>)> {
let rest = text.strip_prefix('/')?;
let (name, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
let cmd = commands(app, cwd).into_iter().find(|c| c.name == name)?;
let args = args.trim();
let mut body = cmd.body.replace("$ARGUMENTS", args);
for (i, a) in args.split_whitespace().enumerate() {
body = body.replace(&format!("${}", i + 1), a);
}
if !cmd.body.contains("$ARGUMENTS") && !args.is_empty() {
body.push_str(&format!("\n\n{args}"));
}
Some((body, cmd.agent))
}
// ---------------------------------------------------------------------------
// Skills
#[derive(Debug, Clone, Serialize)]
pub struct Skill {
pub name: String,
pub description: String,
pub path: PathBuf,
}
pub fn skills(app: &App, cwd: &Path) -> Vec<Skill> {
let mut map = BTreeMap::new();
for dir in resource_dirs(app, cwd, "skills") {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for e in entries.flatten() {
let p = e.path().join("SKILL.md");
let Ok(text) = std::fs::read_to_string(&p) else { continue };
let (meta, _) = frontmatter(&text);
let name = meta.get("name").cloned().unwrap_or_else(|| e.file_name().to_string_lossy().into());
map.insert(name.clone(), Skill { name, description: meta.get("description").cloned().unwrap_or_default(), path: p });
}
}
map.into_values().collect()
}
pub struct SkillTool;
#[async_trait]
impl Tool for SkillTool {
fn name(&self) -> &str {
"skill"
}
fn description(&self) -> String {
"Load a skill (packaged instructions for a kind of task) by name. Follow the returned instructions.".into()
}
fn schema(&self) -> Value {
json!({"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]})
}
fn read_only(&self) -> bool {
true
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let name = arg_str(&input, "name")?;
let Some(sk) = skills(&ctx.app, &ctx.cwd).into_iter().find(|s| s.name == name) else {
return Ok(ToolOutput::error(format!("unknown skill '{name}'")));
};
let text = std::fs::read_to_string(&sk.path)?;
let (_, body) = frontmatter(&text);
let dir = sk.path.parent().unwrap_or(Path::new("."));
Ok(ToolOutput::text(format!("Skill '{name}' (base directory: {}):\n\n{body}", dir.display())))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_frontmatter() {
let (m, b) = frontmatter("---\nname: x\ndescription: \"does y\"\n---\nBody here\n");
assert_eq!(m["name"], "x");
assert_eq!(m["description"], "does y");
assert_eq!(b.trim(), "Body here");
let (m2, b2) = frontmatter("no frontmatter");
assert!(m2.is_empty());
assert_eq!(b2, "no frontmatter");
}
}