Add API tokens, CLI server API and shared core crate
- Personal API tokens (full/read scope, expiry, revoke) and CLI password login - Cargo workspace: codecraft-core (types, providers, permission rules) shared by server and the upcoming terminal client; server re-exports the old paths - CLI server API: /cli/config, /cli/skill, /cli/tool (server MCP tools), /llm/stream model gateway with budgets and usage per user - Theme/language preferences mirrored to the server; personal memory endpoint - Statistics: provider and mode tables stacked; CLI usage labelled - cli/: work in progress (terminal client is being rewritten as a local TUI) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fca1c41087
commit
aa1f8cf016
40 files changed
+2454
-814
No files matched your search
@@ -0,0 +1,250 @@
|
||||
//! 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(&app, &root, &root)
|
||||
.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": env!("CARGO_PKG_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 req = ChatRequest { model: model.clone(), system: b.system, messages: b.messages, tools: b.tools, max_tokens, reasoning: b.reasoning };
|
||||
let session = format!("cli:{}", b.session.unwrap_or_else(|| "default".into()));
|
||||
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()),
|
||||
});
|
||||
});
|
||||
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})))
|
||||
}
|
||||
Reference in new issue
Block a user