Files
codecraft/cli/src/server.rs
T
Richard ZinkandClaude Opus 5.5 6dffcb501a 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>
2026-11-09 00:13:06 +01:00

139 lines
5.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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)))
}
}