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

+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}'"),
}
}