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

+424
View File
@@ -0,0 +1,424 @@
//! Configuration: a commented TOML file that the web UI edits in place.
//!
//! Parsing goes through `toml` into typed structs; writes go through
//! `toml_edit` so comments and formatting survive UI edits.
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const EXAMPLE_CONFIG: &str = include_str!("../../config.example.toml");
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct Config {
pub server: ServerConfig,
pub providers: BTreeMap<String, ProviderConfig>,
pub mcp: BTreeMap<String, McpConfig>,
pub permissions: PermissionConfig,
pub agents: BTreeMap<String, AgentConfig>,
pub craft: CraftConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct ServerConfig {
pub bind: String,
pub workspace: String,
pub data_dir: String,
pub auth_token: String,
pub default_model: String,
pub small_model: String,
pub auto_compact_threshold: f64,
/// Currency label for cost statistics (display only).
pub currency: String,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
bind: "127.0.0.1:8787".into(),
workspace: ".".into(),
data_dir: "./data".into(),
auth_token: String::new(),
default_model: String::new(),
small_model: String::new(),
auto_compact_threshold: 0.85,
currency: "USD".into(),
}
}
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ProviderKind {
Anthropic,
Openai,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProviderConfig {
pub kind: ProviderKind,
#[serde(default)]
pub base_url: String,
#[serde(default)]
pub api_key: String,
#[serde(default)]
pub auth_header: Option<String>,
#[serde(default)]
pub auth_prefix: Option<String>,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default)]
pub models: Vec<ModelEntry>,
#[serde(default = "yes")]
pub enabled: bool,
}
fn yes() -> bool {
true
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum ModelEntry {
Id(String),
Full(ModelConfig),
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ModelConfig {
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub context: Option<u64>,
#[serde(default)]
pub max_output: Option<u64>,
#[serde(default)]
pub reasoning: bool,
#[serde(default)]
pub vision: bool,
/// Reasoning effort (Anthropic: low|medium|high|xhigh|max, OpenAI: low|medium|high).
#[serde(default)]
pub effort: Option<String>,
/// Prices in currency units per 1M tokens (used for cost statistics).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_write_price: Option<f64>,
}
impl ModelConfig {
/// Cost of one request. Cache prices default to the input price.
pub fn cost(&self, u: &crate::types::Usage) -> f64 {
let inp = self.input_price.unwrap_or(0.0);
let per = |tokens: u64, price: f64| tokens as f64 * price / 1_000_000.0;
per(u.input_tokens, inp)
+ per(u.output_tokens, self.output_price.unwrap_or(0.0))
+ per(u.cache_read_tokens, self.cache_read_price.unwrap_or(inp))
+ per(u.cache_write_tokens, self.cache_write_price.unwrap_or(inp))
}
}
impl ModelEntry {
pub fn resolved(&self) -> ModelConfig {
match self {
ModelEntry::Id(id) => ModelConfig { id: id.clone(), ..Default::default() },
ModelEntry::Full(m) => m.clone(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpConfig {
#[serde(rename = "type")]
pub kind: McpKind,
#[serde(default)]
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub url: String,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default = "yes")]
pub enabled: bool,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum McpKind {
Stdio,
Http,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct PermissionConfig {
#[serde(default)]
pub bash_rules: BTreeMap<String, String>,
#[serde(flatten)]
pub tools: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct AgentConfig {
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub tools: Option<Vec<String>>,
#[serde(default)]
pub prompt: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct CraftConfig {
pub chromium: String,
pub widths: Vec<u32>,
pub clean_room: bool,
}
impl Default for CraftConfig {
fn default() -> Self {
Self { chromium: "chromium".into(), widths: vec![390, 768, 1440], clean_room: true }
}
}
/// Replace `${NAME}` with the environment variable (empty if unset).
pub fn expand_env(s: &str) -> String {
let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap();
re.replace_all(s, |c: &regex::Captures| std::env::var(&c[1]).unwrap_or_default())
.into_owned()
}
impl Config {
pub fn parse(text: &str) -> Result<Self> {
let cfg: Config = toml::from_str(text).context("invalid config")?;
for (name, p) in &cfg.providers {
if name.contains('/') {
bail!("provider name must not contain '/': {name}");
}
if p.base_url.is_empty() && p.kind == ProviderKind::Openai {
bail!("provider {name}: base_url is required");
}
}
Ok(cfg)
}
pub fn workspace(&self) -> PathBuf {
PathBuf::from(expand_env(&self.server.workspace))
}
pub fn data_dir(&self) -> PathBuf {
PathBuf::from(expand_env(&self.server.data_dir))
}
pub fn auth_token(&self) -> String {
expand_env(&self.server.auth_token)
}
}
/// Locate the config file: $CODECRAFT_CONFIG, else ./data/config.toml (or /data in Docker).
pub fn config_path() -> PathBuf {
if let Ok(p) = std::env::var("CODECRAFT_CONFIG") {
return PathBuf::from(p);
}
if Path::new("/data").is_dir() {
return PathBuf::from("/data/config.toml");
}
PathBuf::from("./data/config.toml")
}
/// Load the config, writing the commented example on first start.
pub fn load_or_init(path: &Path) -> Result<(Config, String)> {
if !path.exists() {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(path, EXAMPLE_CONFIG)?;
tracing::info!("wrote default config to {}", path.display());
}
let text = std::fs::read_to_string(path)?;
let cfg = Config::parse(&text)?;
Ok((cfg, text))
}
/// A structured edit coming from the UI. Applied with toml_edit so comments survive.
#[derive(Debug, Deserialize)]
pub struct ConfigPatch {
/// Dotted path into the document, e.g. ["providers", "gemini", "enabled"].
pub path: Vec<String>,
/// New value as JSON; `null` removes the key.
pub value: serde_json::Value,
}
pub fn apply_patches(text: &str, patches: &[ConfigPatch]) -> Result<String> {
let mut doc: toml_edit::DocumentMut = text.parse().context("config is not valid TOML")?;
for patch in patches {
if patch.path.is_empty() {
bail!("empty patch path");
}
let (last, parents) = patch.path.split_last().unwrap();
let mut table: &mut toml_edit::Table = doc.as_table_mut();
for key in parents {
let entry = table
.entry(key)
.or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()));
// Inline tables are converted so we can descend into them.
if let Some(inline) = entry.as_inline_table().cloned() {
*entry = toml_edit::Item::Table(inline.into_table());
}
table = entry
.as_table_mut()
.with_context(|| format!("'{key}' is not a table"))?;
}
if patch.value.is_null() {
table.remove(last);
} else if patch.value.is_object() && parents.len() < 2 && table.get(last).is_none_or(|i| i.is_table()) {
// New sections (e.g. [providers.foo]) become real tables, not inline ones.
let mut t = toml_edit::Table::new();
for (k, v) in patch.value.as_object().unwrap() {
if let Some(v) = json_to_toml(v) {
t.insert(k, toml_edit::Item::Value(v));
}
}
table.insert(last, toml_edit::Item::Table(t));
} else {
let v = json_to_toml(&patch.value).context("unsupported value")?;
match table.get_mut(last) {
// Keep the existing decoration (trailing comments) of the value.
Some(toml_edit::Item::Value(old)) => {
let decor = old.decor().clone();
*old = v;
*old.decor_mut() = decor;
}
_ => {
table.insert(last, toml_edit::Item::Value(v));
}
}
}
}
let out = doc.to_string();
Config::parse(&out)?;
Ok(out)
}
fn json_to_toml(v: &serde_json::Value) -> Option<toml_edit::Value> {
use serde_json::Value as J;
Some(match v {
J::Null => return None,
J::Bool(b) => (*b).into(),
J::Number(n) => {
if let Some(i) = n.as_i64() {
i.into()
} else {
n.as_f64()?.into()
}
}
J::String(s) => s.as_str().into(),
J::Array(a) => {
let mut arr = toml_edit::Array::new();
for x in a {
if let Some(t) = json_to_toml(x) {
arr.push(t);
}
}
if a.iter().any(|x| x.is_object()) {
arr.set_trailing_comma(true);
for item in arr.iter_mut() {
item.decor_mut().set_prefix("\n ");
}
arr.set_trailing("\n");
}
toml_edit::Value::Array(arr)
}
J::Object(o) => {
let mut t = toml_edit::InlineTable::new();
for (k, x) in o {
if let Some(tv) = json_to_toml(x) {
t.insert(k, tv);
}
}
toml_edit::Value::InlineTable(t)
}
})
}
/// JSON view of the config for the UI. Secret values are masked unless they are
/// env references, so the UI can show "${FOO}" but never a literal key.
pub fn redacted_json(cfg: &Config) -> serde_json::Value {
let mut v = serde_json::to_value(cfg).unwrap_or_default();
let mask = |s: &mut serde_json::Value| {
if let Some(text) = s.as_str() {
if !text.is_empty() && !text.starts_with("${") {
*s = serde_json::Value::String("••••••••".into());
}
}
};
if let Some(ps) = v.get_mut("providers").and_then(|p| p.as_object_mut()) {
for p in ps.values_mut() {
if let Some(k) = p.get_mut("api_key") {
mask(k);
}
}
}
if let Some(t) = v.pointer_mut("/server/auth_token") {
mask(t);
}
v
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn example_parses() {
let cfg = Config::parse(EXAMPLE_CONFIG).unwrap();
assert!(cfg.providers.contains_key("anthropic"));
assert_eq!(cfg.permissions.tools.get("read").map(String::as_str), Some("allow"));
assert!(cfg.permissions.bash_rules.contains_key("git status*"));
}
#[test]
fn patch_keeps_comments() {
let out = apply_patches(
EXAMPLE_CONFIG,
&[ConfigPatch {
path: vec!["providers".into(), "gemini".into(), "enabled".into()],
value: serde_json::json!(true),
}],
)
.unwrap();
assert!(out.contains("# Google Gemini through its OpenAI-compatible endpoint."));
assert!(out.contains("enabled = true # set to true once GEMINI_API_KEY is available"));
let cfg = Config::parse(&out).unwrap();
assert!(cfg.providers["gemini"].enabled);
}
#[test]
fn patch_adds_provider_section() {
let out = apply_patches(
EXAMPLE_CONFIG,
&[ConfigPatch {
path: vec!["providers".into(), "vllm".into()],
value: serde_json::json!({"kind": "openai", "base_url": "http://x/v1"}),
}],
)
.unwrap();
assert!(out.contains("[providers.vllm]"));
Config::parse(&out).unwrap();
}
}