//! 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, State(app): State) -> ApiResult { let cfg = app.config(); let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace()); let agents: Vec = 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 = 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 = 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 = 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::>(), "prefs": prefs, "memory": memory, }))) } /// A server skill with its supporting text files (templates). pub async fn skill(Extension(user): Extension, State(app): State, Path(name): Path) -> 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, #[serde(default)] tools: Vec, max_tokens: Option, #[serde(default)] reasoning: bool, effort: Option, /// Local session id (statistics group the requests by it). session: Option, /// Mode / agent name (statistics). agent: Option, } /// Model gateway: one model request, streamed as SSE (`data: `). /// Events: text, reasoning, signature, tool_call, usage, done, error. pub async fn llm_stream(Extension(user): Extension, State(app): State, Json(b): Json) -> Result>>, 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::>(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, State(app): State, Json(b): Json) -> ApiResult { let cfg = app.config(); let (direct, deferred) = app.mcp.tools(&cfg, Some(&user)); let tool: std::sync::Arc = 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, State(app): State) -> 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, State(app): State, Json(b): Json) -> 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, State(app): State) -> 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, State(app): State, Json(b): Json) -> 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, State(app): State) -> ApiResult { let personal: Vec