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,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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user