Files
codecraft/server/src/cli_api.rs
T
Richard ZinkandClaude Opus 5.5 d152ad836d Split view, personal modes, budget warnings, server move script
- Up to three chats side by side (#/s/a+b+c), "Open beside" in the sidebar menu
- File manager tabs of a chat stored per user on the server (/sessions/{id}/tabs)
- Personal modes for every user (Settings → My modes, /account/modes)
- Budget meters (total, provider, images, opencode Go windows) with push warnings
  for admins at the warning share and 100 %, shown in the status overlay
- scripts/move-server.sh: move to another Docker host with bind mounts (Unraid);
  scripts/smoke-test.sh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:06:20 +01:00

296 lines
13 KiB
Rust

//! API for the local command line client (`codecraft`): the CLI runs the
//! agent loop and the file/shell tools on the user's machine; the server
//! provides the configuration (modes, models, rules, skills, theme), acts as
//! the model gateway (keys, budgets, statistics) and runs its MCP tools.
use crate::api::{ApiError, ApiResult, AppState};
use crate::provider::{ChatRequest, StreamEvent, ToolDef};
use crate::store::{UsageRecord, User};
use crate::tools::ToolCtx;
use crate::types::{Message, now_ms};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
use axum::{Extension, Json};
use futures::stream::Stream;
use serde::Deserialize;
use serde_json::{Value, json};
use std::convert::Infallible;
use std::path::PathBuf;
use tokio_util::sync::CancellationToken;
/// Everything the CLI needs to run like the web UI.
pub async fn config(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let cfg = app.config();
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
let agents: Vec<Value> = crate::prompts::agents_for(&app, &root, &root, Some(&user.id))
.into_iter()
.map(|a| {
let mut v = json!(a);
v["prompt"] = json!(a.prompt);
v
})
.collect();
let commands: Vec<Value> = crate::prompts::commands(&app, &root, &root)
.into_iter()
.filter(|c| c.description.len() < 400)
.map(|c| json!({"name": c.name, "description": c.description, "body": c.body, "agent": c.agent}))
.collect();
let skills: Vec<Value> = crate::prompts::skills(&app, &root, &root).into_iter().map(|s| json!({"name": s.name, "description": s.description})).collect();
let (direct, deferred) = app.mcp.tools(&cfg, Some(&user));
let mut mcp_tools: Vec<ToolDef> = direct.iter().map(|t| t.def()).collect();
if !deferred.is_empty() {
mcp_tools.push(crate::tools::Tool::def(&crate::mcp::McpSearch));
mcp_tools.push(crate::tools::Tool::def(&crate::mcp::McpCall));
}
let prefs: Value = app.store.user_setting(&user.id, "prefs")?;
let memory: String = app.store.user_setting(&user.id, "memory")?;
let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) };
Ok(Json(json!({
"version": codecraft_core::version::VERSION,
"user": user,
"models": app.registry().models_json(),
"default_model": default_model,
"small_model": cfg.server.small_model,
"currency": cfg.server.currency,
"base_prompt": crate::prompts::BASE_PROMPT,
"auto_mode_prompt": crate::prompts::AUTO_MODE_PROMPT,
"agents": agents,
"commands": commands,
"skills": skills,
"permissions": cfg.permissions,
"mcp_tools": mcp_tools,
"deferred_mcp": app.mcp.deferred_summary(&cfg, Some(&user)).iter().map(|(s, n, names)| json!({"server": s, "tools": n, "examples": names})).collect::<Vec<_>>(),
"prefs": prefs,
"memory": memory,
})))
}
/// A server skill with its supporting text files (templates).
pub async fn skill(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>) -> ApiResult {
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
let sk = crate::prompts::skills(&app, &root, &root).into_iter().find(|s| s.name == name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "skill not found".into()))?;
let dir = sk.path.parent().map(PathBuf::from).unwrap_or_default();
let content = std::fs::read_to_string(&sk.path)?;
let mut files = Vec::new();
for e in ignore::WalkBuilder::new(&dir).hidden(false).build().flatten() {
let p = e.path();
if !p.is_file() || p == sk.path || files.len() >= 50 {
continue;
}
if let Ok(text) = std::fs::read_to_string(p) {
if text.len() <= 200_000 {
files.push(json!({"path": p.strip_prefix(&dir).unwrap_or(p), "content": text}));
}
}
}
Ok(Json(json!({"name": sk.name, "description": sk.description, "content": content, "files": files})))
}
#[derive(Deserialize)]
pub struct LlmBody {
/// "provider/model"
model: String,
system: String,
messages: Vec<Message>,
#[serde(default)]
tools: Vec<ToolDef>,
max_tokens: Option<u64>,
#[serde(default)]
reasoning: bool,
effort: Option<String>,
/// Local session id (statistics group the requests by it).
session: Option<String>,
/// Mode / agent name (statistics).
agent: Option<String>,
}
/// Model gateway: one model request, streamed as SSE (`data: <json>`).
/// Events: text, reasoning, signature, tool_call, usage, done, error.
pub async fn llm_stream(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<LlmBody>) -> Result<Sse<impl Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
let (provider, mut model) = app.registry().resolve(&b.model).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
if let Some(e) = b.effort.filter(|e| !e.is_empty()) {
model.effort = Some(e);
}
let provider_name = b.model.split_once('/').map(|(p, _)| p.to_string()).unwrap_or_default();
crate::budget::check(&app, &provider_name, Some(&user.id)).map_err(|e| ApiError(StatusCode::PAYMENT_REQUIRED, format!("{e:#}")))?;
let max_tokens = b.max_tokens.or(model.max_output).unwrap_or(16_000);
let session = format!("cli:{}", b.session.unwrap_or_else(|| "default".into()));
let req = ChatRequest { model: model.clone(), system: b.system, messages: b.messages, tools: b.tools, max_tokens, reasoning: b.reasoning, session_id: Some(session.clone()) };
let agent = format!("cli:{}", b.agent.unwrap_or_else(|| "build".into()));
let spec = b.model.clone();
let (out_tx, out_rx) = tokio::sync::mpsc::channel::<Result<SseEvent, Infallible>>(256);
tokio::spawn(async move {
let slot = app.registry().acquire(&spec).await;
let started = std::time::Instant::now();
let (tx, mut rx) = tokio::sync::mpsc::channel(256);
let cancel = CancellationToken::new();
let c2 = cancel.clone();
let handle = tokio::spawn(async move {
let _slot = slot;
provider.stream(req, tx, c2).await
});
let mut usage = crate::types::Usage::default();
let mut ttft = None;
while let Some(ev) = rx.recv().await {
let v = match ev {
StreamEvent::TextDelta(t) => json!({"type": "text", "text": t}),
StreamEvent::ReasoningDelta(t) => json!({"type": "reasoning", "text": t}),
StreamEvent::ReasoningSignature(s) => json!({"type": "signature", "signature": s}),
StreamEvent::ToolCall { id, name, input, extra } => json!({"type": "tool_call", "id": id, "name": name, "input": input, "extra": extra}),
StreamEvent::Usage(u) => {
usage = u.clone();
json!({"type": "usage", "usage": u})
}
StreamEvent::Done { stop_reason } => json!({"type": "done", "stop_reason": stop_reason}),
};
if ttft.is_none() && v["type"] != "usage" {
ttft = Some(started.elapsed().as_millis() as u64);
}
if out_tx.send(Ok(SseEvent::default().data(v.to_string()))).await.is_err() {
// Client went away: stop the model request.
cancel.cancel();
break;
}
}
let result = handle.await;
let error = !matches!(result, Ok(Ok(())));
if let Ok(Err(e)) | Err(e) = result.map_err(anyhow::Error::from) {
let _ = out_tx.send(Ok(SseEvent::default().data(json!({"type": "error", "message": format!("{e:#}")}).to_string()))).await;
}
let (p, m) = spec.split_once('/').unwrap_or(("", spec.as_str()));
let _ = app.store.add_usage(&UsageRecord {
ts: now_ms(),
session_id: session.clone(),
root_session_id: session,
provider: p.into(),
model: m.into(),
agent,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cache_read_tokens: usage.cache_read_tokens,
cache_write_tokens: usage.cache_write_tokens,
ttft_ms: ttft.unwrap_or(0),
duration_ms: started.elapsed().as_millis() as u64,
cost: model.cost(&usage),
error,
user_id: Some(user.id.clone()),
allowance: usage.allowance,
});
});
let stream = futures::stream::unfold(out_rx, |mut rx| async move { rx.recv().await.map(|ev| (ev, rx)) });
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
}
#[derive(Deserialize)]
pub struct ToolBody {
name: String,
input: Value,
}
/// Run one of the server's MCP tools for the CLI (permission was asked locally).
pub async fn tool(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<ToolBody>) -> ApiResult {
let cfg = app.config();
let (direct, deferred) = app.mcp.tools(&cfg, Some(&user));
let tool: std::sync::Arc<dyn crate::tools::Tool> = match b.name.as_str() {
"mcp_search" if !deferred.is_empty() => std::sync::Arc::new(crate::mcp::McpSearch),
"mcp_call" if !deferred.is_empty() => std::sync::Arc::new(crate::mcp::McpCall),
n => direct.into_iter().find(|t| t.name() == n).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("no MCP tool '{n}' for you")))?,
};
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
let ctx = ToolCtx {
app: app.clone(),
session_id: format!("cli-{}", user.id),
call_id: crate::types::new_id(),
cwd: root.clone(),
cancel: CancellationToken::new(),
agent: "build".into(),
roots: app.user_roots(Some(&user.id)),
sandbox: crate::sandbox::spec_for(&app, Some(&user.id)),
clean_room: false,
owner_id: Some(user.id.clone()),
};
app.audit(Some(&user), "cli.mcp", &b.name, "");
match tool.run(b.input, &ctx).await {
Ok(out) => Ok(Json(json!({"content": out.content, "is_error": out.is_error, "images": out.images}))),
Err(e) => Ok(Json(json!({"content": format!("Error: {e:#}"), "is_error": true}))),
}
}
/// Interface preferences (theme) shared between web UI and CLI.
pub async fn get_prefs(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let v: Value = app.store.user_setting(&user.id, "prefs")?;
Ok(Json(if v.is_null() { json!({}) } else { v }))
}
pub async fn put_prefs(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<Value>) -> ApiResult {
if !b.is_object() || b.to_string().len() > 4000 {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid preferences".into()));
}
app.store.set_user_setting(&user.id, "prefs", &b)?;
Ok(Json(b))
}
/// Personal memory (applies to all projects, web and CLI).
pub async fn get_memory(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let m: String = app.store.user_setting(&user.id, "memory")?;
Ok(Json(json!({"memory": m})))
}
#[derive(Deserialize)]
pub struct MemoryBody {
memory: String,
}
pub async fn put_memory(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<MemoryBody>) -> ApiResult {
if b.memory.len() > 20_000 {
return Err(ApiError(StatusCode::BAD_REQUEST, "the memory is limited to 20,000 characters".into()));
}
app.store.set_user_setting(&user.id, "memory", &b.memory)?;
Ok(Json(json!({"memory": b.memory})))
}
// --- prompt templates ---------------------------------------------------------
/// Owner id of the templates every user sees (edited by admins).
const GLOBAL_TEMPLATES: &str = "*";
#[derive(Debug, Clone, serde::Serialize, Deserialize, Default)]
pub struct Template {
pub id: String,
pub name: String,
pub text: String,
}
pub async fn get_templates(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let personal: Vec<Template> = app.store.user_setting(&user.id, "templates")?;
let global: Vec<Template> = app.store.user_setting(GLOBAL_TEMPLATES, "templates")?;
Ok(Json(json!({"personal": personal, "global": global, "can_edit_global": user.is_admin()})))
}
pub async fn put_templates(Extension(user): Extension<User>, State(app): State<AppState>, Path(scope): Path<String>, Json(list): Json<Vec<Template>>) -> ApiResult {
let owner = match scope.as_str() {
"personal" => user.id.clone(),
"global" => {
crate::auth::require_admin(&user)?;
GLOBAL_TEMPLATES.to_string()
}
_ => return Err(ApiError(StatusCode::NOT_FOUND, "unknown scope".into())),
};
if list.len() > 200 || list.iter().any(|t| t.name.trim().is_empty() || t.name.len() > 100 || t.text.len() > 20_000) {
return Err(ApiError(StatusCode::BAD_REQUEST, "templates need a name (max. 100 characters) and at most 20,000 characters of text".into()));
}
let list: Vec<Template> = list
.into_iter()
.map(|mut t| {
if t.id.is_empty() {
t.id = uuid::Uuid::new_v4().simple().to_string();
}
t.name = t.name.trim().to_string();
t
})
.collect();
app.store.set_user_setting(&owner, "templates", &list)?;
Ok(Json(json!(list)))
}