Files
codecraft/server/src/cli_api.rs
T

336 lines
16 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.
//! 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 prompt snippets (`/name` in the composer): [{id, name, text}].
pub async fn get_snippets(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let v: Value = app.store.user_setting(&user.id, "snippets")?;
Ok(Json(if v.is_array() { v } else { json!([]) }))
}
pub async fn put_snippets(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<Value>) -> ApiResult {
let ok = b.as_array().is_some_and(|a| {
a.len() <= 100
&& a.iter().all(|x| {
let name = x["name"].as_str().unwrap_or("");
!name.is_empty() && name.len() <= 40 && !name.contains(char::is_whitespace) && x["text"].as_str().is_some_and(|t| !t.is_empty() && t.len() <= 8000)
})
});
if !ok {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid snippets (name: one word, up to 40 characters; text: up to 8000)".into()));
}
let clean: Vec<Value> = b.as_array().unwrap().iter().map(|x| json!({"id": x["id"].as_str().filter(|i| i.len() <= 64).map(str::to_string).unwrap_or_else(crate::types::new_id), "name": x["name"], "text": x["text"]})).collect();
let v = Value::Array(clean);
app.store.set_user_setting(&user.id, "snippets", &v)?;
Ok(Json(v))
}
/// Order and pinning of the groups (folders, projects) in the chat list – per user, so
/// every device shows the same order: {"order": [group ids], "pinned": [group ids]}.
pub async fn get_sidebar(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let v: Value = app.store.user_setting(&user.id, "sidebar")?;
Ok(Json(if v.is_null() { json!({"order": [], "pinned": []}) } else { v }))
}
pub async fn put_sidebar(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<Value>) -> ApiResult {
let ids = |k: &str| b[k].as_array().is_some_and(|a| a.iter().all(|x| x.as_str().is_some_and(|s| s.len() <= 1000)));
if !ids("order") || !ids("pinned") || b.to_string().len() > 100_000 {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid chat list layout".into()));
}
let v = json!({"order": b["order"], "pinned": b["pinned"]});
app.store.set_user_setting(&user.id, "sidebar", &v)?;
Ok(Json(v))
}
/// 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)))
}