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,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: ®ex::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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user