Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
336 lines
16 KiB
Rust
336 lines
16 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 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)))
|
||
}
|