Add terminal client, memory, model fallback, version and install improvements

- codecraft: full-screen terminal client (ratatui) – local tools and files,
  models via the CodeCraft server gateway or own providers (standalone with a
  guided setup), slash commands, @files, permission dialogs, resume, update
- Static Linux CLI builds (x86_64/aarch64, cargo-zigbuild) served by the
  server: /cli/install.sh and /cli/download/<target>
- Project memory (remember tool, .codecraft/memory.md) and personal memory
- Model fallback when a host is unreachable or a budget is used up
- Version/commit in UI and health, daily release check, "What's new" dialog
- PWA: install button, maskable icon, manifest id and screenshots
- Case-insensitive detection of connection errors for retries

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 00:13:06 +01:00
1 parent aa1f8cf016
commit 6dffcb501a
52 files changed
+6477 -765

No files matched your search

Generated
+1010 -71
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -22,9 +22,31 @@ RUN mkdir -p core/src server/src cli/src \
&& cargo build --release -p codecraft && rm -rf core/src server/src cli/src
COPY core/src ./core/src
COPY server/src ./server/src
COPY server/resources-cli ./server/resources-cli
COPY cli/src ./cli/src
ARG GIT_COMMIT=""
ENV CODECRAFT_COMMIT=${GIT_COMMIT}
RUN touch core/src/lib.rs server/src/main.rs && cargo build --release -p codecraft
# --- terminal client (static Linux binaries for x86_64 and aarch64) -----------
FROM rust:1-bookworm AS cli
RUN apt-get update && apt-get install -y --no-install-recommends cmake xz-utils && rm -rf /var/lib/apt/lists/*
ARG ZIG_VERSION=0.17.1
RUN curl -fsSL https://ziglang.org/download/${ZIG_VERSION}/zig-$(uname -m)-linux-${ZIG_VERSION}.tar.xz | tar -xJ -C /opt \
&& ln -s /opt/zig-*/zig /usr/local/bin/zig \
&& cargo install --locked cargo-zigbuild \
&& rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl
WORKDIR /src
COPY Cargo.toml Cargo.lock ./
COPY core ./core
COPY server/Cargo.toml ./server/
COPY server/resources ./server/resources
COPY cli ./cli
RUN mkdir -p server/src && echo 'fn main(){}' > server/src/main.rs \
&& cargo zigbuild --release -p codecraft-cli --target x86_64-unknown-linux-musl --target aarch64-unknown-linux-musl \
&& mkdir -p /out && cp target/x86_64-unknown-linux-musl/release/codecraft /out/codecraft-linux-x86_64 \
&& cp target/aarch64-unknown-linux-musl/release/codecraft /out/codecraft-linux-aarch64
# --- runtime ------------------------------------------------------------------
# Node is included so agents can work on JS projects and run npx-based MCP servers.
FROM node:22-bookworm-slim
@@ -42,6 +64,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN npm install -g typescript@5 typescript-language-server pyright && npm cache clean --force
COPY --from=server /src/target/release/codecraft /usr/local/bin/codecraft
COPY --from=web /web/dist /app/web
COPY --from=cli /out /app/cli
COPY server/ghidra /app/ghidra
COPY server/resources /app/resources
# The agent's home lives on the data volume: tools it installs in user space
+17 -5
View File
@@ -2,7 +2,7 @@
name = "codecraft-cli"
version = "0.1.0"
edition = "2024"
description = "Command line client for CodeCraft"
description = "CodeCraft in the terminal: a local coding agent using your CodeCraft server or your own model providers"
license = "MIT"
[[bin]]
@@ -10,11 +10,23 @@ name = "codecraft"
path = "src/main.rs"
[dependencies]
codecraft-core = { path = "../core" }
anyhow = "1"
base64 = "0.22"
chrono = "0.4"
clap = { version = "4", features = ["derive", "env"] }
futures-util = "0.3"
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
crossterm = { version = "0.29", features = ["event-stream"] }
eventsource-stream = "0.2"
futures = "0.3"
globset = "0.4"
ignore = "0.4"
ratatui = "0.30"
regex = "1"
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "io-util", "fs", "time", "signal"] }
tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] }
similar = "2"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "io-util", "fs", "time", "process", "sync", "signal"] }
tokio-util = "0.7"
toml = "1"
unicode-width = "0.2"
+552
View File
@@ -0,0 +1,552 @@
//! The local agent loop: model requests (server gateway or own provider),
//! tool calls with permission checks, subagents, compaction, persistence.
use crate::conf::Config;
use crate::models::{Catalog, ModelInfo};
use crate::server::Server;
use crate::session::LocalSession;
use crate::tools::{self, Ctx, ToolSpec};
use anyhow::{Result, anyhow};
use codecraft_core::config::PermissionConfig;
use codecraft_core::permission::{Decision, Evaluator, bash_prefix, split_command};
use codecraft_core::provider::StreamEvent;
use codecraft_core::types::{ImageData, Message, Part, Question, Role, Usage};
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use tokio::sync::{mpsc, oneshot};
use tokio_util::sync::CancellationToken;
const MAX_STEPS: usize = 200;
const COMPACT_AT: f64 = 0.85;
/// A mode (agent) as defined by the server or built in.
#[derive(Debug, Clone)]
pub struct Mode {
pub name: String,
pub description: String,
pub primary: bool,
pub prompt: String,
pub read_only: bool,
pub tools: Option<Vec<String>>,
pub model: Option<String>,
}
pub fn builtin_modes() -> Vec<Mode> {
use codecraft_core::prompts::*;
let m = |name: &str, description: &str, primary: bool, prompt: String, read_only: bool| Mode { name: name.into(), description: description.into(), primary, prompt, read_only, tools: None, model: None };
vec![
m("build", "Full access: implement, edit, run", true, BUILD_PROMPT.into(), false),
m("plan", "Read-only: analyse and propose a plan", true, PLAN_PROMPT.into(), true),
m("craft", "Study a target, build your own version, check and refine", true, craft_prompt(true), false),
m("general", "General-purpose subagent for multi-step tasks", false, GENERAL_PROMPT.into(), false),
m("explore", "Read-only subagent for fast codebase searches", false, EXPLORE_PROMPT.into(), true),
]
}
pub fn modes_from_server(cfg: &Value) -> Vec<Mode> {
cfg["agents"]
.as_array()
.into_iter()
.flatten()
.map(|a| Mode {
name: a["name"].as_str().unwrap_or("").into(),
description: a["description"].as_str().unwrap_or("").into(),
primary: a["mode"] == "primary",
prompt: a["prompt"].as_str().unwrap_or("").into(),
read_only: a["read_only"].as_bool().unwrap_or(false),
tools: a["tools"].as_array().map(|t| t.iter().filter_map(|x| x.as_str().map(String::from)).collect()),
model: a["model"].as_str().map(|m| format!("{}{m}", crate::models::SERVER_PREFIX)),
})
.filter(|m| !m.name.is_empty())
.collect()
}
/// Everything the agent needs from the environment (shared, read-mostly).
pub struct Env {
pub cfg: Config,
pub server: Option<Server>,
pub server_cfg: Option<Value>,
pub catalog: Catalog,
pub modes: Vec<Mode>,
pub permissions: PermissionConfig,
pub base_prompt: String,
pub auto_prompt: String,
pub remote_tools: Vec<ToolSpec>,
pub server_skills: Vec<(String, String)>,
pub memory: String,
}
impl Env {
pub fn mode(&self, name: &str) -> Mode {
self.modes.iter().find(|m| m.name == name).cloned().unwrap_or_else(|| builtin_modes().remove(0))
}
}
pub enum Reply {
Once,
Always,
Reject(Option<String>),
}
/// Events for the UI.
pub enum UiEvent {
Busy(bool),
Text(String),
Reasoning(String),
ToolStart { id: String, name: String, input: Value },
ToolDone { id: String, content: String, is_error: bool, diff: Option<String> },
Todos(Value),
Usage { context: u64, total_cost: f64 },
Permission { tool: String, title: String, input: Value, diff: Option<String>, reply: oneshot::Sender<Reply> },
Question { questions: Vec<Question>, reply: oneshot::Sender<Option<serde_json::Map<String, Value>>> },
Info(String),
Error(String),
/// A new step (model request) begins.
Step,
}
pub struct Agent {
pub env: Arc<Env>,
pub session: LocalSession,
approved: HashSet<String>,
read_files: Arc<Mutex<HashSet<PathBuf>>>,
shells: Arc<Mutex<BTreeMap<String, Arc<tools::Shell>>>>,
ui: mpsc::UnboundedSender<UiEvent>,
}
fn instruction_files(cwd: &Path) -> Vec<(PathBuf, String)> {
let mut out = Vec::new();
let home = crate::conf::home();
let mut dirs: Vec<PathBuf> = cwd.ancestors().take_while(|d| *d != home && d.parent().is_some()).map(Path::to_path_buf).collect();
dirs.reverse();
for d in dirs {
for name in ["AGENTS.md", "CLAUDE.md"] {
let p = d.join(name);
if let Ok(text) = std::fs::read_to_string(&p) {
out.push((p, text.chars().take(40_000).collect()));
break;
}
}
}
out
}
impl Agent {
pub fn new(env: Arc<Env>, session: LocalSession, ui: mpsc::UnboundedSender<UiEvent>) -> Self {
Self { env, session, approved: HashSet::new(), read_files: Arc::default(), shells: Arc::default(), ui }
}
fn emit(&self, ev: UiEvent) {
let _ = self.ui.send(ev);
}
fn model(&self, spec: &str) -> Result<ModelInfo> {
self.env.catalog.find(spec).cloned().ok_or_else(|| anyhow!("model '{spec}' is not available – choose one with /model"))
}
fn tools_for(&self, mode: &Mode, depth: usize) -> Vec<ToolSpec> {
let mut all = tools::local_tools();
all.extend(self.env.remote_tools.iter().map(|t| ToolSpec { name: t.name.clone(), description: t.description.clone(), schema: t.schema.clone(), read_only: t.read_only, is_edit: false, remote: true }));
all.into_iter()
.filter(|t| !mode.read_only || t.read_only)
.filter(|t| !(t.name == "task" && depth >= 2))
.filter(|t| match &mode.tools {
None => true,
Some(list) => list.iter().any(|a| a.eq_ignore_ascii_case(&t.name) || (t.remote && a == "mcp")),
})
.collect()
}
fn system_prompt(&self, mode: &Mode, model: &str) -> String {
let env = &self.env;
let cwd = PathBuf::from(&self.session.cwd);
let mut s = format!("{}\n\n{}", env.base_prompt, mode.prompt);
if self.session.permission_mode == "auto" && !mode.read_only {
s.push_str(&env.auto_prompt);
}
s.push_str(&format!(
"\n\n# Environment\n- Working directory: {}\n- Git repository: {}\n- Platform: {} ({}), local machine of the user (no sandbox – commands run with the user's rights)\n- Model: {model}\n- Date: {}\n",
cwd.display(),
if cwd.join(".git").exists() { "yes" } else { "no" },
std::env::consts::OS,
std::env::consts::ARCH,
chrono::Local::now().format("%Y-%m-%d"),
));
let subs: Vec<&Mode> = env.modes.iter().filter(|m| !m.primary).collect();
if !mode.read_only && !subs.is_empty() {
s.push_str("\n# Subagents\nAvailable for the task tool (subagent_type):\n");
for m in subs {
s.push_str(&format!("- {}{}: {}\n", m.name, if m.read_only { " (read-only)" } else { "" }, m.description));
}
}
let mut skills: Vec<(String, String)> = env.server_skills.clone();
for name in tools::builtin_skill_names() {
if !skills.iter().any(|(n, _)| n == name) {
let desc = tools::BUILTIN
.iter()
.find(|(p, _)| *p == format!("skills/{name}/SKILL.md"))
.map(|(_, c)| codecraft_core_frontmatter(c).0.get("description").cloned().unwrap_or_default())
.unwrap_or_default();
skills.push((name.to_string(), desc));
}
}
for (name, p) in tools::project_skills(&cwd) {
let desc = std::fs::read_to_string(&p).map(|c| codecraft_core_frontmatter(&c).0.get("description").cloned().unwrap_or_default()).unwrap_or_default();
skills.retain(|(n, _)| n != &name);
skills.push((name, desc));
}
if !skills.is_empty() {
s.push_str("\n# Skills\nLoad a skill with the skill tool when the task matches its description:\n");
for (n, d) in &skills {
s.push_str(&format!("- {n}: {d}\n"));
}
}
if !env.memory.trim().is_empty() {
s.push_str(&format!("\n# About the user (personal memory)\n{}\n", env.memory.trim()));
}
if let Ok(mem) = std::fs::read_to_string(cwd.join(".codecraft").join("memory.md")) {
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::<String>()));
} else if !mode.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");
}
let files = instruction_files(&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
}
/// One model request; returns the assistant message and the stop reason.
async fn step(&mut self, model: &ModelInfo, system: String, messages: Vec<Message>, tools: &[ToolSpec], effort: Option<String>, mode: &str, cancel: &CancellationToken, live: bool) -> Result<(Message, String, Usage)> {
let (tx, mut rx) = mpsc::channel(256);
let env = self.env.clone();
let m = model.clone();
let defs = tools.iter().map(ToolSpec::def).collect();
let sid = self.session.id.clone();
let agent = mode.to_string();
let c = cancel.clone();
let handle = tokio::spawn(async move { crate::models::stream(&env.cfg, env.server.as_ref(), &m, system, messages, defs, effort, &sid, &agent, tx, c).await });
let mut parts: Vec<Part> = Vec::new();
let mut usage = Usage::default();
let mut stop = String::new();
while let Some(ev) = rx.recv().await {
match ev {
StreamEvent::TextDelta(t) => {
if live {
self.emit(UiEvent::Text(t.clone()));
}
match parts.last_mut() {
Some(Part::Text { text }) => text.push_str(&t),
_ => parts.push(Part::Text { text: t }),
}
}
StreamEvent::ReasoningDelta(t) => {
if live {
self.emit(UiEvent::Reasoning(t.clone()));
}
match parts.last_mut() {
Some(Part::Reasoning { text, .. }) => text.push_str(&t),
_ => parts.push(Part::Reasoning { text: t, signature: None }),
}
}
StreamEvent::ReasoningSignature(sig) => {
if let Some(Part::Reasoning { signature, .. }) = parts.iter_mut().rev().find(|p| matches!(p, Part::Reasoning { .. })) {
*signature = Some(sig);
}
}
StreamEvent::ToolCall { id, name, input, extra } => parts.push(Part::ToolCall { id, name, input, extra }),
StreamEvent::Usage(u) => usage = u,
StreamEvent::Done { stop_reason } => stop = stop_reason,
}
}
handle.await.map_err(|e| anyhow!("{e}"))??;
let mut msg = Message::new(Role::Assistant, parts);
msg.model = Some(model.spec.clone());
Ok((msg, stop, usage))
}
fn account(&mut self, model: &ModelInfo, usage: &Usage) {
let cost = model.cost(usage);
self.session.usage.input_tokens += usage.input_tokens;
self.session.usage.output_tokens += usage.output_tokens;
self.session.usage.cache_read_tokens += usage.cache_read_tokens;
self.session.usage.cache_write_tokens += usage.cache_write_tokens;
self.session.cost += cost;
self.session.context_tokens = usage.context_tokens();
crate::stats::record(&self.session, &model.spec, usage, cost);
self.emit(UiEvent::Usage { context: usage.context_tokens(), total_cost: self.session.cost });
}
/// Run one user turn.
pub async fn prompt(&mut self, text: String, images: Vec<ImageData>, effort: Option<String>, cancel: CancellationToken) {
self.emit(UiEvent::Busy(true));
if self.session.title.is_empty() {
self.session.title = text.lines().next().unwrap_or("").chars().take(70).collect();
}
let mut parts = vec![Part::Text { text }];
parts.extend(images.into_iter().map(|image| Part::Image { image }));
self.session.messages.push(Message::new(Role::User, parts));
if let Err(e) = self.run_loop(effort, &cancel).await {
self.emit(UiEvent::Error(format!("{e:#}")));
}
let _ = self.session.save();
self.emit(UiEvent::Busy(false));
}
async fn run_loop(&mut self, effort: Option<String>, cancel: &CancellationToken) -> Result<()> {
for _ in 0..MAX_STEPS {
if cancel.is_cancelled() {
break;
}
let mode = self.env.mode(&self.session.mode);
let model = self.model(&mode.model.clone().unwrap_or_else(|| self.session.model.clone()))?;
// Compact before the context window overflows.
if let Some(ctx) = model.config.context {
if self.session.context_tokens as f64 > ctx as f64 * COMPACT_AT {
self.compact(&model, cancel).await?;
}
}
let tools = self.tools_for(&mode, 0);
self.emit(UiEvent::Step);
let system = self.system_prompt(&mode, &model.spec);
let (msg, stop, usage) = self.step(&model, system, self.session.active(), &tools, effort.clone(), &mode.name, cancel, true).await?;
self.account(&model, &usage);
let calls: Vec<(String, String, Value)> = msg.parts.iter().filter_map(|p| if let Part::ToolCall { id, name, input, .. } = p { Some((id.clone(), name.clone(), input.clone())) } else { None }).collect();
self.session.messages.push(msg);
let _ = self.session.save();
if calls.is_empty() {
if stop == "max_tokens" || stop == "length" {
self.emit(UiEvent::Error("The answer hit the output token limit.".into()));
}
break;
}
let mut results = Vec::new();
for (id, name, input) in calls {
if cancel.is_cancelled() {
results.push(tool_result(&id, &name, "Aborted by user".into(), true));
continue;
}
let part = self.execute(&tools, &id, &name, input, &mode, cancel, 0).await;
results.push(part);
}
self.session.messages.push(Message::new(Role::User, results));
let _ = self.session.save();
}
Ok(())
}
fn ctx(&self, cancel: &CancellationToken) -> Ctx {
Ctx {
cwd: PathBuf::from(&self.session.cwd),
cancel: cancel.clone(),
read_files: self.read_files.clone(),
shells: self.shells.clone(),
server: self.env.server.clone(),
server_skills: self.env.server_skills.iter().map(|(n, _)| n.clone()).collect(),
}
}
/// Permission check, then run the tool.
#[allow(clippy::too_many_arguments)]
fn execute<'a>(&'a mut self, tools: &'a [ToolSpec], id: &'a str, name: &'a str, input: Value, _mode: &'a Mode, cancel: &'a CancellationToken, depth: usize) -> futures::future::BoxFuture<'a, Part> {
Box::pin(async move {
let Some(spec) = tools.iter().find(|t| t.name == name) else {
return tool_result(id, name, format!("Unknown or unavailable tool '{name}' in this mode."), true);
};
if depth == 0 {
self.emit(UiEvent::ToolStart { id: id.into(), name: name.into(), input: input.clone() });
}
let decision = Evaluator { cfg: &self.env.permissions, mode: &self.session.permission_mode, approved: &self.approved }.evaluate(spec, &input);
match decision {
Decision::Deny => return self.finish(id, name, "Denied by the permission rules.".into(), true, None, depth),
Decision::Ask => {
let (tx, rx) = oneshot::channel();
let cwd = PathBuf::from(&self.session.cwd);
let title = match name {
"bash" => input["description"].as_str().or(input["command"].as_str()).unwrap_or("").to_string(),
_ => input["path"].as_str().map(|p| format!("{name} {p}")).unwrap_or_else(|| name.to_string()),
};
self.emit(UiEvent::Permission { tool: name.into(), title, input: input.clone(), diff: tools::preview(name, &input, &cwd), reply: tx });
let reply = tokio::select! {
r = rx => r.unwrap_or(Reply::Reject(None)),
_ = cancel.cancelled() => Reply::Reject(Some("aborted".into())),
};
match reply {
Reply::Reject(msg) => {
let mut text = "The user rejected this tool call.".to_string();
if let Some(m) = msg.filter(|m| !m.trim().is_empty()) {
text.push_str(&format!(" User feedback: {m}"));
}
return self.finish(id, name, text, true, None, depth);
}
Reply::Always => {
if name == "bash" {
for part in split_command(input["command"].as_str().unwrap_or("")).unwrap_or_default() {
self.approved.insert(format!("bash:{}", bash_prefix(&part)));
}
} else {
self.approved.insert(name.to_string());
}
}
Reply::Once => {}
}
}
Decision::Allow => {}
}
match name {
"ask_user" => {
let questions: Vec<Question> = serde_json::from_value(input["questions"].clone()).unwrap_or_default();
let (tx, rx) = oneshot::channel();
self.emit(UiEvent::Question { questions, reply: tx });
let answers = tokio::select! { r = rx => r.ok().flatten(), _ = cancel.cancelled() => None };
match answers {
Some(a) => self.finish(id, name, serde_json::to_string_pretty(&a).unwrap_or_default(), false, None, depth),
None => self.finish(id, name, "The user dismissed the questions without answering.".into(), true, None, depth),
}
}
"todo_write" => {
self.session.todos = input["todos"].clone();
self.emit(UiEvent::Todos(input["todos"].clone()));
let ctx = self.ctx(cancel);
match tools::run(&ctx, name, &input).await {
Ok(o) => self.finish(id, name, o.content, false, None, depth),
Err(e) => self.finish(id, name, format!("Error: {e:#}"), true, None, depth),
}
}
"task" => {
let text = self.subagent(&input, cancel, depth + 1).await;
match text {
Ok(t) => self.finish(id, name, t, false, None, depth),
Err(e) => self.finish(id, name, format!("Error: {e:#}"), true, None, depth),
}
}
_ if spec.remote => {
let server = self.env.server.clone();
match server {
Some(s) => match s.tool(name, input).await {
Ok((content, err)) => self.finish(id, name, content, err, None, depth),
Err(e) => self.finish(id, name, format!("Error: {e:#}"), true, None, depth),
},
None => self.finish(id, name, "not connected to the server".into(), true, None, depth),
}
}
_ => {
let ctx = self.ctx(cancel);
match tools::run(&ctx, name, &input).await {
Ok(o) => {
let images = o.images.clone();
let mut part = self.finish(id, name, o.content, o.is_error, o.diff, depth);
if let Part::ToolResult { images: imgs, .. } = &mut part {
*imgs = images;
}
part
}
Err(e) => self.finish(id, name, format!("Error: {e:#}"), true, None, depth),
}
}
}
})
}
fn finish(&self, id: &str, name: &str, content: String, is_error: bool, diff: Option<String>, depth: usize) -> Part {
if depth == 0 {
self.emit(UiEvent::ToolDone { id: id.into(), content: content.clone(), is_error, diff: diff.clone() });
}
let mut part = tool_result(id, name, content, is_error);
if let (Some(d), Part::ToolResult { meta, .. }) = (diff, &mut part) {
*meta = Some(json!({"diff": d}));
}
part
}
/// Run a subagent with a fresh context; returns its final report.
async fn subagent(&mut self, input: &Value, cancel: &CancellationToken, depth: usize) -> Result<String> {
let kind = input["subagent_type"].as_str().unwrap_or("general");
let parent = self.env.mode(&self.session.mode);
let mut mode = self.env.modes.iter().find(|m| m.name == kind && !m.primary).cloned().unwrap_or_else(|| builtin_modes().into_iter().find(|m| m.name == "general").unwrap());
if parent.read_only && !mode.read_only {
mode = builtin_modes().into_iter().find(|m| m.name == "explore").unwrap();
}
let model = self.model(&mode.model.clone().unwrap_or_else(|| self.session.model.clone()))?;
let tools = self.tools_for(&mode, depth);
let prompt = input["prompt"].as_str().unwrap_or("").to_string();
self.emit(UiEvent::Info(format!("subagent {kind}: {}", input["description"].as_str().unwrap_or(""))));
let mut messages = vec![Message::new(Role::User, vec![Part::Text { text: prompt }])];
let mut last = String::new();
for _ in 0..MAX_STEPS {
if cancel.is_cancelled() {
break;
}
let system = self.system_prompt(&mode, &model.spec);
let (msg, _, usage) = self.step(&model, system, messages.clone(), &tools, None, &mode.name, cancel, false).await?;
self.account(&model, &usage);
last = msg.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("");
let calls: Vec<(String, String, Value)> = msg.parts.iter().filter_map(|p| if let Part::ToolCall { id, name, input, .. } = p { Some((id.clone(), name.clone(), input.clone())) } else { None }).collect();
messages.push(msg);
if calls.is_empty() {
break;
}
let mut results = Vec::new();
for (id, name, input) in calls {
results.push(self.execute(&tools, &id, &name, input, &mode, cancel, depth).await);
}
messages.push(Message::new(Role::User, results));
}
Ok(if last.trim().is_empty() { "(the subagent returned no report)".into() } else { last })
}
/// Replace the history with a summary.
pub async fn compact(&mut self, model: &ModelInfo, cancel: &CancellationToken) -> Result<()> {
self.emit(UiEvent::Info("compacting the conversation…".into()));
let mut transcript = String::new();
for m in self.session.active() {
let who = if matches!(m.role, Role::User) { "USER" } else { "ASSISTANT" };
for p in &m.parts {
let line = match p {
Part::Text { text } | Part::Summary { text } => text.clone(),
Part::ToolCall { name, input, .. } => format!("[tool call {name}: {}]", input.to_string().chars().take(1500).collect::<String>()),
Part::ToolResult { name, content, .. } => format!("[result of {name}]\n{}", content.chars().take(3000).collect::<String>()),
_ => continue,
};
transcript.push_str(&format!("### {who}\n{line}\n"));
}
}
let prompt = format!("<transcript>\n{transcript}\n</transcript>\n\nSummarize this coding session so far so that the work can continue seamlessly from the summary alone: goals and explicit requests, decisions, files changed and their state, commands/tests and results, open problems and the exact next steps.");
let msgs = vec![Message::new(Role::User, vec![Part::Text { text: prompt }])];
let (msg, _, usage) = self.step(model, "You are a precise summarizer of software engineering sessions. Reply with the summary only.".into(), msgs, &[], None, "compact", cancel, false).await?;
self.account(model, &usage);
let summary: String = msg.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect();
if summary.trim().is_empty() {
return Err(anyhow!("compaction produced no summary"));
}
self.session.messages.push(Message::new(Role::User, vec![Part::Summary { text: summary }]));
self.session.context_tokens = 0;
self.read_files.lock().unwrap().clear();
let _ = self.session.save();
self.emit(UiEvent::Info("conversation compacted".into()));
Ok(())
}
}
fn tool_result(id: &str, name: &str, content: String, is_error: bool) -> Part {
Part::ToolResult { call_id: id.into(), name: name.into(), content, images: vec![], is_error, meta: None }
}
/// Parse "---\nkey: value\n---\nbody" frontmatter.
pub fn codecraft_core_frontmatter(text: &str) -> (BTreeMap<String, String>, String) {
let mut meta = BTreeMap::new();
let Some(rest) = text.strip_prefix("---\n") else { return (meta, text.to_string()) };
let Some(end) = rest.find("\n---") else { return (meta, text.to_string()) };
for line in rest[..end].lines() {
if let Some((k, v)) = line.split_once(':') {
meta.insert(k.trim().to_string(), v.trim().trim_matches('"').to_string());
}
}
let body = rest[end + 4..].trim_start_matches('\n').to_string();
(meta, body)
}
+1632
View File
File diff suppressed because it is too large. Load diff
+102
View File
@@ -0,0 +1,102 @@
//! Local configuration (~/.config/codecraft/config.toml, readable only by the user).
use anyhow::{Context, Result};
use codecraft_core::config::ProviderConfig;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerLogin {
pub url: String,
pub token: String,
#[serde(default)]
pub username: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
/// Signed-in CodeCraft server (models, modes and rules come from there).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server: Option<ServerLogin>,
/// Model used for new sessions ("provider/model", server models as "server:provider/model").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_model: Option<String>,
/// Permission mode for new sessions (default: auto).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permission_mode: Option<String>,
/// UI language (en, de); default from the server or $LANG.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lang: Option<String>,
/// Theme override, e.g. "nord" and "dark" (default: your server theme).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme_mode: Option<String>,
/// Own model providers (standalone mode), same format as the server config.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub providers: BTreeMap<String, ProviderConfig>,
}
pub fn config_dir() -> PathBuf {
if let Some(x) = std::env::var_os("CODECRAFT_CONFIG_DIR") {
return PathBuf::from(x);
}
#[cfg(windows)]
if let Some(x) = std::env::var_os("APPDATA") {
return PathBuf::from(x).join("codecraft");
}
let base = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).unwrap_or_else(|| home().join(".config"));
base.join("codecraft")
}
pub fn data_dir() -> PathBuf {
if let Some(x) = std::env::var_os("CODECRAFT_DATA_DIR") {
return PathBuf::from(x);
}
#[cfg(windows)]
if let Some(x) = std::env::var_os("LOCALAPPDATA") {
return PathBuf::from(x).join("codecraft");
}
let base = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).unwrap_or_else(|| home().join(".local").join("share"));
base.join("codecraft")
}
pub fn home() -> PathBuf {
std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."))
}
fn path() -> PathBuf {
config_dir().join("config.toml")
}
impl Config {
pub fn load() -> Result<Self> {
let p = path();
match std::fs::read_to_string(&p) {
Ok(text) => toml::from_str(&text).with_context(|| format!("invalid {}", p.display())),
Err(_) => Ok(Self::default()),
}
}
pub fn save(&self) -> Result<()> {
let p = path();
std::fs::create_dir_all(p.parent().unwrap())?;
let text = format!(
"# CodeCraft terminal client – edit with `codecraft setup` or by hand.\n# API keys may be written as \"${{ENV_VAR}}\".\n\n{}",
toml::to_string_pretty(self)?
);
std::fs::write(&p, text)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
/// Nothing set up yet: the setup wizard runs first.
pub fn is_empty(&self) -> bool {
self.server.is_none() && self.providers.is_empty()
}
}
+219
View File
@@ -0,0 +1,219 @@
//! Multi-line input field with history.
use unicode_width::UnicodeWidthStr;
#[derive(Default)]
pub struct Editor {
pub lines: Vec<String>,
/// Cursor: line index and char index.
pub row: usize,
pub col: usize,
history: Vec<String>,
hist_pos: Option<usize>,
}
fn byte_idx(s: &str, ch: usize) -> usize {
s.char_indices().nth(ch).map(|(i, _)| i).unwrap_or(s.len())
}
impl Editor {
pub fn new() -> Self {
let history = std::fs::read_to_string(crate::conf::data_dir().join("history.txt"))
.map(|t| t.lines().map(|l| l.replace("\\n", "\n")).collect())
.unwrap_or_default();
Self { lines: vec![String::new()], history, ..Default::default() }
}
pub fn text(&self) -> String {
self.lines.join("\n")
}
pub fn is_empty(&self) -> bool {
self.lines.iter().all(|l| l.is_empty())
}
pub fn set(&mut self, text: &str) {
self.lines = text.split('\n').map(String::from).collect();
if self.lines.is_empty() {
self.lines.push(String::new());
}
self.row = self.lines.len() - 1;
self.col = self.lines[self.row].chars().count();
}
pub fn clear(&mut self) {
self.lines = vec![String::new()];
self.row = 0;
self.col = 0;
self.hist_pos = None;
}
/// Take the text (and remember it in the history).
pub fn take(&mut self) -> String {
let t = self.text();
if !t.trim().is_empty() && self.history.last() != Some(&t) {
self.history.push(t.clone());
if self.history.len() > 500 {
self.history.remove(0);
}
let _ = std::fs::create_dir_all(crate::conf::data_dir());
let _ = std::fs::write(crate::conf::data_dir().join("history.txt"), self.history.iter().map(|h| h.replace('\n', "\\n")).collect::<Vec<_>>().join("\n"));
}
self.clear();
t
}
pub fn insert(&mut self, s: &str) {
for (i, part) in s.split('\n').enumerate() {
if i > 0 {
self.newline();
}
let part = part.replace('\r', "").replace('\t', " ");
let line = &mut self.lines[self.row];
let at = byte_idx(line, self.col);
line.insert_str(at, &part);
self.col += part.chars().count();
}
}
pub fn newline(&mut self) {
let line = &mut self.lines[self.row];
let at = byte_idx(line, self.col);
let rest = line.split_off(at);
self.lines.insert(self.row + 1, rest);
self.row += 1;
self.col = 0;
}
pub fn backspace(&mut self) {
if self.col > 0 {
let line = &mut self.lines[self.row];
let a = byte_idx(line, self.col - 1);
let b = byte_idx(line, self.col);
line.replace_range(a..b, "");
self.col -= 1;
} else if self.row > 0 {
let line = self.lines.remove(self.row);
self.row -= 1;
self.col = self.lines[self.row].chars().count();
self.lines[self.row].push_str(&line);
}
}
pub fn delete(&mut self) {
let len = self.lines[self.row].chars().count();
if self.col < len {
let line = &mut self.lines[self.row];
let a = byte_idx(line, self.col);
let b = byte_idx(line, self.col + 1);
line.replace_range(a..b, "");
} else if self.row + 1 < self.lines.len() {
let next = self.lines.remove(self.row + 1);
self.lines[self.row].push_str(&next);
}
}
pub fn delete_word(&mut self) {
let chars: Vec<char> = self.lines[self.row].chars().collect();
let mut i = self.col;
while i > 0 && chars[i - 1].is_whitespace() {
i -= 1;
}
while i > 0 && !chars[i - 1].is_whitespace() {
i -= 1;
}
let line = &mut self.lines[self.row];
let a = byte_idx(line, i);
let b = byte_idx(line, self.col);
line.replace_range(a..b, "");
self.col = i;
}
pub fn left(&mut self) {
if self.col > 0 {
self.col -= 1;
} else if self.row > 0 {
self.row -= 1;
self.col = self.lines[self.row].chars().count();
}
}
pub fn right(&mut self) {
if self.col < self.lines[self.row].chars().count() {
self.col += 1;
} else if self.row + 1 < self.lines.len() {
self.row += 1;
self.col = 0;
}
}
pub fn home(&mut self) {
self.col = 0;
}
pub fn end(&mut self) {
self.col = self.lines[self.row].chars().count();
}
/// Up: previous line, or older history entry on the first line.
pub fn up(&mut self) {
if self.row > 0 {
self.row -= 1;
self.col = self.col.min(self.lines[self.row].chars().count());
return;
}
if self.history.is_empty() {
return;
}
let pos = match self.hist_pos {
None if !self.is_empty() => return,
None => self.history.len() - 1,
Some(0) => 0,
Some(p) => p - 1,
};
self.hist_pos = Some(pos);
let h = self.history[pos].clone();
self.set(&h);
}
pub fn down(&mut self) {
if self.row + 1 < self.lines.len() {
self.row += 1;
self.col = self.col.min(self.lines[self.row].chars().count());
return;
}
if let Some(p) = self.hist_pos {
if p + 1 < self.history.len() {
self.hist_pos = Some(p + 1);
let h = self.history[p + 1].clone();
self.set(&h);
} else {
self.clear();
}
}
}
/// The word the cursor is in (for @file and /command completion): (start char, word).
pub fn current_word(&self) -> (usize, String) {
let chars: Vec<char> = self.lines[self.row].chars().collect();
let mut i = self.col.min(chars.len());
while i > 0 && !chars[i - 1].is_whitespace() {
i -= 1;
}
(i, chars[i..self.col.min(chars.len())].iter().collect())
}
pub fn replace_word(&mut self, start: usize, with: &str) {
let line = &mut self.lines[self.row];
let a = byte_idx(line, start);
let b = byte_idx(line, self.col);
line.replace_range(a..b, with);
self.col = start + with.chars().count();
}
/// Cursor position in display columns (for the terminal cursor).
pub fn cursor_x(&self) -> usize {
let line = &self.lines[self.row];
UnicodeWidthStr::width(&line[..byte_idx(line, self.col)])
}
}
+60
View File
@@ -0,0 +1,60 @@
//! UI texts (English and German).
use std::sync::atomic::{AtomicBool, Ordering};
static GERMAN: AtomicBool = AtomicBool::new(false);
pub fn set_lang(lang: &str) {
GERMAN.store(lang.starts_with("de"), Ordering::Relaxed);
}
pub fn system_lang() -> String {
std::env::var("LC_ALL").or_else(|_| std::env::var("LANG")).unwrap_or_default().chars().take(2).collect()
}
/// (key, English, German)
const TEXTS: &[(&str, &str, &str)] = &[
("placeholder", "Message… (/ commands, @ files, Shift+Enter new line)", "Nachricht… (/ Befehle, @ Dateien, Shift+Enter neue Zeile)"),
("busy_placeholder", "Message is queued and sent after this turn…", "Nachricht wird nach diesem Schritt gesendet…"),
("permission", "Permission required", "Freigabe erforderlich"),
("perm_keys", "[y] allow once [a] always in this session [n] reject [f] reject with feedback", "[y] einmal erlauben [a] immer in dieser Session [n] ablehnen [f] ablehnen mit Hinweis"),
("feedback", "Tell the agent what to do instead (Enter to send):", "Was soll der Agent stattdessen tun? (Enter zum Senden):"),
("question", "Question", "Frage"),
("question_keys", "↑↓ choose · Enter confirm · type your own answer · Esc dismiss", "↑↓ wählen · Enter bestätigen · eigene Antwort tippen · Esc verwerfen"),
("thinking", "Thinking", "Denkt nach"),
("working", "Working", "Arbeitet"),
("interrupt", "Esc to interrupt", "Esc unterbricht"),
("mode", "mode", "Modus"),
("context", "context", "Kontext"),
("quit_again", "Press Ctrl+C again to quit", "Nochmal Ctrl+C zum Beenden"),
("aborted", "interrupted", "unterbrochen"),
("no_model", "No model available – run /setup", "Kein Modell verfügbar – /setup ausführen"),
("help", "Commands", "Befehle"),
("choose_model", "Choose a model", "Modell wählen"),
("choose_mode", "Choose a mode", "Modus wählen"),
("choose_session", "Resume a session", "Session fortsetzen"),
("choose_theme", "Choose a theme", "Theme wählen"),
("filter", "type to filter", "tippen zum Filtern"),
("queued", "queued", "in der Warteschlange"),
("welcome", "Welcome to CodeCraft", "Willkommen bei CodeCraft"),
("setup_title", "Set up CodeCraft", "CodeCraft einrichten"),
("setup_intro", "Where should the models come from? You can add more later with /setup.", "Woher sollen die Modelle kommen? Weitere kannst du später mit /setup hinzufügen."),
("setup_server", "Sign in to a CodeCraft server", "Bei einem CodeCraft-Server anmelden"),
("setup_provider", "Add a model provider (API key or local)", "Modell-Anbieter hinzufügen (API-Key oder lokal)"),
("setup_done", "Done", "Fertig"),
("server_url", "Server URL", "Server-Adresse"),
("username", "Username", "Benutzer"),
("password", "Password", "Passwort"),
("api_key", "API key", "API-Key"),
("base_url", "Endpoint URL", "Endpunkt-URL"),
("provider_name", "Name for this provider", "Name für diesen Anbieter"),
("detected", "running locally", "läuft lokal"),
("saved", "Saved.", "Gespeichert."),
("models_found", "models found", "Modelle gefunden"),
("enter_next", "Enter = next · Esc = back", "Enter = weiter · Esc = zurück"),
];
pub fn t(key: &str) -> &'static str {
let de = GERMAN.load(Ordering::Relaxed);
TEXTS.iter().find(|(k, _, _)| *k == key).map(|(_, en, d)| if de { *d } else { *en }).unwrap_or("?")
}
+333 -630
View File
File diff suppressed because it is too large. Load diff
+152
View File
@@ -0,0 +1,152 @@
//! Model catalog: the server's models ("server:provider/model") and the user's
//! own providers ("provider/model"), and streaming requests to either.
use crate::conf::Config;
use crate::server::Server;
use anyhow::{Result, anyhow};
use codecraft_core::config::ModelConfig;
use codecraft_core::provider::{ChatRequest, StreamEvent, ToolDef};
use codecraft_core::types::{Message, Usage};
use serde_json::Value;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
pub const SERVER_PREFIX: &str = "server:";
#[derive(Debug, Clone)]
pub struct ModelInfo {
/// Selection key, e.g. "server:gpu/qwen-coder" or "ollama/llama3".
pub spec: String,
pub label: String,
pub config: ModelConfig,
}
impl ModelInfo {
pub fn cost(&self, u: &Usage) -> f64 {
self.config.cost(u)
}
}
#[derive(Default, Clone)]
pub struct Catalog {
pub models: Vec<ModelInfo>,
pub currency: String,
}
fn model_from_json(v: &Value) -> ModelConfig {
serde_json::from_value(v.clone()).unwrap_or_else(|_| ModelConfig { id: v["id"].as_str().unwrap_or("").into(), ..Default::default() })
}
impl Catalog {
/// Collect models from the server config (if signed in) and local providers.
pub async fn load(cfg: &Config, server_cfg: Option<&Value>) -> Self {
let mut models = Vec::new();
let mut currency = String::new();
if let Some(sc) = server_cfg {
currency = sc["currency"].as_str().unwrap_or("").to_string();
for p in sc["models"].as_array().into_iter().flatten() {
let provider = p["provider"].as_str().unwrap_or("");
for m in p["models"].as_array().into_iter().flatten() {
let config = model_from_json(m);
let name = m["name"].as_str().unwrap_or(&config.id).to_string();
models.push(ModelInfo { spec: format!("{SERVER_PREFIX}{provider}/{}", config.id), label: format!("server · {provider} · {name}"), config });
}
}
}
for (name, pc) in &cfg.providers {
if !pc.enabled {
continue;
}
let mut list: Vec<ModelConfig> = pc.models.iter().map(|m| m.resolved()).collect();
if list.is_empty() {
// Discover (e.g. Ollama, LM Studio) – quick, failures leave the list empty.
let provider = codecraft_core::provider::build(codecraft_core::provider::http_client(), pc);
if let Ok(Ok(found)) = tokio::time::timeout(std::time::Duration::from_secs(5), provider.list_models()).await {
list = found;
}
}
for m in list {
let label = format!("{name} · {}", m.name.clone().unwrap_or_else(|| m.id.clone()));
models.push(ModelInfo { spec: format!("{name}/{}", m.id), label, config: m });
}
}
Self { models, currency }
}
pub fn find(&self, spec: &str) -> Option<&ModelInfo> {
self.models.iter().find(|m| m.spec == spec)
}
}
/// Stream one model request to the right backend.
#[allow(clippy::too_many_arguments)]
pub async fn stream(
cfg: &Config,
server: Option<&Server>,
model: &ModelInfo,
system: String,
messages: Vec<Message>,
tools: Vec<ToolDef>,
effort: Option<String>,
session: &str,
agent: &str,
tx: mpsc::Sender<StreamEvent>,
cancel: CancellationToken,
) -> Result<()> {
if let Some(rest) = model.spec.strip_prefix(SERVER_PREFIX) {
let server = server.ok_or_else(|| anyhow!("not signed in to a CodeCraft server (codecraft login)"))?;
return server.stream(rest, system, messages, tools, effort, session, agent, tx, cancel).await;
}
let (pname, _) = model.spec.split_once('/').ok_or_else(|| anyhow!("invalid model '{}'", model.spec))?;
let pc = cfg.providers.get(pname).ok_or_else(|| anyhow!("unknown provider '{pname}' – run /setup"))?;
let provider = codecraft_core::provider::build(codecraft_core::provider::http_client(), pc);
let mut mc = model.config.clone();
if let Some(e) = effort {
mc.effort = Some(e);
}
let max_tokens = mc.max_output.unwrap_or(16_000);
let req = ChatRequest { model: mc, system, messages, tools, max_tokens, reasoning: true };
provider.stream(req, tx, cancel).await
}
/// Known endpoints for the setup wizard.
pub struct Preset {
pub id: &'static str,
pub label: &'static str,
pub kind: codecraft_core::config::ProviderKind,
pub base_url: &'static str,
pub needs_key: bool,
pub key_hint: &'static str,
}
pub fn presets() -> Vec<Preset> {
use codecraft_core::config::ProviderKind::*;
vec![
Preset { id: "anthropic", label: "Anthropic (Claude)", kind: Anthropic, base_url: "", needs_key: true, key_hint: "sk-ant-… from console.anthropic.com" },
Preset { id: "openai", label: "OpenAI", kind: Openai, base_url: "https://api.openai.com/v1", needs_key: true, key_hint: "sk-… from platform.openai.com" },
Preset { id: "gemini", label: "Google Gemini", kind: Openai, base_url: "https://generativelanguage.googleapis.com/v1beta/openai", needs_key: true, key_hint: "key from aistudio.google.com" },
Preset { id: "openrouter", label: "OpenRouter", kind: Openai, base_url: "https://openrouter.ai/api/v1", needs_key: true, key_hint: "sk-or-… from openrouter.ai" },
Preset { id: "opencode", label: "opencode Zen / Go", kind: Openai, base_url: "https://opencode.ai/zen/v1", needs_key: true, key_hint: "key from opencode.ai" },
Preset { id: "ollama", label: "Ollama (local)", kind: Openai, base_url: "http://localhost:11434/v1", needs_key: false, key_hint: "" },
Preset { id: "lmstudio", label: "LM Studio (local)", kind: Openai, base_url: "http://localhost:1234/v1", needs_key: false, key_hint: "" },
Preset { id: "llamacpp", label: "llama.cpp / vLLM (local)", kind: Openai, base_url: "http://localhost:8080/v1", needs_key: false, key_hint: "" },
Preset { id: "custom", label: "Other OpenAI-compatible endpoint", kind: Openai, base_url: "", needs_key: false, key_hint: "optional" },
]
}
/// Local model servers that answer right now (for the wizard).
pub async fn detect_local() -> Vec<&'static str> {
let mut found = Vec::new();
for p in presets().into_iter().filter(|p| p.base_url.starts_with("http://localhost")) {
let ok = codecraft_core::provider::http_client()
.get(format!("{}/models", p.base_url))
.timeout(std::time::Duration::from_millis(800))
.send()
.await
.is_ok_and(|r| r.status().is_success());
if ok {
found.push(p.id);
}
}
found
}
+812
View File
@@ -0,0 +1,812 @@
{
"ash-dark": {
"accent": "#e0914f",
"accent2": "#d6b46a",
"aqua": "#8aa89a",
"bg0": "#1c1b1f",
"bg1": "#232226",
"bg2": "#29282c",
"bg3": "#3a383d",
"blue": "#7c9aa1",
"fg0": "#f3ede1",
"fg1": "#e6dfd1",
"fg2": "#cfc7b8",
"fg3": "#b0a89b",
"green": "#8f9a5c",
"muted": "#999187",
"on-accent": "#1c1b1f",
"purple": "#b47e99",
"red": "#c15c46",
"syntax-comment": "#6d6a63",
"syntax-function": "#7c9aa1",
"syntax-keyword": "#c15c46",
"syntax-meta": "#e0914f",
"syntax-number": "#a585ac",
"syntax-string": "#8f9a5c",
"syntax-type": "#d6b46a",
"syntax-variable": "#e6dfd1"
},
"ash-light": {
"accent": "#a85f1d",
"accent2": "#8a6a24",
"aqua": "#3d6b72",
"bg0": "#e9e3d5",
"bg1": "#dcd5c3",
"bg2": "#d2cab7",
"bg3": "#c9c1ad",
"blue": "#3d6b72",
"fg0": "#1e1c18",
"fg1": "#2a2822",
"fg2": "#433f37",
"fg3": "#5a554b",
"green": "#5c6631",
"muted": "#6f685c",
"on-accent": "#ffffff",
"purple": "#7c4a66",
"red": "#96392a",
"syntax-comment": "#8a8274",
"syntax-function": "#3d6b72",
"syntax-keyword": "#96392a",
"syntax-meta": "#a85f1d",
"syntax-number": "#6a4c72",
"syntax-string": "#5c6631",
"syntax-type": "#8a6a24",
"syntax-variable": "#2a2822"
},
"catppuccin-dark": {
"accent": "#cba6f7",
"accent2": "#fab387",
"aqua": "#94e2d5",
"bg0": "#1e1e2e",
"bg1": "#181825",
"bg2": "#313244",
"bg3": "#45475a",
"blue": "#89b4fa",
"fg0": "#cdd6f4",
"fg1": "#cdd6f4",
"fg2": "#bac2de",
"fg3": "#a6adc8",
"green": "#a6e3a1",
"muted": "#9399b2",
"on-accent": "#1e1e2e",
"purple": "#f5c2e7",
"red": "#f38ba8",
"syntax-comment": "#7f849c",
"syntax-function": "#89b4fa",
"syntax-keyword": "#cba6f7",
"syntax-meta": "#f5c2e7",
"syntax-number": "#fab387",
"syntax-string": "#a6e3a1",
"syntax-type": "#f9e2af",
"syntax-variable": "#cdd6f4"
},
"catppuccin-light": {
"accent": "#8839ef",
"accent2": "#fe640b",
"aqua": "#179299",
"bg0": "#eff1f5",
"bg1": "#e6e9ef",
"bg2": "#ccd0da",
"bg3": "#bcc0cc",
"blue": "#1e66f5",
"fg0": "#4c4f69",
"fg1": "#4c4f69",
"fg2": "#5c5f77",
"fg3": "#6c6f85",
"green": "#40a02b",
"muted": "#6c6f85",
"on-accent": "#ffffff",
"purple": "#ea76cb",
"red": "#d20f39",
"syntax-comment": "#8c8fa1",
"syntax-function": "#1e66f5",
"syntax-keyword": "#8839ef",
"syntax-meta": "#ea76cb",
"syntax-number": "#fe640b",
"syntax-string": "#40a02b",
"syntax-type": "#df8e1d",
"syntax-variable": "#4c4f69"
},
"coffee-dark": {
"accent": "#e0a15c",
"accent2": "#ffda92",
"aqua": "#9fb8a8",
"bg0": "#1d150f",
"bg1": "#2d2118",
"bg2": "#3a2a20",
"bg3": "#4a372a",
"blue": "#a3b4c7",
"fg0": "#f8eee0",
"fg1": "#f0e2d0",
"fg2": "#d8c6b0",
"fg3": "#b09a84",
"green": "#b5b27a",
"muted": "#8f7a67",
"on-accent": "#1d150f",
"purple": "#cf90a2",
"red": "#d78a6f",
"syntax-comment": "#8f7a67",
"syntax-function": "#e0a15c",
"syntax-keyword": "#d78a6f",
"syntax-meta": "#ec8f89",
"syntax-number": "#cf90a2",
"syntax-string": "#ffda92",
"syntax-type": "#ec8f89",
"syntax-variable": "#f0e2d0"
},
"coffee-light": {
"accent": "#8e2d53",
"accent2": "#b25d76",
"aqua": "#3f6b5e",
"bg0": "#e8e3c3",
"bg1": "#ddd8b8",
"bg2": "#d1ccad",
"bg3": "#c5c1a6",
"blue": "#8e2d53",
"fg0": "#1f1013",
"fg1": "#2d1a1d",
"fg2": "#45302f",
"fg3": "#5d4744",
"green": "#4f6b00",
"muted": "#6e5a55",
"on-accent": "#ffffff",
"purple": "#7a003b",
"red": "#a4405e",
"syntax-comment": "#8a7d70",
"syntax-function": "#8e2d53",
"syntax-keyword": "#a4405e",
"syntax-meta": "#b25d76",
"syntax-number": "#7a003b",
"syntax-string": "#4f6b00",
"syntax-type": "#b25d76",
"syntax-variable": "#2d1a1d"
},
"dracula-dark": {
"accent": "#bd93f9",
"accent2": "#ffb86c",
"aqua": "#8be9fd",
"bg0": "#282a36",
"bg1": "#21222c",
"bg2": "#343746",
"bg3": "#44475a",
"blue": "#8be9fd",
"fg0": "#f8f8f2",
"fg1": "#f8f8f2",
"fg2": "#e2e2dc",
"fg3": "#c0c0ba",
"green": "#50fa7b",
"muted": "#9aa3c9",
"on-accent": "#282a36",
"purple": "#ff79c6",
"red": "#ff5555",
"syntax-comment": "#6272a4",
"syntax-function": "#50fa7b",
"syntax-keyword": "#ff79c6",
"syntax-meta": "#ffb86c",
"syntax-number": "#bd93f9",
"syntax-string": "#f1fa8c",
"syntax-type": "#8be9fd",
"syntax-variable": "#f8f8f2"
},
"dracula-light": {
"accent": "#644ac9",
"accent2": "#a34d14",
"aqua": "#036a96",
"bg0": "#fffbeb",
"bg1": "#f5f1dc",
"bg2": "#efeddc",
"bg3": "#cfcfde",
"blue": "#036a96",
"fg0": "#1f1f1f",
"fg1": "#1f1f1f",
"fg2": "#3a3a3a",
"fg3": "#6c664b",
"green": "#14710a",
"muted": "#6c664b",
"on-accent": "#ffffff",
"purple": "#a3144d",
"red": "#cb3a2a",
"syntax-comment": "#6c664b",
"syntax-function": "#14710a",
"syntax-keyword": "#a3144d",
"syntax-meta": "#a34d14",
"syntax-number": "#644ac9",
"syntax-string": "#846e15",
"syntax-type": "#036a96",
"syntax-variable": "#1f1f1f"
},
"everforest-dark": {
"accent": "#a7c080",
"accent2": "#e69875",
"aqua": "#83c092",
"bg0": "#2d353b",
"bg1": "#343f44",
"bg2": "#3d484d",
"bg3": "#475258",
"blue": "#7fbbb3",
"fg0": "#e5dcc4",
"fg1": "#d3c6aa",
"fg2": "#c0b597",
"fg3": "#9da9a0",
"green": "#a7c080",
"muted": "#859289",
"on-accent": "#2d353b",
"purple": "#d699b6",
"red": "#e67e80",
"syntax-comment": "#859289",
"syntax-function": "#a7c080",
"syntax-keyword": "#e67e80",
"syntax-meta": "#e69875",
"syntax-number": "#d699b6",
"syntax-string": "#dbbc7f",
"syntax-type": "#7fbbb3",
"syntax-variable": "#d3c6aa"
},
"everforest-light": {
"accent": "#8da101",
"accent2": "#f57d26",
"aqua": "#35a77c",
"bg0": "#fdf6e3",
"bg1": "#f4f0d9",
"bg2": "#efebd4",
"bg3": "#e0dcc7",
"blue": "#3a94c5",
"fg0": "#4a565c",
"fg1": "#5c6a72",
"fg2": "#66757c",
"fg3": "#748086",
"green": "#8da101",
"muted": "#7a8478",
"on-accent": "#2d353b",
"purple": "#df69ba",
"red": "#f85552",
"syntax-comment": "#939f91",
"syntax-function": "#8da101",
"syntax-keyword": "#f85552",
"syntax-meta": "#f57d26",
"syntax-number": "#df69ba",
"syntax-string": "#dfa000",
"syntax-type": "#3a94c5",
"syntax-variable": "#5c6a72"
},
"github-dark": {
"accent": "#4493f8",
"accent2": "#d29922",
"aqua": "#39c5cf",
"bg0": "#0d1117",
"bg1": "#161b22",
"bg2": "#21262d",
"bg3": "#30363d",
"blue": "#58a6ff",
"fg0": "#f0f6fc",
"fg1": "#e6edf3",
"fg2": "#c9d1d9",
"fg3": "#9198a1",
"green": "#3fb950",
"muted": "#8b949e",
"on-accent": "#0d1117",
"purple": "#bc8cff",
"red": "#f85149",
"syntax-comment": "#8b949e",
"syntax-function": "#d2a8ff",
"syntax-keyword": "#ff7b72",
"syntax-meta": "#7ee787",
"syntax-number": "#79c0ff",
"syntax-string": "#a5d6ff",
"syntax-type": "#ffa657",
"syntax-variable": "#e6edf3"
},
"github-light": {
"accent": "#0969da",
"accent2": "#9a6700",
"aqua": "#1b7c83",
"bg0": "#ffffff",
"bg1": "#f6f8fa",
"bg2": "#eaeef2",
"bg3": "#d0d7de",
"blue": "#0969da",
"fg0": "#1f2328",
"fg1": "#1f2328",
"fg2": "#31363c",
"fg3": "#59636e",
"green": "#1a7f37",
"muted": "#59636e",
"on-accent": "#ffffff",
"purple": "#8250df",
"red": "#d1242f",
"syntax-comment": "#59636e",
"syntax-function": "#8250df",
"syntax-keyword": "#cf222e",
"syntax-meta": "#116329",
"syntax-number": "#0550ae",
"syntax-string": "#0a3069",
"syntax-type": "#953800",
"syntax-variable": "#1f2328"
},
"gruvbox-dark": {
"accent": "#fabd2f",
"accent2": "#fe8019",
"aqua": "#8ec07c",
"bg0": "#282828",
"bg1": "#32302f",
"bg2": "#3c3836",
"bg3": "#504945",
"blue": "#83a598",
"fg0": "#fbf1c7",
"fg1": "#ebdbb2",
"fg2": "#d5c4a1",
"fg3": "#a89984",
"green": "#b8bb26",
"muted": "#928374",
"on-accent": "#282828",
"purple": "#d3869b",
"red": "#fb4934",
"syntax-comment": "#928374",
"syntax-function": "#8ec07c",
"syntax-keyword": "#fb4934",
"syntax-meta": "#fe8019",
"syntax-number": "#d3869b",
"syntax-string": "#b8bb26",
"syntax-type": "#fabd2f",
"syntax-variable": "#83a598"
},
"gruvbox-light": {
"accent": "#b57614",
"accent2": "#af3a03",
"aqua": "#427b58",
"bg0": "#fbf1c7",
"bg1": "#f2e5bc",
"bg2": "#ebdbb2",
"bg3": "#d5c4a1",
"blue": "#076678",
"fg0": "#282828",
"fg1": "#3c3836",
"fg2": "#504945",
"fg3": "#665c54",
"green": "#79740e",
"muted": "#7c6f64",
"on-accent": "#282828",
"purple": "#8f3f71",
"red": "#9d0006",
"syntax-comment": "#928374",
"syntax-function": "#427b58",
"syntax-keyword": "#9d0006",
"syntax-meta": "#af3a03",
"syntax-number": "#8f3f71",
"syntax-string": "#79740e",
"syntax-type": "#b57614",
"syntax-variable": "#076678"
},
"harbor-dark": {
"accent": "#e75a50",
"accent2": "#e1ce98",
"aqua": "#8a9ba6",
"bg0": "#1b1b1b",
"bg1": "#232323",
"bg2": "#2e2e2e",
"bg3": "#3f3f3f",
"blue": "#77838a",
"fg0": "#f7f4ea",
"fg1": "#efebdc",
"fg2": "#d6d1c0",
"fg3": "#c0b79f",
"green": "#a99b7a",
"muted": "#a99b7a",
"on-accent": "#1b1b1b",
"purple": "#e58980",
"red": "#f44336",
"syntax-comment": "#77838a",
"syntax-function": "#a99b7a",
"syntax-keyword": "#e75a50",
"syntax-meta": "#e58980",
"syntax-number": "#e58980",
"syntax-string": "#e1ce98",
"syntax-type": "#e1ce98",
"syntax-variable": "#efebdc"
},
"harbor-light": {
"accent": "#4c6c94",
"accent2": "#dc8164",
"aqua": "#3d727d",
"bg0": "#dfe4c4",
"bg1": "#d0d6b4",
"bg2": "#c3c9a6",
"bg3": "#b3b994",
"blue": "#4c6c94",
"fg0": "#101c18",
"fg1": "#1c2d28",
"fg2": "#2f413b",
"fg3": "#435650",
"green": "#556753",
"muted": "#556753",
"on-accent": "#ffffff",
"purple": "#8a5b81",
"red": "#b14752",
"syntax-comment": "#6f7d6c",
"syntax-function": "#3d727d",
"syntax-keyword": "#b14752",
"syntax-meta": "#b06247",
"syntax-number": "#8a5b81",
"syntax-string": "#556753",
"syntax-type": "#4c6c94",
"syntax-variable": "#1c2d28"
},
"kanagawa-dark": {
"accent": "#7e9cd8",
"accent2": "#ffa066",
"aqua": "#7aa89f",
"bg0": "#1f1f28",
"bg1": "#16161d",
"bg2": "#2a2a37",
"bg3": "#363646",
"blue": "#7fb4ca",
"fg0": "#dcd7ba",
"fg1": "#dcd7ba",
"fg2": "#c8c093",
"fg3": "#a6a69c",
"green": "#98bb6c",
"muted": "#8f8e85",
"on-accent": "#1f1f28",
"purple": "#957fb8",
"red": "#e46876",
"syntax-comment": "#727169",
"syntax-function": "#7e9cd8",
"syntax-keyword": "#957fb8",
"syntax-meta": "#ffa066",
"syntax-number": "#d27e99",
"syntax-string": "#98bb6c",
"syntax-type": "#7aa89f",
"syntax-variable": "#e6c384"
},
"kanagawa-light": {
"accent": "#4d699b",
"accent2": "#cc6d00",
"aqua": "#597b75",
"bg0": "#f2ecbc",
"bg1": "#e5ddb0",
"bg2": "#dcd5ac",
"bg3": "#d5cea3",
"blue": "#4d699b",
"fg0": "#43436c",
"fg1": "#545464",
"fg2": "#5f5f72",
"fg3": "#6d6d7c",
"green": "#6f894e",
"muted": "#716e61",
"on-accent": "#ffffff",
"purple": "#624c83",
"red": "#c84053",
"syntax-comment": "#8a8980",
"syntax-function": "#4d699b",
"syntax-keyword": "#624c83",
"syntax-meta": "#cc6d00",
"syntax-number": "#b35b79",
"syntax-string": "#6f894e",
"syntax-type": "#597b75",
"syntax-variable": "#545464"
},
"nord-dark": {
"accent": "#88c0d0",
"accent2": "#d08770",
"aqua": "#8fbcbb",
"bg0": "#2e3440",
"bg1": "#3b4252",
"bg2": "#434c5e",
"bg3": "#4c566a",
"blue": "#81a1c1",
"fg0": "#eceff4",
"fg1": "#e5e9f0",
"fg2": "#d8dee9",
"fg3": "#b8c0cf",
"green": "#a3be8c",
"muted": "#8a93a5",
"on-accent": "#2e3440",
"purple": "#b48ead",
"red": "#bf616a",
"syntax-comment": "#616e88",
"syntax-function": "#88c0d0",
"syntax-keyword": "#81a1c1",
"syntax-meta": "#d08770",
"syntax-number": "#b48ead",
"syntax-string": "#a3be8c",
"syntax-type": "#8fbcbb",
"syntax-variable": "#d8dee9"
},
"nord-light": {
"accent": "#5e81ac",
"accent2": "#b55f45",
"aqua": "#4d8a89",
"bg0": "#eceff4",
"bg1": "#e5e9f0",
"bg2": "#d8dee9",
"bg3": "#c2c9d6",
"blue": "#5e81ac",
"fg0": "#2e3440",
"fg1": "#3b4252",
"fg2": "#434c5e",
"fg3": "#4c566a",
"green": "#5f7d48",
"muted": "#5f6b80",
"on-accent": "#ffffff",
"purple": "#8f6a8a",
"red": "#a9434d",
"syntax-comment": "#7b88a1",
"syntax-function": "#4c7a90",
"syntax-keyword": "#5e81ac",
"syntax-meta": "#b55f45",
"syntax-number": "#8f6a8a",
"syntax-string": "#5f7d48",
"syntax-type": "#4d8a89",
"syntax-variable": "#3b4252"
},
"one-dark": {
"accent": "#61afef",
"accent2": "#d19a66",
"aqua": "#56b6c2",
"bg0": "#282c34",
"bg1": "#21252b",
"bg2": "#2c313a",
"bg3": "#3e4451",
"blue": "#61afef",
"fg0": "#d7dae0",
"fg1": "#abb2bf",
"fg2": "#9da5b4",
"fg3": "#8b929e",
"green": "#98c379",
"muted": "#7f848e",
"on-accent": "#282c34",
"purple": "#c678dd",
"red": "#e06c75",
"syntax-comment": "#7f848e",
"syntax-function": "#61afef",
"syntax-keyword": "#c678dd",
"syntax-meta": "#56b6c2",
"syntax-number": "#d19a66",
"syntax-string": "#98c379",
"syntax-type": "#e5c07b",
"syntax-variable": "#e06c75"
},
"one-light": {
"accent": "#4078f2",
"accent2": "#c18401",
"aqua": "#0184bc",
"bg0": "#fafafa",
"bg1": "#f0f0f1",
"bg2": "#e5e5e6",
"bg3": "#d4d4d6",
"blue": "#4078f2",
"fg0": "#232324",
"fg1": "#383a42",
"fg2": "#4f525e",
"fg3": "#696c77",
"green": "#50a14f",
"muted": "#696c77",
"on-accent": "#ffffff",
"purple": "#a626a4",
"red": "#e45649",
"syntax-comment": "#a0a1a7",
"syntax-function": "#4078f2",
"syntax-keyword": "#a626a4",
"syntax-meta": "#0184bc",
"syntax-number": "#986801",
"syntax-string": "#50a14f",
"syntax-type": "#c18401",
"syntax-variable": "#e45649"
},
"rosepine-dark": {
"accent": "#c4a7e7",
"accent2": "#ebbcba",
"aqua": "#9ccfd8",
"bg0": "#191724",
"bg1": "#1f1d2e",
"bg2": "#26233a",
"bg3": "#403d52",
"blue": "#31748f",
"fg0": "#e0def4",
"fg1": "#e0def4",
"fg2": "#c9c6e0",
"fg3": "#908caa",
"green": "#9ccfd8",
"muted": "#908caa",
"on-accent": "#191724",
"purple": "#c4a7e7",
"red": "#eb6f92",
"syntax-comment": "#6e6a86",
"syntax-function": "#ebbcba",
"syntax-keyword": "#31748f",
"syntax-meta": "#c4a7e7",
"syntax-number": "#f6c177",
"syntax-string": "#f6c177",
"syntax-type": "#9ccfd8",
"syntax-variable": "#e0def4"
},
"rosepine-light": {
"accent": "#286983",
"accent2": "#d7827e",
"aqua": "#56949f",
"bg0": "#faf4ed",
"bg1": "#f2e9e1",
"bg2": "#dfdad9",
"bg3": "#cecacd",
"blue": "#286983",
"fg0": "#464261",
"fg1": "#575279",
"fg2": "#6a6585",
"fg3": "#797593",
"green": "#56949f",
"muted": "#797593",
"on-accent": "#ffffff",
"purple": "#907aa9",
"red": "#b4637a",
"syntax-comment": "#9893a5",
"syntax-function": "#d7827e",
"syntax-keyword": "#286983",
"syntax-meta": "#907aa9",
"syntax-number": "#ea9d34",
"syntax-string": "#ea9d34",
"syntax-type": "#56949f",
"syntax-variable": "#575279"
},
"solarized-dark": {
"accent": "#b58900",
"accent2": "#cb4b16",
"aqua": "#2aa198",
"bg0": "#002b36",
"bg1": "#073642",
"bg2": "#0b4250",
"bg3": "#155565",
"blue": "#268bd2",
"fg0": "#eee8d5",
"fg1": "#93a1a1",
"fg2": "#839496",
"fg3": "#839496",
"green": "#859900",
"muted": "#6c8288",
"on-accent": "#002b36",
"purple": "#6c71c4",
"red": "#dc322f",
"syntax-comment": "#586e75",
"syntax-function": "#268bd2",
"syntax-keyword": "#859900",
"syntax-meta": "#cb4b16",
"syntax-number": "#d33682",
"syntax-string": "#2aa198",
"syntax-type": "#b58900",
"syntax-variable": "#93a1a1"
},
"solarized-light": {
"accent": "#b58900",
"accent2": "#cb4b16",
"aqua": "#2aa198",
"bg0": "#fdf6e3",
"bg1": "#eee8d5",
"bg2": "#e4dcc4",
"bg3": "#d6ceb5",
"blue": "#268bd2",
"fg0": "#073642",
"fg1": "#586e75",
"fg2": "#657b83",
"fg3": "#657b83",
"green": "#859900",
"muted": "#6f8186",
"on-accent": "#002b36",
"purple": "#6c71c4",
"red": "#dc322f",
"syntax-comment": "#93a1a1",
"syntax-function": "#268bd2",
"syntax-keyword": "#859900",
"syntax-meta": "#cb4b16",
"syntax-number": "#d33682",
"syntax-string": "#2aa198",
"syntax-type": "#b58900",
"syntax-variable": "#586e75"
},
"tokyonight-dark": {
"accent": "#7aa2f7",
"accent2": "#ff9e64",
"aqua": "#73daca",
"bg0": "#24283b",
"bg1": "#1f2335",
"bg2": "#292e42",
"bg3": "#3b4261",
"blue": "#7aa2f7",
"fg0": "#c0caf5",
"fg1": "#c0caf5",
"fg2": "#a9b1d6",
"fg3": "#9aa5ce",
"green": "#9ece6a",
"muted": "#737aa2",
"on-accent": "#24283b",
"purple": "#bb9af7",
"red": "#f7768e",
"syntax-comment": "#565f89",
"syntax-function": "#7aa2f7",
"syntax-keyword": "#bb9af7",
"syntax-meta": "#e0af68",
"syntax-number": "#ff9e64",
"syntax-string": "#9ece6a",
"syntax-type": "#2ac3de",
"syntax-variable": "#7dcfff"
},
"tokyonight-light": {
"accent": "#2e7de9",
"accent2": "#b15c00",
"aqua": "#118c74",
"bg0": "#e1e2e7",
"bg1": "#d0d5e3",
"bg2": "#c4c8da",
"bg3": "#a8aecb",
"blue": "#2e7de9",
"fg0": "#273463",
"fg1": "#3760bf",
"fg2": "#4b5a99",
"fg3": "#6172b0",
"green": "#587539",
"muted": "#6c74a0",
"on-accent": "#ffffff",
"purple": "#9854f1",
"red": "#f52a65",
"syntax-comment": "#848cb5",
"syntax-function": "#2e7de9",
"syntax-keyword": "#9854f1",
"syntax-meta": "#8c6c3e",
"syntax-number": "#b15c00",
"syntax-string": "#587539",
"syntax-type": "#007197",
"syntax-variable": "#3760bf"
},
"vscode-dark": {
"accent": "#0078d4",
"accent2": "#ce9178",
"aqua": "#4ec9b0",
"bg0": "#1f1f1f",
"bg1": "#181818",
"bg2": "#2b2b2b",
"bg3": "#3c3c3c",
"blue": "#3794ff",
"fg0": "#ffffff",
"fg1": "#cccccc",
"fg2": "#bbbbbb",
"fg3": "#9d9d9d",
"green": "#89d185",
"muted": "#8b8b8b",
"on-accent": "#ffffff",
"purple": "#c586c0",
"red": "#f14c4c",
"syntax-comment": "#6a9955",
"syntax-function": "#dcdcaa",
"syntax-keyword": "#569cd6",
"syntax-meta": "#c586c0",
"syntax-number": "#b5cea8",
"syntax-string": "#ce9178",
"syntax-type": "#4ec9b0",
"syntax-variable": "#9cdcfe"
},
"vscode-light": {
"accent": "#005fb8",
"accent2": "#a31515",
"aqua": "#267f99",
"bg0": "#ffffff",
"bg1": "#f8f8f8",
"bg2": "#e5e5e5",
"bg3": "#cecece",
"blue": "#005fb8",
"fg0": "#000000",
"fg1": "#3b3b3b",
"fg2": "#444444",
"fg3": "#616161",
"green": "#388a34",
"muted": "#6e7681",
"on-accent": "#ffffff",
"purple": "#af00db",
"red": "#cd3131",
"syntax-comment": "#008000",
"syntax-function": "#795e26",
"syntax-keyword": "#0000ff",
"syntax-meta": "#af00db",
"syntax-number": "#098658",
"syntax-string": "#a31515",
"syntax-type": "#267f99",
"syntax-variable": "#001080"
}
}
+138
View File
@@ -0,0 +1,138 @@
//! Connection to a CodeCraft server: login, configuration, model gateway, MCP tools.
use crate::conf::ServerLogin;
use anyhow::{Context, Result, anyhow, bail};
use codecraft_core::provider::{StreamEvent, ToolDef};
use codecraft_core::types::{Message, Usage};
use eventsource_stream::Eventsource;
use futures::StreamExt;
use serde_json::{Value, json};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
#[derive(Clone)]
pub struct Server {
pub url: String,
token: String,
http: reqwest::Client,
}
pub fn http() -> reqwest::Client {
reqwest::Client::builder()
.user_agent(concat!("codecraft-cli/", env!("CARGO_PKG_VERSION")))
.connect_timeout(std::time::Duration::from_secs(15))
.build()
.expect("http client")
}
async fn check(resp: reqwest::Response) -> Result<reqwest::Response> {
if resp.status().is_success() {
return Ok(resp);
}
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let msg = serde_json::from_str::<Value>(&body).ok().and_then(|v| v["error"].as_str().map(String::from)).unwrap_or(body);
match status.as_u16() {
401 => bail!("the server rejected the login – run `codecraft login` again"),
_ => bail!("server: {} {}", status.as_u16(), msg.trim()),
}
}
/// Sign in with username and password; the server creates an API token for this machine.
pub async fn login(url: &str, username: &str, password: &str) -> Result<ServerLogin> {
let url = url.trim().trim_end_matches('/').to_string();
let url = if url.starts_with("http://") || url.starts_with("https://") { url } else { format!("https://{url}") };
let host = hostname();
let resp = http()
.post(format!("{url}/api/auth/cli-login"))
.json(&json!({"username": username, "password": password, "hostname": host}))
.send()
.await
.with_context(|| format!("cannot reach {url}"))?;
let v: Value = check(resp).await?.json().await?;
let token = v["token"].as_str().ok_or_else(|| anyhow!("unexpected answer from the server"))?.to_string();
Ok(ServerLogin { url, token, username: v["user"]["username"].as_str().unwrap_or(username).to_string() })
}
pub fn hostname() -> String {
std::env::var("HOSTNAME")
.ok()
.or_else(|| std::fs::read_to_string("/etc/hostname").ok())
.or_else(|| std::env::var("COMPUTERNAME").ok())
.map(|h| h.trim().to_string())
.filter(|h| !h.is_empty())
.unwrap_or_else(|| "terminal".into())
}
impl Server {
pub fn new(login: &ServerLogin) -> Self {
Self { url: login.url.clone(), token: login.token.clone(), http: http() }
}
pub async fn get(&self, path: &str) -> Result<Value> {
let resp = self.http.get(format!("{}/api{path}", self.url)).bearer_auth(&self.token).send().await.with_context(|| format!("cannot reach {}", self.url))?;
Ok(check(resp).await?.json().await?)
}
pub async fn post(&self, path: &str, body: Value) -> Result<Value> {
let resp = self.http.post(format!("{}/api{path}", self.url)).bearer_auth(&self.token).json(&body).send().await.with_context(|| format!("cannot reach {}", self.url))?;
Ok(check(resp).await?.json().await?)
}
/// One model request through the server's gateway, as stream events.
#[allow(clippy::too_many_arguments)]
pub async fn stream(
&self,
model: &str,
system: String,
messages: Vec<Message>,
tools: Vec<ToolDef>,
effort: Option<String>,
session: &str,
agent: &str,
tx: mpsc::Sender<StreamEvent>,
cancel: CancellationToken,
) -> Result<()> {
let body = json!({
"model": model, "system": system, "messages": messages, "tools": tools,
"reasoning": true, "effort": effort, "session": session, "agent": agent,
});
let resp = self.http.post(format!("{}/api/llm/stream", self.url)).bearer_auth(&self.token).json(&body).send().await.with_context(|| format!("cannot reach {}", self.url))?;
let resp = check(resp).await?;
let mut events = resp.bytes_stream().eventsource();
loop {
let ev = tokio::select! {
ev = events.next() => ev,
_ = cancel.cancelled() => return Ok(()),
};
let Some(ev) = ev else { break };
let ev = ev.map_err(|e| anyhow!("stream error: {e}"))?;
let v: Value = serde_json::from_str(&ev.data).unwrap_or(Value::Null);
let out = match v["type"].as_str().unwrap_or("") {
"text" => StreamEvent::TextDelta(v["text"].as_str().unwrap_or("").into()),
"reasoning" => StreamEvent::ReasoningDelta(v["text"].as_str().unwrap_or("").into()),
"signature" => StreamEvent::ReasoningSignature(v["signature"].as_str().unwrap_or("").into()),
"tool_call" => StreamEvent::ToolCall {
id: v["id"].as_str().unwrap_or("").into(),
name: v["name"].as_str().unwrap_or("").into(),
input: v["input"].clone(),
extra: v.get("extra").filter(|e| !e.is_null()).cloned(),
},
"usage" => StreamEvent::Usage(serde_json::from_value::<Usage>(v["usage"].clone()).unwrap_or_default()),
"done" => StreamEvent::Done { stop_reason: v["stop_reason"].as_str().unwrap_or("").into() },
"error" => bail!("{}", v["message"].as_str().unwrap_or("model error")),
_ => continue,
};
if tx.send(out).await.is_err() {
break;
}
}
Ok(())
}
/// Run one of the server's MCP tools.
pub async fn tool(&self, name: &str, input: Value) -> Result<(String, bool)> {
let v = self.post("/cli/tool", json!({"name": name, "input": input})).await?;
Ok((v["content"].as_str().unwrap_or("").to_string(), v["is_error"].as_bool().unwrap_or(false)))
}
}
+90
View File
@@ -0,0 +1,90 @@
//! Local sessions (~/.local/share/codecraft/sessions/<id>.json).
use anyhow::Result;
use codecraft_core::types::{Message, Part, Usage, new_id, now_ms};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalSession {
pub id: String,
pub title: String,
pub cwd: String,
pub model: String,
pub mode: String,
pub permission_mode: String,
pub created_at: i64,
pub updated_at: i64,
pub messages: Vec<Message>,
#[serde(default)]
pub usage: Usage,
#[serde(default)]
pub cost: f64,
#[serde(default)]
pub todos: Value,
/// Tokens of the last request (context fill).
#[serde(default)]
pub context_tokens: u64,
}
fn dir() -> PathBuf {
crate::conf::data_dir().join("sessions")
}
impl LocalSession {
pub fn new(cwd: &str, model: &str, mode: &str, permission_mode: &str) -> Self {
let now = now_ms();
Self {
id: new_id(),
title: String::new(),
cwd: cwd.into(),
model: model.into(),
mode: mode.into(),
permission_mode: permission_mode.into(),
created_at: now,
updated_at: now,
messages: vec![],
usage: Usage::default(),
cost: 0.0,
todos: Value::Null,
context_tokens: 0,
}
}
pub fn save(&mut self) -> Result<()> {
if self.messages.is_empty() {
return Ok(());
}
self.updated_at = now_ms();
std::fs::create_dir_all(dir())?;
let tmp = dir().join(format!("{}.json.tmp", self.id));
std::fs::write(&tmp, serde_json::to_vec(self)?)?;
std::fs::rename(tmp, dir().join(format!("{}.json", self.id)))?;
Ok(())
}
pub fn load(id: &str) -> Result<Self> {
Ok(serde_json::from_slice(&std::fs::read(dir().join(format!("{id}.json")))?)?)
}
/// Messages the model sees: everything from the last summary on.
pub fn active(&self) -> Vec<Message> {
let start = self.messages.iter().rposition(|m| m.parts.iter().any(|p| matches!(p, Part::Summary { .. }))).unwrap_or(0);
self.messages[start..].to_vec()
}
}
/// Sessions (newest first), optionally only those of one folder.
pub fn list(cwd: Option<&str>) -> Vec<LocalSession> {
let mut out: Vec<LocalSession> = std::fs::read_dir(dir())
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.filter_map(|e| serde_json::from_slice::<LocalSession>(&std::fs::read(e.path()).ok()?).ok())
.filter(|s| cwd.is_none_or(|c| s.cwd == c))
.collect();
out.sort_by_key(|s| std::cmp::Reverse(s.updated_at));
out
}
+36
View File
@@ -0,0 +1,36 @@
//! Local usage log (~/.local/share/codecraft/usage.jsonl) for /costs.
use crate::session::LocalSession;
use codecraft_core::types::{Usage, now_ms};
use serde_json::{Value, json};
use std::io::Write;
fn path() -> std::path::PathBuf {
crate::conf::data_dir().join("usage.jsonl")
}
pub fn record(session: &LocalSession, model: &str, u: &Usage, cost: f64) {
let line = json!({"ts": now_ms(), "session": session.id, "model": model, "in": u.input_tokens, "out": u.output_tokens, "cache_read": u.cache_read_tokens, "cache_write": u.cache_write_tokens, "cost": cost});
let _ = std::fs::create_dir_all(crate::conf::data_dir());
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path()) {
let _ = writeln!(f, "{line}");
}
}
/// (requests, input, output, cost) per model since `since` (ms).
pub fn summary(since: i64) -> Vec<(String, u64, u64, u64, f64)> {
let text = std::fs::read_to_string(path()).unwrap_or_default();
let mut map: std::collections::BTreeMap<String, (u64, u64, u64, f64)> = Default::default();
for line in text.lines() {
let Ok(v) = serde_json::from_str::<Value>(line) else { continue };
if v["ts"].as_i64().unwrap_or(0) < since {
continue;
}
let e = map.entry(v["model"].as_str().unwrap_or("?").to_string()).or_default();
e.0 += 1;
e.1 += v["in"].as_u64().unwrap_or(0) + v["cache_read"].as_u64().unwrap_or(0) + v["cache_write"].as_u64().unwrap_or(0);
e.2 += v["out"].as_u64().unwrap_or(0);
e.3 += v["cost"].as_f64().unwrap_or(0.0);
}
map.into_iter().map(|(k, (n, i, o, c))| (k, n, i, o, c)).collect()
}
+102
View File
@@ -0,0 +1,102 @@
//! Colours from the web themes (same palettes as the web UI, 24-bit).
use ratatui::style::Color;
use serde_json::Value;
use std::sync::OnceLock;
const PALETTES: &str = include_str!("palettes.json");
#[derive(Debug, Clone, Copy)]
#[allow(dead_code)]
pub struct Theme {
pub bg0: Color,
pub bg1: Color,
pub bg2: Color,
pub bg3: Color,
pub fg0: Color,
pub fg1: Color,
pub fg2: Color,
pub fg3: Color,
pub muted: Color,
pub accent: Color,
pub accent2: Color,
pub on_accent: Color,
pub red: Color,
pub green: Color,
pub blue: Color,
pub aqua: Color,
pub purple: Color,
pub keyword: Color,
pub string: Color,
pub comment: Color,
}
fn hex(v: &Value, key: &str, fallback: Color) -> Color {
v[key]
.as_str()
.and_then(|s| {
let s = s.trim_start_matches('#');
Some(Color::Rgb(u8::from_str_radix(s.get(0..2)?, 16).ok()?, u8::from_str_radix(s.get(2..4)?, 16).ok()?, u8::from_str_radix(s.get(4..6)?, 16).ok()?))
})
.unwrap_or(fallback)
}
pub fn names() -> Vec<String> {
let all: Value = serde_json::from_str(PALETTES).unwrap_or(Value::Null);
let mut out: Vec<String> = all.as_object().map(|o| o.keys().filter_map(|k| k.rsplit_once('-').map(|(b, _)| b.to_string())).collect()).unwrap_or_default();
out.dedup();
out
}
/// Is the terminal background dark? (COLORFGBG, else assume dark)
fn terminal_dark() -> bool {
std::env::var("COLORFGBG").ok().and_then(|v| v.rsplit(';').next().and_then(|b| b.parse::<u8>().ok())).map(|bg| bg < 7 || bg == 8).unwrap_or(true)
}
impl Theme {
/// `base` e.g. "gruvbox", `mode` "light" | "dark" | "auto".
pub fn load(base: &str, mode: &str) -> Self {
let dark = match mode {
"light" => false,
"dark" => true,
_ => terminal_dark(),
};
let all: Value = serde_json::from_str(PALETTES).unwrap_or(Value::Null);
let key = format!("{base}-{}", if dark { "dark" } else { "light" });
let p = all.get(&key).or_else(|| all.get(if dark { "gruvbox-dark" } else { "gruvbox-light" })).cloned().unwrap_or(Value::Null);
let d = |k: &str, f: Color| hex(&p, k, f);
Self {
bg0: d("bg0", Color::Reset),
bg1: d("bg1", Color::Reset),
bg2: d("bg2", Color::DarkGray),
bg3: d("bg3", Color::DarkGray),
fg0: d("fg0", Color::White),
fg1: d("fg1", Color::Reset),
fg2: d("fg2", Color::Gray),
fg3: d("fg3", Color::Gray),
muted: d("muted", Color::DarkGray),
accent: d("accent", Color::Yellow),
accent2: d("accent2", Color::LightYellow),
on_accent: d("on-accent", Color::Black),
red: d("red", Color::Red),
green: d("green", Color::Green),
blue: d("blue", Color::Blue),
aqua: d("aqua", Color::Cyan),
purple: d("purple", Color::Magenta),
keyword: d("syntax-keyword", Color::Red),
string: d("syntax-string", Color::Green),
comment: d("syntax-comment", Color::DarkGray),
}
}
}
static CURRENT: OnceLock<std::sync::RwLock<Theme>> = OnceLock::new();
pub fn set(t: Theme) {
let lock = CURRENT.get_or_init(|| std::sync::RwLock::new(t));
*lock.write().unwrap() = t;
}
pub fn get() -> Theme {
*CURRENT.get_or_init(|| std::sync::RwLock::new(Theme::load("gruvbox", "auto"))).read().unwrap()
}
+568
View File
@@ -0,0 +1,568 @@
//! Local tools: they run on this machine in the session's working directory.
use crate::server::Server;
use anyhow::{Result, anyhow, bail};
use base64::Engine;
use codecraft_core::permission::ToolInfo;
use codecraft_core::provider::ToolDef;
use codecraft_core::types::ImageData;
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio_util::sync::CancellationToken;
const MAX_OUTPUT: usize = 30_000;
/// Built-in skills and commands (also available without a server).
pub const BUILTIN: &[(&str, &str)] = &[
("skills/cad/SKILL.md", include_str!("../../server/resources/skills/cad/SKILL.md")),
("skills/recreate/SKILL.md", include_str!("../../server/resources/skills/recreate/SKILL.md")),
("skills/recreate/templates/01-inventory.md", include_str!("../../server/resources/skills/recreate/templates/01-inventory.md")),
("skills/recreate/templates/02-overview.md", include_str!("../../server/resources/skills/recreate/templates/02-overview.md")),
("skills/recreate/templates/03-architecture.md", include_str!("../../server/resources/skills/recreate/templates/03-architecture.md")),
("skills/recreate/templates/04-file-formats.md", include_str!("../../server/resources/skills/recreate/templates/04-file-formats.md")),
("skills/recreate/templates/05-ui-and-behaviour.md", include_str!("../../server/resources/skills/recreate/templates/05-ui-and-behaviour.md")),
("skills/recreate/templates/06-assets.md", include_str!("../../server/resources/skills/recreate/templates/06-assets.md")),
("skills/recreate/templates/recreation-plan.md", include_str!("../../server/resources/skills/recreate/templates/recreation-plan.md")),
("commands/cad.md", include_str!("../../server/resources/commands/cad.md")),
("commands/recreate-init.md", include_str!("../../server/resources/commands/recreate-init.md")),
("commands/recreate-start.md", include_str!("../../server/resources/commands/recreate-start.md")),
];
pub struct ToolSpec {
pub name: String,
pub description: String,
pub schema: Value,
pub read_only: bool,
pub is_edit: bool,
/// Executed by the server (its MCP tools).
pub remote: bool,
}
impl ToolInfo for ToolSpec {
fn name(&self) -> &str {
&self.name
}
fn read_only(&self) -> bool {
self.read_only
}
fn is_edit(&self) -> bool {
self.is_edit
}
}
impl ToolSpec {
pub fn def(&self) -> ToolDef {
ToolDef { name: self.name.clone(), description: self.description.clone(), input_schema: self.schema.clone() }
}
}
fn spec(name: &str, description: &str, schema: Value, read_only: bool, is_edit: bool) -> ToolSpec {
ToolSpec { name: name.into(), description: description.into(), schema, read_only, is_edit, remote: false }
}
/// The local tool set.
pub fn local_tools() -> Vec<ToolSpec> {
vec![
spec("read", "Read a file (text with line numbers; images are shown to you). Use offset/limit (lines) for large files.", json!({"type":"object","properties":{"path":{"type":"string"},"offset":{"type":"integer"},"limit":{"type":"integer"}},"required":["path"]}), true, false),
spec("write", "Create or overwrite a file with the given content. Read existing files first.", json!({"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}), false, true),
spec("edit", "Replace an exact text in a file. old_string must match exactly (including indentation) and be unique unless replace_all is true. Read the file first.", json!({"type":"object","properties":{"path":{"type":"string"},"old_string":{"type":"string"},"new_string":{"type":"string"},"replace_all":{"type":"boolean"}},"required":["path","old_string","new_string"]}), false, true),
spec("list", "List a directory (respects .gitignore).", json!({"type":"object","properties":{"path":{"type":"string"}}}), true, false),
spec("glob", "Find files by glob pattern, e.g. \"**/*.rs\" (respects .gitignore).", json!({"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string"}},"required":["pattern"]}), true, false),
spec("grep", "Search file contents with a regular expression (respects .gitignore). Returns file:line: text.", json!({"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string"},"glob":{"type":"string"},"ignore_case":{"type":"boolean"}},"required":["pattern"]}), true, false),
spec("bash", "Run a shell command in the working directory (stdout and stderr combined). Default timeout 2 min, max 10 min (timeout_ms). For servers/watchers set run_in_background=true and read output with bash_output.", json!({"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"},"timeout_ms":{"type":"integer"},"run_in_background":{"type":"boolean"}},"required":["command"]}), false, false),
spec("bash_output", "Read new output of a background shell.", json!({"type":"object","properties":{"shell_id":{"type":"string"}},"required":["shell_id"]}), true, false),
spec("kill_shell", "Stop a background shell.", json!({"type":"object","properties":{"shell_id":{"type":"string"}},"required":["shell_id"]}), false, false),
spec("todo_write", "Maintain a task list for the current work, shown live to the user. Send the complete list each time; exactly one item in_progress.", json!({"type":"object","properties":{"todos":{"type":"array","items":{"type":"object","properties":{"content":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}), true, false),
spec("ask_user", "Ask the user one or more questions and wait for the answers. Only for decisions that are genuinely the user's. Offer 2-4 options per question.", json!({"type":"object","properties":{"questions":{"type":"array","items":{"type":"object","properties":{"question":{"type":"string"},"options":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string"},"description":{"type":"string"}},"required":["label"]}}},"required":["question"]}}},"required":["questions"]}), true, false),
spec("task", "Delegate a self-contained task to a subagent (fresh context). It returns only its final report.", json!({"type":"object","properties":{"description":{"type":"string"},"prompt":{"type":"string"},"subagent_type":{"type":"string"}},"required":["description","prompt"]}), true, false),
spec("skill", "Load a skill (packaged instructions for a kind of task) by name and follow it.", json!({"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}), true, false),
spec("remember", "Save a lasting insight about this project (build/test commands, conventions, pitfalls, decisions) to .codecraft/memory.md – it is loaded into every future session here. Keep entries short; don't store secrets or temporary state.", json!({"type":"object","properties":{"note":{"type":"string"}},"required":["note"]}), false, true),
]
}
/// What tools may touch and use.
pub struct Ctx {
pub cwd: PathBuf,
pub cancel: CancellationToken,
pub read_files: Arc<Mutex<HashSet<PathBuf>>>,
pub shells: Arc<Mutex<BTreeMap<String, Arc<Shell>>>>,
pub server: Option<Server>,
/// Server skills (name -> description), fetched on demand.
pub server_skills: Vec<String>,
}
pub struct Output {
pub content: String,
pub is_error: bool,
pub images: Vec<ImageData>,
/// Unified diff of a file change (shown in the UI).
pub diff: Option<String>,
}
impl Output {
fn text(s: impl Into<String>) -> Self {
Self { content: s.into(), is_error: false, images: vec![], diff: None }
}
pub fn error(s: impl Into<String>) -> Self {
Self { content: s.into(), is_error: true, images: vec![], diff: None }
}
}
fn arg<'a>(v: &'a Value, k: &str) -> Result<&'a str> {
v[k].as_str().ok_or_else(|| anyhow!("missing argument '{k}'"))
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut end = max;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}\n… [truncated, {} more bytes]", &s[..end], s.len() - end)
}
pub fn resolve(cwd: &Path, p: &str) -> PathBuf {
let p = p.trim();
let raw = if Path::new(p).is_absolute() {
PathBuf::from(p)
} else if let Some(rest) = p.strip_prefix("~/") {
crate::conf::home().join(rest)
} else {
cwd.join(p)
};
let mut out = PathBuf::new();
for c in raw.components() {
match c {
std::path::Component::ParentDir => {
out.pop();
}
std::path::Component::CurDir => {}
other => out.push(other),
}
}
out
}
pub fn unified_diff(path: &str, old: &str, new: &str) -> String {
similar::TextDiff::from_lines(old, new).unified_diff().context_radius(3).header(&format!("a/{path}"), &format!("b/{path}")).to_string()
}
fn image_type(p: &Path) -> Option<&'static str> {
match p.extension()?.to_str()?.to_ascii_lowercase().as_str() {
"png" => Some("image/png"),
"jpg" | "jpeg" => Some("image/jpeg"),
"gif" => Some("image/gif"),
"webp" => Some("image/webp"),
_ => None,
}
}
pub fn apply_edit(old: &str, input: &Value) -> Result<String> {
let from = arg(input, "old_string")?;
let to = arg(input, "new_string")?;
if from.is_empty() {
bail!("old_string must not be empty (use write to create files)");
}
let n = old.matches(from).count();
if n == 0 {
bail!("old_string not found in the file – read it again and copy the exact text");
}
if n > 1 && !input["replace_all"].as_bool().unwrap_or(false) {
bail!("old_string occurs {n} times – add more context or set replace_all");
}
Ok(if input["replace_all"].as_bool().unwrap_or(false) { old.replace(from, to) } else { old.replacen(from, to, 1) })
}
/// The proposed change of a write/edit as a diff (for the permission dialog).
pub fn preview(name: &str, input: &Value, cwd: &Path) -> Option<String> {
let path = resolve(cwd, input["path"].as_str()?);
let old = std::fs::read_to_string(&path).unwrap_or_default();
let new = match name {
"write" => input["content"].as_str()?.to_string(),
"edit" => apply_edit(&old, input).ok()?,
_ => return None,
};
Some(unified_diff(&rel(cwd, &path), &old, &new))
}
pub fn rel(cwd: &Path, p: &Path) -> String {
p.strip_prefix(cwd).map(|r| r.display().to_string()).unwrap_or_else(|_| p.display().to_string())
}
// --- background shells ---------------------------------------------------------------
pub struct Shell {
#[allow(dead_code)]
pub command: String,
output: Mutex<String>,
read_pos: Mutex<usize>,
child: Mutex<Option<tokio::process::Child>>,
pub done: Mutex<Option<i32>>,
}
fn shell_cmd(cwd: &Path, command: &str) -> tokio::process::Command {
#[cfg(windows)]
let mut c = {
// Git Bash if available, else PowerShell.
let bash = std::env::var("CODECRAFT_SHELL").ok().or_else(|| which("bash"));
match bash {
Some(b) => {
let mut c = tokio::process::Command::new(b);
c.arg("-c").arg(format!("exec 2>&1\n{command}"));
c
}
None => {
let mut c = tokio::process::Command::new("powershell");
c.args(["-NoProfile", "-Command", &format!("& {{ {command} }} 2>&1")]);
c
}
}
};
#[cfg(not(windows))]
let mut c = {
let shell = std::env::var("CODECRAFT_SHELL").unwrap_or_else(|_| "bash".into());
let mut c = tokio::process::Command::new(shell);
c.arg("-c").arg(format!("exec 2>&1\n{command}"));
c
};
c.current_dir(cwd).env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
c.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()).kill_on_drop(true);
#[cfg(unix)]
c.process_group(0);
c
}
#[cfg(windows)]
fn which(name: &str) -> Option<String> {
std::env::var_os("PATH").and_then(|p| std::env::split_paths(&p).map(|d| d.join(format!("{name}.exe"))).find(|p| p.is_file()).map(|p| p.display().to_string()))
}
fn kill_tree(child: &tokio::process::Child) {
#[cfg(unix)]
if let Some(pid) = child.id() {
unsafe extern "C" {
fn kill(pid: i32, sig: i32) -> i32;
}
// SAFETY: plain syscall; negative pid = process group.
unsafe {
kill(-(pid as i32), 9);
}
}
#[cfg(not(unix))]
let _ = child;
}
async fn run_bash(ctx: &Ctx, input: &Value) -> Result<Output> {
let command = arg(input, "command")?;
let timeout = Duration::from_millis(input["timeout_ms"].as_u64().unwrap_or(120_000).min(600_000));
let mut cmd = shell_cmd(&ctx.cwd, command);
if input["run_in_background"].as_bool().unwrap_or(false) {
let mut child = cmd.spawn()?;
let mut stdout = child.stdout.take().unwrap();
let id = format!("sh{}", ctx.shells.lock().unwrap().len() + 1);
let shell = Arc::new(Shell { command: command.into(), output: Mutex::default(), read_pos: Mutex::new(0), child: Mutex::new(Some(child)), done: Mutex::new(None) });
ctx.shells.lock().unwrap().insert(id.clone(), shell.clone());
tokio::spawn(async move {
let mut buf = vec![0u8; 8192];
loop {
match stdout.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
let mut out = shell.output.lock().unwrap();
out.push_str(&String::from_utf8_lossy(&buf[..n]));
if out.len() > 2_000_000 {
let cut = out.len() - 1_000_000;
let mut c = cut;
while !out.is_char_boundary(c) {
c += 1;
}
out.drain(..c);
*shell.read_pos.lock().unwrap() = 0;
}
}
}
}
let child = shell.child.lock().unwrap().take();
if let Some(mut c) = child {
let code = c.wait().await.ok().and_then(|s| s.code()).unwrap_or(-1);
*shell.done.lock().unwrap() = Some(code);
}
});
return Ok(Output::text(format!("Started in the background with shell id {id}. Read its output with bash_output, stop it with kill_shell.")));
}
let mut child = cmd.spawn()?;
let mut stdout = child.stdout.take().unwrap();
let mut out = Vec::new();
let read = async {
let _ = stdout.read_to_end(&mut out).await;
};
let status = tokio::select! {
_ = read => child.wait().await.ok(),
_ = tokio::time::sleep(timeout) => {
kill_tree(&child);
let _ = child.kill().await;
return Ok(Output::error(format!("[command timed out after {}s and was killed]", timeout.as_secs())));
}
_ = ctx.cancel.cancelled() => {
kill_tree(&child);
let _ = child.kill().await;
return Ok(Output::error("[command aborted by user]"));
}
};
let code = status.and_then(|s| s.code()).unwrap_or(-1);
let mut text = truncate(String::from_utf8_lossy(&out).trim_end(), MAX_OUTPUT);
if text.is_empty() {
text = "(no output)".into();
}
if code != 0 {
text.push_str(&format!("\n[exit code {code}]"));
}
Ok(Output { content: text, is_error: code != 0, images: vec![], diff: None })
}
// --- skills ------------------------------------------------------------------------------
/// Local skills of the project (.codecraft/skills, .claude/skills): name -> SKILL.md path.
pub fn project_skills(cwd: &Path) -> BTreeMap<String, PathBuf> {
let mut out = BTreeMap::new();
for base in [".claude", ".codecraft"] {
let dir = cwd.join(base).join("skills");
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for e in entries.flatten() {
let p = e.path().join("SKILL.md");
if p.is_file() {
out.insert(e.file_name().to_string_lossy().to_string(), p);
}
}
}
out
}
pub fn builtin_skill_names() -> Vec<&'static str> {
BUILTIN.iter().filter_map(|(p, _)| p.strip_prefix("skills/").and_then(|r| r.strip_suffix("/SKILL.md"))).collect()
}
fn skill_cache(name: &str) -> PathBuf {
crate::conf::data_dir().join("skills").join(name)
}
async fn load_skill(ctx: &Ctx, name: &str) -> Result<Output> {
// Project skills win, then the server's, then the built-in ones.
if let Some(p) = project_skills(&ctx.cwd).get(name) {
let text = std::fs::read_to_string(p)?;
let dir = p.parent().unwrap_or(&ctx.cwd);
return Ok(Output::text(format!("{text}\n\n(Skill files are in {})", dir.display())));
}
if let Some(server) = ctx.server.as_ref().filter(|_| ctx.server_skills.iter().any(|s| s == name)) {
let v = server.get(&format!("/cli/skill/{name}")).await?;
let dir = skill_cache(name);
for f in v["files"].as_array().into_iter().flatten() {
let rel = f["path"].as_str().unwrap_or("");
if rel.contains("..") {
continue;
}
let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap())?;
std::fs::write(&p, f["content"].as_str().unwrap_or(""))?;
}
return Ok(Output::text(format!("{}\n\n(Skill files are in {})", v["content"].as_str().unwrap_or(""), dir.display())));
}
let prefix = format!("skills/{name}/");
let files: Vec<&(&str, &str)> = BUILTIN.iter().filter(|(p, _)| p.starts_with(&prefix)).collect();
if files.is_empty() {
bail!("unknown skill '{name}'");
}
let dir = skill_cache(name);
let mut main = String::new();
for (p, content) in files {
let rel = &p[prefix.len()..];
if rel == "SKILL.md" {
main = content.to_string();
}
let target = dir.join(rel);
std::fs::create_dir_all(target.parent().unwrap())?;
std::fs::write(&target, content)?;
}
Ok(Output::text(format!("{main}\n\n(Skill files are in {})", dir.display())))
}
// --- dispatcher -----------------------------------------------------------------------------
/// Run a local tool (ask_user and task are handled by the agent loop).
pub async fn run(ctx: &Ctx, name: &str, input: &Value) -> Result<Output> {
match name {
"read" => {
let path = resolve(&ctx.cwd, arg(input, "path")?);
if path.is_dir() {
bail!("{} is a directory – use list", path.display());
}
let bytes = tokio::fs::read(&path).await.map_err(|e| anyhow!("{}: {e}", path.display()))?;
ctx.read_files.lock().unwrap().insert(path.clone());
if let Some(mt) = image_type(&path) {
return Ok(Output {
content: format!("Image {} ({} bytes)", rel(&ctx.cwd, &path), bytes.len()),
is_error: false,
images: vec![ImageData { media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) }],
diff: None,
});
}
if bytes.iter().take(8000).any(|b| *b == 0) {
return Ok(Output::text(format!("{} is a binary file ({} bytes)", path.display(), bytes.len())));
}
let text = String::from_utf8_lossy(&bytes);
let offset = input["offset"].as_u64().unwrap_or(1).max(1) as usize;
let limit = input["limit"].as_u64().unwrap_or(2000) as usize;
let mut out = String::new();
let total = text.lines().count();
for (i, l) in text.lines().enumerate().skip(offset - 1).take(limit) {
out.push_str(&format!("{:>6}\t{}\n", i + 1, if l.len() > 2000 { &l[..l.char_indices().nth(2000).map(|(i, _)| i).unwrap_or(l.len())] } else { l }));
}
if offset - 1 + limit < total {
out.push_str(&format!("… ({} more lines, use offset)\n", total - (offset - 1 + limit)));
}
Ok(Output::text(if out.is_empty() { "(file is empty)".into() } else { truncate(&out, MAX_OUTPUT * 2) }))
}
"write" => {
let path = resolve(&ctx.cwd, arg(input, "path")?);
let content = arg(input, "content")?;
let old = if path.exists() {
if !ctx.read_files.lock().unwrap().contains(&path) {
return Ok(Output::error("File exists but has not been read in this session. Read it first."));
}
std::fs::read_to_string(&path).unwrap_or_default()
} else {
String::new()
};
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(&path, content)?;
ctx.read_files.lock().unwrap().insert(path.clone());
let diff = unified_diff(&rel(&ctx.cwd, &path), &old, content);
Ok(Output { content: format!("Wrote {} ({} lines)", rel(&ctx.cwd, &path), content.lines().count()), is_error: false, images: vec![], diff: Some(diff) })
}
"edit" => {
let path = resolve(&ctx.cwd, arg(input, "path")?);
if !ctx.read_files.lock().unwrap().contains(&path) {
return Ok(Output::error("File has not been read in this session. Read it first."));
}
let old = std::fs::read_to_string(&path)?;
let new = match apply_edit(&old, input) {
Ok(n) => n,
Err(e) => return Ok(Output::error(e.to_string())),
};
std::fs::write(&path, &new)?;
let diff = unified_diff(&rel(&ctx.cwd, &path), &old, &new);
Ok(Output { content: format!("Edited {}", rel(&ctx.cwd, &path)), is_error: false, images: vec![], diff: Some(diff) })
}
"list" => {
let path = resolve(&ctx.cwd, input["path"].as_str().unwrap_or("."));
let mut entries: Vec<String> = Vec::new();
for e in ignore::WalkBuilder::new(&path).max_depth(Some(1)).hidden(false).build().flatten() {
if e.depth() == 0 || e.file_name() == ".git" {
continue;
}
let dir = e.file_type().is_some_and(|t| t.is_dir());
entries.push(format!("{}{}", e.file_name().to_string_lossy(), if dir { "/" } else { "" }));
}
entries.sort();
Ok(Output::text(if entries.is_empty() { "(empty)".into() } else { entries.join("\n") }))
}
"glob" => {
let base = resolve(&ctx.cwd, input["path"].as_str().unwrap_or("."));
let matcher = globset::Glob::new(arg(input, "pattern")?)?.compile_matcher();
let mut hits = Vec::new();
for e in ignore::WalkBuilder::new(&base).hidden(false).build().flatten() {
if e.file_type().is_some_and(|t| t.is_file()) {
let r = e.path().strip_prefix(&base).unwrap_or(e.path());
if matcher.is_match(r) {
hits.push(rel(&ctx.cwd, e.path()));
if hits.len() >= 500 {
break;
}
}
}
}
Ok(Output::text(if hits.is_empty() { "No files found.".into() } else { hits.join("\n") }))
}
"grep" => {
let base = resolve(&ctx.cwd, input["path"].as_str().unwrap_or("."));
let re = regex::RegexBuilder::new(arg(input, "pattern")?).case_insensitive(input["ignore_case"].as_bool().unwrap_or(false)).build()?;
let filter = input["glob"].as_str().map(|g| globset::Glob::new(g).map(|g| g.compile_matcher())).transpose()?;
let mut out = String::new();
let mut n = 0;
'files: for e in ignore::WalkBuilder::new(&base).hidden(false).build().flatten() {
if !e.file_type().is_some_and(|t| t.is_file()) {
continue;
}
if let Some(f) = &filter {
if !f.is_match(e.file_name()) && !f.is_match(e.path().strip_prefix(&base).unwrap_or(e.path())) {
continue;
}
}
let Ok(text) = std::fs::read_to_string(e.path()) else { continue };
for (i, line) in text.lines().enumerate() {
if re.is_match(line) {
out.push_str(&format!("{}:{}: {}\n", rel(&ctx.cwd, e.path()), i + 1, line.chars().take(300).collect::<String>()));
n += 1;
if n >= 300 {
out.push_str("… (more matches – narrow the search)\n");
break 'files;
}
}
}
}
Ok(Output::text(if out.is_empty() { "No matches.".into() } else { out }))
}
"bash" => run_bash(ctx, input).await,
"bash_output" => {
let id = arg(input, "shell_id")?;
let shell = ctx.shells.lock().unwrap().get(id).cloned().ok_or_else(|| anyhow!("no shell {id}"))?;
let out = shell.output.lock().unwrap();
let mut pos = shell.read_pos.lock().unwrap();
let new = out[(*pos).min(out.len())..].to_string();
*pos = out.len();
let state = match *shell.done.lock().unwrap() {
Some(c) => format!("[exited with code {c}]"),
None => "[running]".into(),
};
Ok(Output::text(format!("{}\n{state}", truncate(&new, MAX_OUTPUT))))
}
"kill_shell" => {
let id = arg(input, "shell_id")?;
let shell = ctx.shells.lock().unwrap().get(id).cloned().ok_or_else(|| anyhow!("no shell {id}"))?;
if let Some(c) = shell.child.lock().unwrap().as_ref() {
kill_tree(c);
}
if let Some(mut c) = shell.child.lock().unwrap().take() {
let _ = c.start_kill();
}
Ok(Output::text(format!("Shell {id} stopped.")))
}
"todo_write" => {
let todos = input["todos"].as_array().cloned().unwrap_or_default();
let open = todos.iter().filter(|t| t["status"] != "completed").count();
Ok(Output::text(format!("Todo list updated ({open} open).")))
}
"skill" => load_skill(ctx, arg(input, "name")?).await,
"remember" => {
let note = arg(input, "note")?.trim().replace('\n', " ");
let path = ctx.cwd.join(".codecraft").join("memory.md");
std::fs::create_dir_all(path.parent().unwrap())?;
let mut text = std::fs::read_to_string(&path).unwrap_or_else(|_| "# Project memory\n\nLearned facts about this project (maintained by CodeCraft; edit freely).\n\n".into());
if !text.ends_with('\n') {
text.push('\n');
}
text.push_str(&format!("- {note}\n"));
std::fs::write(&path, &text)?;
Ok(Output::text(format!("Saved to .codecraft/memory.md: {note}")))
}
other => bail!("unknown tool '{other}'"),
}
}
+1
View File
@@ -187,6 +187,7 @@ web_fetch = "ask"
web_search = "allow"
diagnostics = "allow"
openscad = "allow" # renders .scad files to STL/3MF + previews
remember = "allow" # project memory (.codecraft/memory.md)
browser_snapshot = "allow"
image_compare = "allow"
generate_image = "ask" # costs money per image
+3
View File
@@ -79,6 +79,9 @@ pub struct ModelConfig {
/// Max. simultaneous requests to this model (None = unlimited).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrency: Option<usize>,
/// Model to use when this one's host is unreachable or its budget is used up ("provider/model").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fallback: Option<String>,
/// Extra request body fields sent as-is (e.g. temperature, reasoning_budget_tokens).
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub options: serde_json::Map<String, serde_json::Value>,
+1
View File
@@ -3,5 +3,6 @@
pub mod config;
pub mod permission;
pub mod prompts;
pub mod provider;
pub mod types;
+52
View File
@@ -0,0 +1,52 @@
//! Built-in prompts (shared by the server and the terminal client).
pub const AUTO_MODE_PROMPT: &str = "\n\n# Working autonomously\nThe user does not want to approve every step. Before you start a larger change (several files, installing dependencies, migrations, anything hard to undo), confirm the plan ONCE with ask_user in a short summary. After the user agrees, carry out the whole plan without asking again – reading, editing, building and testing are pre-approved. Only stop to ask when something unexpected changes the plan. Small, obvious fixes need no confirmation. Destructive or external actions (deleting, git push, deployments, changing other systems via MCP) still trigger an approval prompt automatically.";
pub 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."#;
pub const BUILD_PROMPT: &str = "Mode: BUILD. You have full access to read, modify and run things in the project.";
pub 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."#;
pub 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 phases. Report each phase with craft_phase when you enter it (study, specify in deep mode, build, check, ready) – the user sees it as a progress bar; use todo_write for the steps inside a 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}"#;
pub 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).";
pub const CLEAN_ROOM_OFF: &str = "Deep mode is ON: you may also read the target's page source and scripts, and analyse programs (binary_info, decompile with Ghidra) for the user's private use. The goal is a faithful RE-CREATION, not a copy. For software:\n1. STUDY: binary_info, then decompile. Find the program's structure: entry point and main loop, UI (windows, menus, dialogs, resources), input handling, file formats, algorithms and data structures, timing.\n2. SPECIFY: write .codecraft/spec.md – behaviour, screens, data and file formats, algorithms in your own words (pseudocode allowed), edge cases. This spec is the only input for the build.\n3. BUILD: implement from the spec in a modern stack (the user's choice, or a sensible default), with clean, idiomatic, documented code. Never paste or mechanically translate decompiled code; names, structure and code must be your own. Reuse only facts (file formats, constants needed for compatibility).\n4. CHECK: compare with the original – same files read/written, same outputs for the same inputs, same look where it matters – and refine.";
pub 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.";
pub 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.";
/// Default review criteria per mode (used when the config gives none).
pub fn refine_criteria(agent: &str) -> &'static str {
match agent {
"plan" => "Is the plan complete and concrete (files, functions, order of steps)? Are risks, edge cases, migration and verification covered? Are open questions for the user stated? Is anything in it wrong for this codebase?",
"craft" => "Were the phases done properly (study, build, check)? Did the CHECK step really compare the result with the original (screenshots, outputs) and report remaining differences? Is the result original work (no copied code/assets)? What still differs visibly or behaviourally?",
_ => "Is the request fully implemented – every requirement, not just the main path? Is the code correct (logic, edge cases, error handling)? Were the changes verified (build, tests, running it) and do they pass? Any leftovers (TODOs, debug output, unused code), broken existing behaviour or missing docs/tests for the change?",
}
}
/// The craft prompt for clean room or deep mode.
pub fn craft_prompt(clean_room: bool) -> String {
CRAFT_PROMPT.replace("{CLEAN_ROOM}", if clean_room { CLEAN_ROOM_ON } else { CLEAN_ROOM_OFF })
}
+5 -1
View File
@@ -7,7 +7,11 @@
# Rebuilding or recreating the container keeps all of it.
services:
codecraft:
build: .
build:
context: .
args:
# Shown in Settings and /api/health: GIT_COMMIT=$(git rev-parse --short HEAD) docker compose build
GIT_COMMIT: ${GIT_COMMIT:-}
image: codecraft:latest
container_name: codecraft
restart: unless-stopped
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
# Install the CodeCraft terminal client (codecraft) from __CODECRAFT_URL__
# curl -fsSL __CODECRAFT_URL__/cli/install.sh | sh
# Options (environment): CODECRAFT_INSTALL_DIR (default ~/.local/bin)
set -eu
SERVER="__CODECRAFT_URL__"
DIR="${CODECRAFT_INSTALL_DIR:-$HOME/.local/bin}"
os=$(uname -s | tr '[:upper:]' '[:lower:]')
arch=$(uname -m)
case "$arch" in
x86_64|amd64) arch=x86_64 ;;
aarch64|arm64) arch=aarch64 ;;
*) echo "codecraft: unsupported architecture $arch" >&2; exit 1 ;;
esac
case "$os" in
linux) ;;
*) echo "codecraft: no prebuilt binary for $os yet – build from source: cargo install --git <repo> codecraft-cli" >&2; exit 1 ;;
esac
target="$os-$arch"
url="$SERVER/cli/download/$target"
mkdir -p "$DIR"
tmp="$DIR/.codecraft.download"
echo "Downloading codecraft ($target) from $SERVER …"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$url" -o "$tmp"
elif command -v wget >/dev/null 2>&1; then
wget -q "$url" -O "$tmp"
else
echo "codecraft: needs curl or wget" >&2; exit 1
fi
chmod +x "$tmp"
mv "$tmp" "$DIR/codecraft"
echo "Installed $DIR/codecraft ($("$DIR/codecraft" --version 2>/dev/null || echo codecraft))"
case ":$PATH:" in
*":$DIR:"*) ;;
*) echo "Note: $DIR is not in your PATH – add this to your shell profile:"
echo " export PATH=\"$DIR:\$PATH\"" ;;
esac
echo
echo "Next steps:"
echo " codecraft login $SERVER # sign in with your CodeCraft account"
echo " codecraft # start in your project folder"
+48 -2
View File
@@ -158,7 +158,53 @@ pub fn router(app: AppState) -> Router {
let index = web_dir.join("index.html");
let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(tower_http::services::ServeFile::new(index));
Router::new().nest("/api", api).fallback_service(static_files).with_state(app)
Router::new()
.nest("/api", api)
.route("/cli/install.sh", get(cli_install_script))
.route("/cli/download/{target}", get(cli_download))
.fallback_service(static_files)
.with_state(app)
}
fn cli_dir() -> PathBuf {
std::env::var("CODECRAFT_CLI_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("/app/cli"))
}
/// The server's public base URL (config, else from the request).
fn public_base(app: &App, headers: &axum::http::HeaderMap) -> String {
let cfg = app.config();
if !cfg.auth.public_url.is_empty() {
return cfg.auth.public_url.trim_end_matches('/').to_string();
}
let host = headers.get("x-forwarded-host").or(headers.get(axum::http::header::HOST)).and_then(|v| v.to_str().ok()).unwrap_or("localhost:8787");
let proto = headers.get("x-forwarded-proto").and_then(|v| v.to_str().ok()).unwrap_or("http");
format!("{proto}://{host}")
}
/// `curl -fsSL <server>/cli/install.sh | sh` installs the terminal client.
async fn cli_install_script(State(app): State<AppState>, headers: axum::http::HeaderMap) -> Response {
let base = public_base(&app, &headers);
let script = include_str!("../resources-cli/install.sh").replace("__CODECRAFT_URL__", &base);
([(axum::http::header::CONTENT_TYPE, "text/x-shellscript; charset=utf-8")], script).into_response()
}
/// Static CLI binaries, e.g. /cli/download/linux-x86_64.
async fn cli_download(Path(target): Path<String>) -> Response {
if !target.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return (StatusCode::BAD_REQUEST, "invalid target").into_response();
}
let path = cli_dir().join(format!("codecraft-{target}"));
match tokio::fs::read(&path).await {
Ok(bytes) => (
[
(axum::http::header::CONTENT_TYPE, "application/octet-stream".to_string()),
(axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"codecraft\"".to_string()),
],
bytes,
)
.into_response(),
Err(_) => (StatusCode::NOT_FOUND, format!("no codecraft build for {target} on this server")).into_response(),
}
}
/// Load a session the user may at least read.
@@ -1022,7 +1068,7 @@ async fn backup(Extension(user): Extension<User>, State(app): State<AppState>) -
/// Liveness for monitoring (no auth needed – see auth::is_public).
async fn health(State(app): State<AppState>) -> Json<Value> {
Json(json!({"ok": true, "version": env!("CARGO_PKG_VERSION"), "sandbox": crate::sandbox::available(), "providers": app.registry().providers.len()}))
Json(json!({"ok": true, "version": crate::update::VERSION, "commit": crate::update::commit(), "sandbox": crate::sandbox::available(), "providers": app.registry().providers.len()}))
}
// --- budget -------------------------------------------------------------------
+3
View File
@@ -77,6 +77,8 @@ pub struct App {
pub push: crate::push::Push,
/// Web terminals (PTYs).
pub terminals: crate::terminal::Terminals,
/// Newer release found by the update check (admins see it).
pub update: crate::update::UpdateState,
}
/// What a user may do with a session.
@@ -116,6 +118,7 @@ impl App {
lsp: Default::default(),
push: crate::push::Push::new(&data),
terminals: Default::default(),
update: Default::default(),
});
crate::auth::bootstrap(&app)?;
app.clone().start_mcp();
+3 -1
View File
@@ -253,7 +253,9 @@ pub async fn info(State(app): State<Arc<App>>, headers: HeaderMap) -> Json<Value
let user = current_user(&app, &headers);
Json(json!({
"name": "CodeCraft",
"version": env!("CARGO_PKG_VERSION"),
"version": crate::update::VERSION,
"commit": crate::update::commit(),
"update": if user.as_ref().is_some_and(|u| u.is_admin()) { app.update.latest.lock().unwrap().clone() } else { None },
"setup_required": app.setup_code.lock().unwrap().is_some(),
"oidc": if cfg.auth.oidc.enabled { json!({"label": cfg.auth.oidc.button_label}) } else { Value::Null },
"user": user,
+3
View File
@@ -254,6 +254,8 @@ pub struct ServerConfig {
pub budget_action: String,
/// Largest file upload in MB (uploads are sent in chunks, so the reverse proxy limit doesn't apply).
pub max_upload_mb: u64,
/// Where to look for new releases (Gitea/GitHub "latest release" API); empty = no check.
pub update_url: String,
}
impl Default for ServerConfig {
@@ -272,6 +274,7 @@ impl Default for ServerConfig {
budget_warning: 0.8,
budget_action: "warn".into(),
max_upload_mb: 500,
update_url: "https://git.familie-zink.org/api/v1/repos/zink/codecraft/releases/latest".into(),
}
}
}
+2
View File
@@ -29,6 +29,7 @@ mod store;
mod terminal;
mod tools;
mod types;
mod update;
use anyhow::Result;
use tracing_subscriber::EnvFilter;
@@ -55,6 +56,7 @@ async fn main() -> Result<()> {
push::spawn_listener(app.clone());
terminal::spawn_reaper(app.clone());
jobs::spawn_scheduler(app.clone());
update::spawn_checker(app.clone());
let router = api::router(app);
let listener = tokio::net::TcpListener::bind(&bind).await?;
tracing::info!("listening on http://{bind}");
+12 -49
View File
@@ -35,56 +35,8 @@ pub struct Agent {
pub path: Option<String>,
}
pub const AUTO_MODE_PROMPT: &str = "\n\n# Working autonomously\nThe user does not want to approve every step. Before you start a larger change (several files, installing dependencies, migrations, anything hard to undo), confirm the plan ONCE with ask_user in a short summary. After the user agrees, carry out the whole plan without asking again – reading, editing, building and testing are pre-approved. Only stop to ask when something unexpected changes the plan. Small, obvious fixes need no confirmation. Destructive or external actions (deleting, git push, deployments, changing other systems via MCP) still trigger an approval prompt automatically.";
pub use codecraft_core::prompts::*;
pub 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 phases. Report each phase with craft_phase when you enter it (study, specify in deep mode, build, check, ready) – the user sees it as a progress bar; use todo_write for the steps inside a 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, and analyse programs (binary_info, decompile with Ghidra) for the user's private use. The goal is a faithful RE-CREATION, not a copy. For software:\n1. STUDY: binary_info, then decompile. Find the program's structure: entry point and main loop, UI (windows, menus, dialogs, resources), input handling, file formats, algorithms and data structures, timing.\n2. SPECIFY: write .codecraft/spec.md – behaviour, screens, data and file formats, algorithms in your own words (pseudocode allowed), edge cases. This spec is the only input for the build.\n3. BUILD: implement from the spec in a modern stack (the user's choice, or a sensible default), with clean, idiomatic, documented code. Never paste or mechanically translate decompiled code; names, structure and code must be your own. Reuse only facts (file formats, constants needed for compatibility).\n4. CHECK: compare with the original – same files read/written, same outputs for the same inputs, same look where it matters – and refine.";
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.";
/// Default review criteria per mode (used when the config gives none).
pub fn refine_criteria(agent: &str) -> &'static str {
match agent {
"plan" => "Is the plan complete and concrete (files, functions, order of steps)? Are risks, edge cases, migration and verification covered? Are open questions for the user stated? Is anything in it wrong for this codebase?",
"craft" => "Were the phases done properly (study, build, check)? Did the CHECK step really compare the result with the original (screenshots, outputs) and report remaining differences? Is the result original work (no copied code/assets)? What still differs visibly or behaviourally?",
_ => "Is the request fully implemented – every requirement, not just the main path? Is the code correct (logic, edge cases, error handling)? Were the changes verified (build, tests, running it) and do they pass? Any leftovers (TODOs, debug output, unused code), broken existing behaviour or missing docs/tests for the change?",
}
}
/// The craft prompt for clean room or deep mode.
pub fn craft_prompt(clean_room: bool) -> String {
CRAFT_PROMPT.replace("{CLEAN_ROOM}", if clean_room { CLEAN_ROOM_ON } else { CLEAN_ROOM_OFF })
}
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
let craft = craft_prompt(clean_room);
@@ -270,6 +222,17 @@ pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &
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::<String>())),
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");
+25 -3
View File
@@ -300,7 +300,18 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
reasoning: true,
model: model.clone(),
};
let step = stream_step(&app, &session, &agent.name, &model_spec, provider, req, &cancel).await?;
let step = match stream_step(&app, &session, &agent.name, &model_spec, provider, req.clone(), &cancel).await {
Ok(step) => step,
// Host down or budget used up: switch to the model's fallback for this step.
Err(e) if model.fallback.is_some() && !cancel.is_cancelled() && needs_fallback(&e) => {
let fb = model.fallback.clone().unwrap_or_default();
let (fb_provider, fb_model) = app.registry().resolve(&fb)?;
app.emit(Event::Error { session_id: session_id.into(), message: format!("{model_spec} is not available ({}) – using the fallback model {fb}", short_error(&e)) });
let req = ChatRequest { model: fb_model.clone(), max_tokens: fb_model.max_output.unwrap_or(16_000), ..req };
stream_step(&app, &session, &agent.name, &fb, fb_provider, req, &cancel).await?
}
Err(e) => return Err(e),
};
final_text = step
.message
.parts
@@ -747,9 +758,20 @@ fn estimate_prompt_tokens(req: &ChatRequest) -> u64 {
(chars as f64 / 3.8) as u64 + images * 1000
}
/// Errors after which the fallback model takes over: the host is unreachable
/// (connection, timeout, gateway errors) or the provider's budget is used up.
fn needs_fallback(e: &anyhow::Error) -> bool {
let s = format!("{e:#}").to_lowercase();
["connect", "timed out", "timeout", "http 502", "http 503", "http 504", "dns", "budget"].iter().any(|k| s.contains(k))
}
fn short_error(e: &anyhow::Error) -> String {
format!("{e:#}").lines().next().unwrap_or("").chars().take(120).collect()
}
fn is_retryable(e: &anyhow::Error) -> bool {
let s = format!("{e:#}");
s.contains("HTTP 429") || s.contains("HTTP 5") || s.contains("overloaded") || s.contains("connection") || s.contains("timed out")
let s = format!("{e:#}").to_lowercase();
["http 429", "http 5", "overloaded", "connect", "timed out", "timeout"].iter().any(|k| s.contains(k))
}
/// One model request with streaming to the UI, usage recording and retries.
+39
View File
@@ -97,6 +97,45 @@ impl Tool for CraftPhase {
// ---------------------------------------------------------------------------
/// Project memory: lasting facts about the project in .codecraft/memory.md.
pub struct Remember;
#[async_trait]
impl Tool for Remember {
fn name(&self) -> &str {
"remember"
}
fn description(&self) -> String {
"Save a lasting insight about this project (build/test commands, conventions, pitfalls, decisions) to \
.codecraft/memory.md – it is loaded into every future session in this project. Keep entries short; \
never store secrets or temporary state."
.into()
}
fn schema(&self) -> Value {
json!({"type": "object", "properties": {"note": {"type": "string"}}, "required": ["note"]})
}
fn is_edit(&self) -> bool {
true
}
fn title(&self, input: &Value) -> String {
input["note"].as_str().unwrap_or("").chars().take(80).collect()
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let note = arg_str(&input, "note")?.trim().replace('\n', " ");
let path = ctx.resolve(".codecraft/memory.md")?;
std::fs::create_dir_all(path.parent().unwrap())?;
let mut text = std::fs::read_to_string(&path).unwrap_or_else(|_| "# Project memory\n\nLearned facts about this project (maintained by CodeCraft; edit freely).\n\n".into());
if !text.ends_with('\n') {
text.push('\n');
}
text.push_str(&format!("- {note}\n"));
std::fs::write(&path, &text)?;
Ok(ToolOutput::text(format!("Saved to .codecraft/memory.md: {note}")).with_meta(json!({"memory": note})))
}
}
// ---------------------------------------------------------------------------
/// Switch the session's working directory (only on the user's request).
pub struct ChangeDirectory;
+1
View File
@@ -164,6 +164,7 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
Arc::new(web::WebSearch),
Arc::new(agent::CraftPhase),
Arc::new(agent::ChangeDirectory),
Arc::new(agent::Remember),
Arc::new(cad::OpenScad),
Arc::new(crate::jobs::Schedule),
Arc::new(crate::lsp::Diagnostics),
+57
View File
@@ -0,0 +1,57 @@
//! Version information and a daily check for new releases.
use crate::app::App;
use serde_json::{Value, json};
use std::sync::{Arc, Mutex};
use std::time::Duration;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Git commit the image was built from (Docker build arg GIT_COMMIT).
pub fn commit() -> &'static str {
option_env!("CODECRAFT_COMMIT").unwrap_or("")
}
#[derive(Default)]
pub struct UpdateState {
pub latest: Mutex<Option<Value>>,
}
fn newer(a: &str, b: &str) -> bool {
let p = |v: &str| v.trim_start_matches('v').split(['.', '-']).take(3).map(|x| x.parse::<u64>().unwrap_or(0)).collect::<Vec<_>>();
p(a) > p(b)
}
async fn check(app: &App) -> Option<Value> {
let url = app.config().server.update_url.clone();
if url.is_empty() {
return None;
}
let v: Value = crate::provider::http_client().get(&url).timeout(Duration::from_secs(20)).send().await.ok()?.json().await.ok()?;
let tag = v["tag_name"].as_str()?.to_string();
if !newer(&tag, VERSION) {
return None;
}
Some(json!({"version": tag.trim_start_matches('v'), "url": v["html_url"], "notes": v["body"].as_str().unwrap_or("").chars().take(4000).collect::<String>(), "published_at": v["published_at"]}))
}
pub fn spawn_checker(app: Arc<App>) {
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(60)).await;
loop {
let found = check(&app).await;
*app.update.latest.lock().unwrap() = found;
tokio::time::sleep(Duration::from_secs(24 * 3600)).await;
}
});
}
#[cfg(test)]
mod tests {
#[test]
fn version_compare() {
assert!(super::newer("v0.2.0", "0.1.9"));
assert!(super::newer("1.0.0", "0.9.9"));
assert!(!super::newer("v0.1.0", "0.1.0"));
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+3 -1
View File
@@ -15,6 +15,7 @@ import { Jobs } from "./components/Jobs";
import { Spinner, useTitleActivity } from "./components/Activity";
import { DialogHost } from "./dialogs";
import { startPrefsSync } from "./prefsSync";
import { WhatsNew } from "./components/WhatsNew";
// The file manager (editor, git) is loaded on demand to keep the chat small.
const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
@@ -31,7 +32,7 @@ export function App() {
const refresh = async () => {
try {
const i = await get<Info>("/info");
setState(() => ({ me: i.user }));
setState(() => ({ me: i.user, update: i.update ?? null }));
if (i.user) {
await loadSessions();
void startPrefsSync();
@@ -70,6 +71,7 @@ export function App() {
<>
<Login info={info} onSuccess={refresh} />
<DialogHost />
{info?.user && <WhatsNew version={info.version} />}
</>
);
+30
View File
@@ -5,6 +5,7 @@ import { useFetch, useModels } from "../hooks";
import { getLang, languages, setLang, useT, type Key } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
import { canPrompt, isInstalled, onInstallChange, platformHint, promptInstall } from "../install";
import { currentSubscription, disablePush, enablePush, pushSupported, testPush } from "../push";
import { applyTheme, getTheme, getThemeMode, onThemeChange, THEME_MODES, THEMES, type Theme, type ThemeMode } from "../theme";
import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs";
@@ -109,12 +110,36 @@ function InterfaceSettings() {
<div className="hint">{t("settings.reasoningHint")}</div>
<label>{t("settings.notifications")}</label>
<PushControl />
<label>{t("install.label")}</label>
<InstallControl />
<label>{t("settings.version")}</label>
<VersionInfo />
<div className="hint">{t("settings.notificationsHint")}</div>
</div>
</div>
);
}
function InstallControl() {
const t = useT();
const prompt = useSyncExternalStore(onInstallChange, canPrompt);
if (isInstalled()) return <span className="chip">{t("install.installed")}</span>;
if (prompt)
return (
<div>
<button className="small primary" onClick={() => void promptInstall()}>
<Icon name="download" /> {t("install.button")}
</button>
</div>
);
return <div className="hint">{t(`install.hint.${platformHint()}` as Key)}</div>;
}
function VersionInfo() {
const info = useFetch<{ version: string; commit?: string }>("/info").data;
return <div className="hint mono">{info ? `CodeCraft ${info.version}${info.commit ? ` (${info.commit})` : ""}` : "…"}</div>;
}
/** Web Push on this device: enable/disable and a test notification. */
function PushControl() {
const t = useT();
@@ -313,6 +338,7 @@ interface ModelRow {
cache_read_price?: number;
cache_write_price?: number;
max_concurrency?: number;
fallback?: string;
options?: Record<string, unknown>;
}
@@ -479,6 +505,7 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
<th>{t("settings.priceCacheRead")}</th>
<th>{t("settings.priceCacheWrite")}</th>
<th title={t("settings.maxConcurrencyHint")}>{t("settings.slots")}</th>
<th title={t("settings.fallbackHint")}>{t("settings.fallback")}</th>
<th />
</tr>
</thead>
@@ -516,6 +543,9 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
<td>
<input type="number" min={1} value={m.max_concurrency ?? ""} placeholder="∞" onChange={(e) => setModel(i, { max_concurrency: num(e.target.value) })} />
</td>
<td>
<ModelSelect value={m.fallback ?? ""} onChange={(v) => setModel(i, { fallback: v || undefined })} emptyLabel="–" />
</td>
<td>
<button className="small ghost danger" onClick={() => setModels(models.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
<Icon name="close" />
+6
View File
@@ -28,6 +28,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
const permissions = useStore((s) => s.permissions);
const questions = useStore((s) => s.questions);
const me = useStore((s) => s.me);
const update = useStore((s) => s.update);
// Full-text search over titles and messages.
const [query, setQuery] = useState("");
const [hits, setHits] = useState<{ id: string; title: string; cwd: string; agent: string; updated_at: number; snippet: string }[]>();
@@ -132,6 +133,11 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
</div>
))}
</nav>
{update && (
<a className="update-chip" href={update.url} target="_blank" rel="noreferrer" title={update.notes}>
<Icon name="sparkle" /> {t("update.available", { version: update.version })}
</a>
)}
<div className="sidebar-user">
<span title={me?.username}>
{me?.display_name || me?.username}
+8
View File
@@ -86,6 +86,14 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
);
}
if (call.name === "remember") {
return (
<div className="todo-line memory-card">
<Icon name="brain" /> {t("memory.remembered")}: {call.input?.note}
</div>
);
}
// Image files written by the agent are shown as images (SVG too, rendered via <img>).
const writtenImage =
(call.name === "write" || call.name === "edit") && typeof call.input?.path === "string" && IMAGE_EXT.test(call.input.path) && result && !result.is_error
+46
View File
@@ -0,0 +1,46 @@
import { useState } from "react";
import { getLang, useT } from "../i18n";
import { WHATS_NEW } from "../whatsnew";
import { Icon } from "./Icon";
const KEY = "codecraft.seenVersion";
/** Shown once after the server was updated to a version with release notes. */
export function WhatsNew({ version }: { version: string }) {
const t = useT();
const seen = localStorage.getItem(KEY);
const [open, setOpen] = useState(() => {
// First visit ever: just remember the version.
if (!seen) {
localStorage.setItem(KEY, version);
return false;
}
return seen !== version && !!WHATS_NEW[version];
});
if (!open) return null;
const entry = WHATS_NEW[version];
const items = getLang() === "de" ? entry.de : entry.en;
const close = () => {
localStorage.setItem(KEY, version);
setOpen(false);
};
return (
<div className="modal-backdrop" onClick={close}>
<div className="modal" role="dialog" aria-label={t("whatsnew.title")} onClick={(e) => e.stopPropagation()}>
<h3>
<Icon name="sparkle" /> {t("whatsnew.title", { version })}
</h3>
<ul className="whatsnew">
{items.map((i) => (
<li key={i}>{i}</li>
))}
</ul>
<div className="row" style={{ justifyContent: "flex-end" }}>
<button className="primary" onClick={close}>
{t("common.ok")}
</button>
</div>
</div>
</div>
);
}
+4
View File
@@ -4,6 +4,8 @@ import { useT } from "../../i18n";
import { useStore } from "../../store";
import { GitAccount } from "./GitAccount";
import { ApiTokens } from "./ApiTokens";
import { CliInstall } from "./CliInstall";
import { Memory } from "./Memory";
export function Account() {
const t = useT();
@@ -46,7 +48,9 @@ export function Account() {
</button>
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div>
</div>
<Memory />
<GitAccount />
<CliInstall />
<ApiTokens />
</>
);
@@ -0,0 +1,44 @@
import { useT } from "../../i18n";
import { toast } from "../../dialogs";
import { Icon } from "../Icon";
/** How to install and use the terminal client. */
export function CliInstall() {
const t = useT();
const cmd = `curl -fsSL ${location.origin}/cli/install.sh | sh`;
const copy = (s: string) => navigator.clipboard.writeText(s).then(() => toast(t("tokens.copied"), "success"));
return (
<div className="panel">
<h3>
<Icon name="terminal" /> {t("cli.title")}
</h3>
<div className="hint" style={{ marginBottom: 10 }}>
{t("cli.intro")}
</div>
<ol className="cli-steps">
<li>
{t("cli.step1")}
<div className="row" style={{ gap: 6, marginTop: 4 }}>
<code className="token-value">{cmd}</code>
<button className="small" onClick={() => copy(cmd)}>
<Icon name="copy" /> {t("tokens.copy")}
</button>
</div>
</li>
<li>
{t("cli.step2")}
<div className="row" style={{ gap: 6, marginTop: 4 }}>
<code className="token-value">{`codecraft login ${location.origin}`}</code>
<button className="small" onClick={() => copy(`codecraft login ${location.origin}`)}>
<Icon name="copy" /> {t("tokens.copy")}
</button>
</div>
</li>
<li>{t("cli.step3")}</li>
</ol>
<div className="hint">
{t("cli.downloads")} <a href="/cli/download/linux-x86_64">Linux x86_64</a> · <a href="/cli/download/linux-aarch64">Linux arm64</a>
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useState } from "react";
import { get, put } from "../../api";
import { useT } from "../../i18n";
import { toast } from "../../dialogs";
import { Icon } from "../Icon";
/** Personal memory: preferences that apply in every project (web and terminal). */
export function Memory() {
const t = useT();
const [text, setText] = useState<string>();
useEffect(() => {
get<{ memory: string }>("/account/memory")
.then((r) => setText(r.memory ?? ""))
.catch((e) => toast(e.message));
}, []);
if (text === undefined) return null;
return (
<div className="panel">
<h3>
<Icon name="brain" /> {t("memory.title")}
</h3>
<div className="hint" style={{ marginBottom: 8 }}>
{t("memory.hint")}
</div>
<textarea rows={6} style={{ width: "100%" }} value={text} placeholder={t("memory.placeholder")} onChange={(e) => setText(e.target.value)} />
<div className="row" style={{ marginTop: 8 }}>
<span className="grow" />
<button className="primary" onClick={() => put("/account/memory", { memory: text }).then(() => toast(t("settings.saved"), "success")).catch((e) => toast(e.message))}>
{t("common.save")}
</button>
</div>
</div>
);
}
+23
View File
@@ -388,6 +388,29 @@ export const de: Messages = {
"tokens.noExpiry": "Kein Ablauf",
"tokens.days": "Tage",
"tokens.create": "Token erstellen",
"cli.title": "Terminal-Client",
"cli.intro": "Arbeite mit CodeCraft im Terminal wie mit Claude Code: Der Agent bearbeitet Dateien und führt Befehle auf deinem Rechner aus, Modelle, Modi und Regeln kommen von diesem Server (Kosten erscheinen in deiner Statistik). Er läuft auch ohne Server mit eigenen API-Keys.",
"cli.step1": "Installieren (Linux, x86_64 und arm64):",
"cli.step2": "Mit deinem CodeCraft-Konto anmelden:",
"cli.step3": "Im Projektordner mit `codecraft` starten. Darin: /help, /model, /mode, Shift+Tab wechselt den Modus, @ fügt Dateien hinzu. `codecraft update` aktualisiert.",
"cli.downloads": "Direkte Downloads:",
"memory.title": "Persönliches Gedächtnis",
"memory.hint": "Was jeder Agent über dich wissen soll – in allen Projekten, im Web und im Terminal-Client (Sprache, Code-Stil, bevorzugte Werkzeuge …). Projektwissen liegt pro Projekt in .codecraft/memory.md.",
"memory.placeholder": "z. B. Antworte auf Deutsch. Ich bevorzuge TypeScript und Rust. Nie ohne Rückfrage committen.",
"memory.remembered": "Gemerkt",
"whatsnew.title": "Neu in {version}",
"update.available": "Update {version} verfügbar",
"settings.version": "Version",
"settings.fallback": "Ersatz",
"settings.fallbackHint": "Wird automatisch genutzt, wenn der Host dieses Modells nicht erreichbar oder das Budget des Anbieters aufgebraucht ist",
"install.label": "App",
"install.installed": "Installiert",
"install.button": "CodeCraft als App installieren",
"install.hint.chrome": "Chrome auf Android: Menü ⋮ → „App installieren“ (oder „Zum Startbildschirm hinzufügen“ → Installieren). Wenn nur eine Verknüpfung entsteht, muss der Server aus dem Internet erreichbar sein, damit Google die App bauen kann.",
"install.hint.samsung": "Samsung Internet: Menü → „Seite hinzufügen zu“ → „Startbildschirm“.",
"install.hint.firefox": "Firefox kann keine Web-Apps installieren – öffne CodeCraft in Chrome (Android) oder nutze „Zum Startbildschirm hinzufügen“.",
"install.hint.ios": "Safari: Teilen-Knopf → „Zum Home-Bildschirm“. Push-Benachrichtigungen funktionieren nur in der installierten App.",
"install.hint.desktop": "Chrome/Edge: Installations-Symbol in der Adressleiste oder Menü → „CodeCraft installieren“.",
"new.title": "Neue Session",
"new.directory": "Projektverzeichnis",
"new.up": "Hoch",
+23
View File
@@ -387,6 +387,29 @@ export const en = {
"tokens.noExpiry": "No expiry",
"tokens.days": "days",
"tokens.create": "Create token",
"cli.title": "Terminal client",
"cli.intro": "Work with CodeCraft in your terminal like with Claude Code: the agent edits files and runs commands on your machine, while models, modes and rules come from this server (costs show up in your statistics). It can also run without a server using your own API keys.",
"cli.step1": "Install (Linux, x86_64 and arm64):",
"cli.step2": "Sign in with your CodeCraft account:",
"cli.step3": "Start it in a project folder with `codecraft`. Inside: /help, /model, /mode, Shift+Tab switches the mode, @ adds files. `codecraft update` updates it.",
"cli.downloads": "Direct downloads:",
"memory.title": "Personal memory",
"memory.hint": "What every agent should know about you – in all projects, in the web and in the terminal client (language, code style, tools you prefer …). Project facts are kept per project in .codecraft/memory.md.",
"memory.placeholder": "e.g. Answer in German. I prefer TypeScript and Rust. Never commit without asking.",
"memory.remembered": "Remembered",
"whatsnew.title": "What's new in {version}",
"update.available": "Update {version} available",
"settings.version": "Version",
"settings.fallback": "Fallback",
"settings.fallbackHint": "Used automatically when this model's host is unreachable or its provider budget is used up",
"install.label": "App",
"install.installed": "Installed",
"install.button": "Install CodeCraft as an app",
"install.hint.chrome": "Chrome on Android: menu ⋮ → “Install app” (or “Add to home screen” → Install). If only a shortcut appears, the server must be reachable from the internet for Google to build the app.",
"install.hint.samsung": "Samsung Internet: menu → “Add page to” → “Home screen”.",
"install.hint.firefox": "Firefox can't install web apps – open CodeCraft in Chrome (Android) or use “Add to home screen”.",
"install.hint.ios": "Safari: Share button → “Add to Home Screen”. Push notifications work only in the installed app.",
"install.hint.desktop": "Chrome/Edge: install icon in the address bar or menu → “Install CodeCraft”.",
"new.title": "New session",
"new.directory": "Project directory",
"new.up": "Up",
+41
View File
@@ -0,0 +1,41 @@
/** PWA installation: keep the browser's install prompt for an in-app button. */
type PromptEvent = Event & { prompt: () => Promise<void>; userChoice: Promise<{ outcome: string }> };
let deferred: PromptEvent | null = null;
const listeners = new Set<() => void>();
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
deferred = e as PromptEvent;
listeners.forEach((l) => l());
});
window.addEventListener("appinstalled", () => {
deferred = null;
listeners.forEach((l) => l());
});
export const canPrompt = () => !!deferred;
export const isInstalled = () => matchMedia("(display-mode: standalone)").matches || (navigator as any).standalone === true;
export function onInstallChange(fn: () => void) {
listeners.add(fn);
return () => void listeners.delete(fn);
}
export async function promptInstall(): Promise<boolean> {
if (!deferred) return false;
await deferred.prompt();
const choice = await deferred.userChoice;
deferred = null;
listeners.forEach((l) => l());
return choice.outcome === "accepted";
}
/** Which manual instructions fit this browser. */
export function platformHint(): "ios" | "firefox" | "samsung" | "chrome" | "desktop" {
const ua = navigator.userAgent;
if (/iPhone|iPad|iPod/.test(ua)) return "ios";
if (/Firefox/.test(ua)) return "firefox";
if (/SamsungBrowser/.test(ua)) return "samsung";
if (/Android/.test(ua)) return "chrome";
return "desktop";
}
+1
View File
@@ -1,6 +1,7 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { registerSW } from "virtual:pwa-register";
import "./install";
import "./styles.css";
import { App } from "./App";
import { applyTheme } from "./theme";
+3
View File
@@ -25,6 +25,8 @@ export interface SessionError {
export interface State {
me: User | null;
/** Newer release (admins). */
update: { version: string; url: string; notes: string } | null;
connected: boolean;
sessions: Session[];
/** Includes subagent sessions loaded on demand. */
@@ -49,6 +51,7 @@ export interface State {
let state: State = {
me: null,
update: null,
connected: false,
sessions: [],
sessionById: {},
+32
View File
@@ -3054,3 +3054,35 @@ button.danger-solid {
word-break: break-all;
font-size: 12px;
}
.cli-steps {
margin: 0 0 10px;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.memory-card .icon {
color: var(--purple);
}
.update-chip {
display: flex;
align-items: center;
gap: 6px;
margin: 0 12px 8px;
padding: 6px 10px;
border-radius: 8px;
background: color-mix(in srgb, var(--accent) 18%, var(--bg1));
color: var(--fg0);
font-size: 13px;
text-decoration: none;
}
.whatsnew {
margin: 10px 0 16px;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 6px;
}
+3
View File
@@ -186,6 +186,9 @@ export interface User {
export interface Info {
name: string;
version: string;
commit?: string;
/** Newer release (admins only). */
update?: { version: string; url: string; notes: string } | null;
setup_required: boolean;
oidc: { label: string } | null;
user: User | null;
+19
View File
@@ -0,0 +1,19 @@
/** "What's new" entries per version (shown once after an update). */
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"0.1.0": {
en: [
"Terminal client `codecraft`: work locally like with Claude Code – Settings → Account → Terminal client",
"Automations: agents on a schedule (ask an agent in any chat to set one up)",
"Review loops per mode, project memory and personal memory",
"Web terminal, git worktrees, accept changes hunk by hunk, voice input",
"15 colour themes with light/dark/auto, push notifications on your phone",
],
de: [
"Terminal-Client `codecraft`: lokal arbeiten wie mit Claude Code – Einstellungen → Konto → Terminal-Client",
"Automationen: Agenten nach Zeitplan (lass sie dir in einem Chat von einem Agenten einrichten)",
"Review-Schleifen pro Modus, Projekt-Gedächtnis und persönliches Gedächtnis",
"Web-Terminal, Git-Worktrees, Änderungen stückweise übernehmen, Spracheingabe",
"15 Farbthemes mit Hell/Dunkel/Auto, Push-Benachrichtigungen aufs Handy",
],
},
};
+10 -2
View File
@@ -18,16 +18,24 @@ export default defineConfig({
background_color: "#282828",
display: "standalone",
start_url: "/",
id: "/",
scope: "/",
orientation: "any",
categories: ["developer", "productivity"],
icons: [
{ src: "icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "icon-512.png", sizes: "512x512", type: "image/png" },
{ src: "icon-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
{ src: "icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
{ src: "icon.svg", sizes: "any", type: "image/svg+xml" },
],
screenshots: [
{ src: "screenshots/wide.png", sizes: "1400x860", type: "image/png", form_factor: "wide", label: "Chat with an agent" },
{ src: "screenshots/narrow.png", sizes: "780x1688", type: "image/png", form_factor: "narrow", label: "CodeCraft on the phone" },
],
},
workbox: {
// The API is always live data; only the app shell is cached.
navigateFallbackDenylist: [/^\/api/],
navigateFallbackDenylist: [/^\/api/, /^\/cli/],
runtimeCaching: [],
// The STEP importer (OpenCascade, ~8 MB) is loaded only when needed.
globIgnores: ["**/occt-import-js*"],