//! 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>, pub read_only: bool, /// "coding" or "chat" (plain conversation prompt). pub style: String, pub model: Option, /// "builtin", "config" or "file". pub source: String, /// Max. instances running at the same time. pub max_parallel: Option, /// Review loop after each turn (None/0 rounds = off). pub refine: Option, /// Source file of Markdown agents. #[serde(skip_serializing_if = "Option::is_none")] pub path: Option, } pub use codecraft_core::prompts::*; pub fn builtin_agents(clean_room: bool) -> Vec { let craft = craft_prompt(clean_room); vec![ Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: 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, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None }, Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: 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, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: 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, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: 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, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None }, ] } /// Parse "---\nkey: value\n---\nbody" frontmatter (flat keys only). pub fn frontmatter(text: &str) -> (BTreeMap, 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. /// Skills and commands shipped with CodeCraft (read-only for agents). pub fn builtin_resources() -> PathBuf { if let Ok(p) = std::env::var("CODECRAFT_RESOURCES") { return PathBuf::from(p); } ["/app/resources", "./resources", "./server/resources"].iter().map(PathBuf::from).find(|p| p.is_dir()).unwrap_or_else(|| PathBuf::from("/app/resources")) } /// Resource directories, lowest priority first: built-in, data dir, workspace, project. fn resource_dirs(app: &App, root: &Path, cwd: &Path, kind: &str) -> Vec { let mut dirs = vec![builtin_resources().join(kind), app.data_dir().join(kind)]; let ws = root.to_path_buf(); 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, root: &Path, cwd: &Path) -> Vec { let cfg = app.config(); let mut map: BTreeMap = 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, root, 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: meta.get("read_only").is_some_and(|v| v == "true"), style: meta.get("style").filter(|v| *v == "chat").cloned().unwrap_or_else(|| "coding".into()), model: meta.get("model").filter(|m| m.contains('/')).cloned(), source: "file".into(), max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()), refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None }), path: Some(p.display().to_string()), }); } } // 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: a.read_only.unwrap_or_else(|| base.as_ref().is_some_and(|b| b.read_only)), style: a.style.clone().filter(|s| s == "chat" || s == "coding").or(base.as_ref().map(|b| b.style.clone())).unwrap_or_else(|| "coding".into()), model: a.model.clone().filter(|m| !m.is_empty()).or(base.as_ref().and_then(|b| b.model.clone())), source: "config".into(), max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)), refine: a.refine.clone().or(base.as_ref().and_then(|b| b.refine.clone())), path: None, }); } map.into_values().collect() } /// Personal modes a user created for themselves (user setting "modes"). pub fn personal_modes(app: &App, user_id: &str) -> BTreeMap { app.store.user_setting(user_id, "modes").unwrap_or_default() } /// All agents plus the user's personal modes. Personal modes never replace built-in, /// admin or project ones of the same name. pub fn agents_for(app: &App, root: &Path, cwd: &Path, user_id: Option<&str>) -> Vec { let mut list = agents(app, root, cwd); let Some(uid) = user_id else { return list }; for (name, a) in personal_modes(app, uid) { if list.iter().any(|x| x.name == name) { continue; } list.push(Agent { name, description: a.description.unwrap_or_default(), mode: "primary".into(), prompt: a.prompt.unwrap_or_default(), tools: a.tools, read_only: a.read_only.unwrap_or(false), style: a.style.filter(|s| s == "chat").unwrap_or_else(|| "coding".into()), model: a.model.filter(|m| !m.is_empty()), source: "personal".into(), max_parallel: None, refine: a.refine, path: None, }); } list } pub fn find_agent_for(app: &App, root: &Path, cwd: &Path, name: &str, user_id: Option<&str>) -> Agent { agents_for(app, root, cwd, user_id) .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, root: &Path, cwd: &Path) -> Vec<(PathBuf, String)> { let mut dirs = vec![app.data_dir()]; let ws = root.to_path_buf(); 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 } /// The plain chat mode: no coding instructions, project files or memory – only the /// date, the model and what the user keeps in their personal memory. /// Add the instructions the user set for this chat (they come last, so they win). pub fn with_chat_instructions(mut system: String, instructions: &str) -> String { if !instructions.trim().is_empty() { system.push_str(&format!( "\n\n# Instructions for this chat\nThe user set these for this chat. They apply to every message and override the defaults above:\n{}\n", instructions.trim() )); } system } fn chat_prompt(app: &App, agent: &Agent, model: &str, owner: Option<&crate::store::User>) -> String { let mut s = agent.prompt.clone(); s.push_str(&format!("\n\n# Context\n- Date: {}\n- Model: {model}\n", chrono::Local::now().format("%A, %Y-%m-%d"))); if let Some(u) = owner { let memory: String = app.store.user_setting(&u.id, "memory").unwrap_or_default(); if !memory.trim().is_empty() { s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim())); } } s } pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &str, permission_mode: &str, owner: Option<&crate::store::User>) -> String { if agent.style == "chat" { return chat_prompt(app, agent, model, owner); } let mut s = String::from(BASE_PROMPT); s.push_str("\n\n"); s.push_str(&agent.prompt); if std::env::var("HOME").is_ok_and(|h| h.starts_with("/data")) { s.push_str("\n\n# Installing tools\nYou run without root (no apt/sudo). Install what you need in user space – it persists: Python packages with `pip install ` (user site) or a venv, CLI tools with `pipx install`, Rust with `curl https://sh.rustup.rs -sSf | sh -s -- -y` (then `~/.cargo/bin` is on PATH), Node packages with `npm i -g`, and other binaries (e.g. Godot) by downloading them to `~/.local/bin`. Check with `which`/`--version` first whether a tool is already installed."); } if permission_mode == "auto" && !agent.read_only { s.push_str(AUTO_MODE_PROMPT); } let git = cwd.join(".git").exists(); s.push_str(&format!( "\n\n# Environment\n- Working directory: {}\n- Project root: {}\n- Git repository: {}\n- Platform: {} ({})\n- Model: {model}\n- Date: {}\n", cwd.display(), root.display(), if git { "yes" } else { "no" }, std::env::consts::OS, std::env::consts::ARCH, chrono::Local::now().format("%Y-%m-%d"), )); if !agent.read_only || agent.name == "plan" { let subs: Vec = agents(app, root, cwd).into_iter().filter(|a| a.mode != "primary").collect(); if !subs.is_empty() { s.push_str("\n# Subagents\nAvailable for the task tool (subagent_type). Delegate work that fits a subagent's description – especially writing, summarizing and translating – and use background=true for tasks whose result you do not need right away.\n"); for a in &subs { let model = a.model.as_deref().map(|m| format!(" [model: {m}]")).unwrap_or_default(); let ro = if a.read_only { " (read-only)" } else { "" }; s.push_str(&format!("- {}{ro}{model}: {}\n", a.name, a.description)); } } } let deferred = app.mcp.deferred_summary(&app.config(), owner); if !deferred.is_empty() { s.push_str("\n# On-demand MCP servers\nThese servers have many tools that are not listed directly. Find tools with mcp_search, then call them with mcp_call:\n"); for (server, n, names) in &deferred { s.push_str(&format!("- {server} ({n} tools, e.g. {})\n", names.join(", "))); } } let skills = skills(app, root, 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)); } } if let Some(u) = owner { let memory: String = app.store.user_setting(&u.id, "memory").unwrap_or_default(); if !memory.trim().is_empty() { s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim())); } } match std::fs::read_to_string(cwd.join(".codecraft").join("memory.md")) { Ok(mem) => s.push_str(&format!("\n# Project memory\nFacts learned earlier in this project (keep it current with the remember tool):\n{}\n", mem.chars().take(20_000).collect::())), Err(_) if !agent.read_only => s.push_str("\n# Project memory\nWhen you learn something lasting about this project (how to build/test, conventions, pitfalls), save it with the remember tool.\n"), Err(_) => {} } let files = instruction_files(app, root, 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\n{}\n\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, /// Craft: switch the session to clean room (true) or deep (false). #[serde(skip)] pub clean_room: Option, } pub fn commands(app: &App, root: &Path, cwd: &Path) -> Vec { let mut map = BTreeMap::new(); for dir in resource_dirs(app, root, 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()); let clean_room = meta.get("clean_room").and_then(|v| v.parse::().ok()); map.insert(name.clone(), Command { name, description, body, agent: meta.get("agent").cloned(), clean_room }); } } 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, root: &Path, cwd: &Path, text: &str) -> Option<(String, Option, Option)> { let rest = text.strip_prefix('/')?; let (name, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); let cmd = commands(app, root, 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, cmd.clean_room)) } // --------------------------------------------------------------------------- // Skills #[derive(Debug, Clone, Serialize)] pub struct Skill { pub name: String, pub description: String, pub path: PathBuf, } pub fn skills(app: &App, root: &Path, cwd: &Path) -> Vec { let mut map = BTreeMap::new(); for dir in resource_dirs(app, root, 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 { let name = arg_str(&input, "name")?; let Some(sk) = skills(&ctx.app, &ctx.root(), &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"); } }