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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 23:18:50 +01:00
1 parent fca1c41087
commit aa1f8cf016
40 files changed
+2454 -814

No files matched your search

+674
View File
@@ -0,0 +1,674 @@
//! `codecraft` – command line client for CodeCraft.
//!
//! Authenticates with a personal API token (Settings → Account → API tokens).
use anyhow::{Context, Result, anyhow, bail};
use clap::{Parser, Subcommand};
use futures_util::{SinkExt, StreamExt};
use serde_json::{Value, json};
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio_tungstenite::tungstenite::{Message as WsMessage, client::IntoClientRequest};
#[derive(Parser)]
#[command(name = "codecraft", version, about = "Command line client for CodeCraft", propagate_version = true)]
struct Cli {
/// Server URL (overrides the saved login)
#[arg(long, env = "CODECRAFT_URL", global = true)]
url: Option<String>,
/// API token (overrides the saved login)
#[arg(long, env = "CODECRAFT_TOKEN", global = true, hide_env_values = true)]
token: Option<String>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Save server URL and API token (Settings → Account → API tokens)
Login { url: String, token: String },
/// Remove the saved login
Logout,
/// Server, user, running sessions and costs this month
Status,
/// List sessions
Sessions {
/// Show at most N sessions
#[arg(short = 'n', long, default_value_t = 20)]
limit: usize,
},
/// Print a session's conversation
Show { session: String },
/// Export a session as Markdown or JSON
Export {
session: String,
#[arg(long, default_value = "md")]
format: String,
#[arg(short, long)]
output: Option<PathBuf>,
},
/// Start a new session with a prompt and follow it
Run {
prompt: String,
/// Working directory on the server (default: your folder)
#[arg(long)]
cwd: Option<String>,
/// Mode: build, plan, craft, …
#[arg(long, default_value = "build")]
agent: String,
/// Model "provider/model"
#[arg(long)]
model: Option<String>,
/// Permission mode: default, auto, accept_edits, bypass
#[arg(long)]
mode: Option<String>,
/// Reasoning effort: low, medium, high, max
#[arg(long)]
effort: Option<String>,
/// Don't wait – print the session id and exit
#[arg(long)]
detach: bool,
},
/// Send a prompt to an existing session and follow it
Send {
session: String,
prompt: String,
#[arg(long)]
effort: Option<String>,
},
/// Interactive chat (new session, or continue one)
Chat {
session: Option<String>,
#[arg(long)]
cwd: Option<String>,
#[arg(long, default_value = "build")]
agent: String,
#[arg(long)]
model: Option<String>,
},
/// Follow a running session (answer permissions and questions)
Attach { session: String },
/// Stop a running session
Stop { session: String },
/// Automations
Jobs {
#[command(subcommand)]
cmd: Option<JobCmd>,
},
/// Files in your folders
Files {
#[command(subcommand)]
cmd: FileCmd,
},
/// Costs and tokens of the last days
Costs {
#[arg(long, default_value_t = 30)]
days: u32,
},
}
#[derive(Subcommand)]
enum JobCmd {
/// List automations (default)
List,
/// Start an automation now
Run { id: String },
/// Enable an automation
On { id: String },
/// Disable an automation
Off { id: String },
}
#[derive(Subcommand)]
enum FileCmd {
/// List a folder
Ls { path: Option<String> },
/// Download a file
Get {
path: String,
#[arg(short, long)]
output: Option<PathBuf>,
},
/// Upload a file (chunked, large files supported)
Put { local: PathBuf, remote: String },
}
// --- config ------------------------------------------------------------------------
fn config_path() -> PathBuf {
let base = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".config"));
base.join("codecraft").join("cli.json")
}
fn load_login(cli: &Cli) -> Result<(String, String)> {
let saved: Value = std::fs::read_to_string(config_path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
let url = cli.url.clone().or_else(|| saved["url"].as_str().map(String::from));
let token = cli.token.clone().or_else(|| saved["token"].as_str().map(String::from));
match (url, token) {
(Some(u), Some(t)) => Ok((u.trim_end_matches('/').to_string(), t)),
_ => bail!("not logged in – run `codecraft login <url> <token>` (create a token under Settings → Account → API tokens)"),
}
}
// --- output ------------------------------------------------------------------------
struct Style {
on: bool,
}
impl Style {
fn paint(&self, code: &str, s: &str) -> String {
if self.on { format!("\x1b[{code}m{s}\x1b[0m") } else { s.to_string() }
}
fn dim(&self, s: &str) -> String {
self.paint("2", s)
}
fn bold(&self, s: &str) -> String {
self.paint("1", s)
}
fn accent(&self, s: &str) -> String {
self.paint("33", s)
}
fn red(&self, s: &str) -> String {
self.paint("31", s)
}
fn green(&self, s: &str) -> String {
self.paint("32", s)
}
}
fn short(id: &str) -> &str {
&id[id.len().saturating_sub(8)..]
}
fn fmt_time(ms: i64) -> String {
let secs = (now_ms() - ms) / 1000;
match secs {
s if s < 60 => "just now".into(),
s if s < 3600 => format!("{} min ago", s / 60),
s if s < 86_400 => format!("{} h ago", s / 3600),
s => format!("{} d ago", s / 86_400),
}
}
fn now_ms() -> i64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
}
// --- API ---------------------------------------------------------------------------
struct Api {
base: String,
token: String,
http: reqwest::Client,
}
impl Api {
fn new(base: String, token: String) -> Self {
let http = reqwest::Client::builder().user_agent(concat!("codecraft-cli/", env!("CARGO_PKG_VERSION"))).build().expect("http client");
Self { base, token, http }
}
async fn send(&self, req: reqwest::RequestBuilder) -> Result<reqwest::Response> {
let resp = req.bearer_auth(&self.token).send().await.context("cannot reach the server")?;
if resp.status().is_success() {
return Ok(resp);
}
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let msg = serde_json::from_str::<Value>(&body).ok().and_then(|v| v["error"].as_str().map(String::from)).unwrap_or(body);
if status.as_u16() == 401 {
bail!("unauthorized – the token is invalid or expired (codecraft login …)");
}
bail!("{} {}", status.as_u16(), msg.trim())
}
async fn get(&self, path: &str) -> Result<Value> {
Ok(self.send(self.http.get(format!("{}/api{path}", self.base))).await?.json().await?)
}
async fn post(&self, path: &str, body: Value) -> Result<Value> {
Ok(self.send(self.http.post(format!("{}/api{path}", self.base)).json(&body)).await?.json().await?)
}
async fn patch(&self, path: &str, body: Value) -> Result<Value> {
Ok(self.send(self.http.patch(format!("{}/api{path}", self.base)).json(&body)).await?.json().await?)
}
/// Resolve a full or shortened (suffix) session id.
async fn session_id(&self, id: &str) -> Result<String> {
if id.len() >= 24 {
return Ok(id.to_string());
}
let list = self.get("/sessions").await?;
let hits: Vec<String> = list.as_array().into_iter().flatten().filter_map(|s| s["id"].as_str()).filter(|s| s.ends_with(id)).map(String::from).collect();
match hits.len() {
1 => Ok(hits[0].clone()),
0 => bail!("no session matches '{id}'"),
_ => bail!("'{id}' is ambiguous – use more characters"),
}
}
}
// --- following a session -----------------------------------------------------------
/// Read one line from stdin (async, so events keep flowing).
async fn read_line(prompt: &str) -> Result<Option<String>> {
print!("{prompt}");
std::io::stdout().flush()?;
let mut line = String::new();
let n = BufReader::new(tokio::io::stdin()).read_line(&mut line).await?;
Ok(if n == 0 { None } else { Some(line.trim_end_matches(['\r', '\n']).to_string()) })
}
async fn answer_permission(api: &Api, st: &Style, ev: &Value) -> Result<()> {
println!("\n{} {} {}", st.accent("? Permission"), st.bold(ev["tool"].as_str().unwrap_or("")), ev["title"].as_str().unwrap_or(""));
if let Some(cmd) = ev["input"]["command"].as_str() {
println!(" {}", st.dim(&format!("$ {cmd}")));
}
if let Some(diff) = ev["diff"].as_str() {
for l in diff.lines().take(60) {
let line = if l.starts_with('+') && !l.starts_with("+++") {
st.green(l)
} else if l.starts_with('-') && !l.starts_with("---") {
st.red(l)
} else {
st.dim(l)
};
println!(" {line}");
}
}
loop {
let Some(ans) = read_line(" [y]es once / [a]lways / [n]o (n: <feedback>) > ").await? else { return Ok(()) };
let ans = ans.trim();
let (reply, message) = match ans.chars().next().map(|c| c.to_ascii_lowercase()) {
Some('y') | Some('j') => ("once", None),
Some('a') => ("always", None),
Some('n') => ("reject", ans.split_once(':').map(|(_, m)| m.trim().to_string()).filter(|m| !m.is_empty())),
_ => continue,
};
api.post(&format!("/permissions/{}", ev["id"].as_str().unwrap_or("")), json!({"reply": reply, "message": message})).await?;
return Ok(());
}
}
async fn answer_question(api: &Api, st: &Style, ev: &Value) -> Result<()> {
let mut answers = serde_json::Map::new();
for q in ev["questions"].as_array().into_iter().flatten() {
let text = q["question"].as_str().unwrap_or("");
println!("\n{} {}", st.accent("? Question"), st.bold(text));
let options: Vec<&str> = q["options"].as_array().into_iter().flatten().filter_map(|o| o["label"].as_str()).collect();
for (i, o) in options.iter().enumerate() {
println!(" {}. {o}", i + 1);
}
let Some(ans) = read_line(" answer (number or text) > ").await? else { return Ok(()) };
let value = ans.trim().parse::<usize>().ok().and_then(|n| options.get(n.wrapping_sub(1)).map(|s| s.to_string())).unwrap_or_else(|| ans.trim().to_string());
answers.insert(text.to_string(), Value::String(value));
}
api.post(&format!("/questions/{}", ev["id"].as_str().unwrap_or("")), json!({"answers": answers})).await?;
Ok(())
}
/// Stream a session's events until it is idle. `wait_start`: the turn was just
/// submitted, so an initial "idle" state is not the end yet.
async fn follow(api: &Api, st: &Style, session: &str, wait_start: bool) -> Result<()> {
let ws_url = format!("{}/api/ws", api.base.replacen("http", "ws", 1));
let mut req = ws_url.into_client_request()?;
req.headers_mut().insert("Authorization", format!("Bearer {}", api.token).parse()?);
let (mut ws, _) = tokio_tungstenite::connect_async(req).await.context("cannot open the event stream")?;
// Requests that were already pending before we connected.
for ev in api.get("/pending").await?.as_array().into_iter().flatten() {
if ev["root_session_id"] == session || ev["session_id"] == session {
handle_interaction(api, st, ev).await?;
}
}
let mut started = !wait_start || api.get(&format!("/sessions/{session}")).await?["busy"] == true;
let mut in_text = false;
let mut in_thinking = false;
let idle_check = tokio::time::sleep(std::time::Duration::from_secs(5));
tokio::pin!(idle_check);
loop {
tokio::select! {
msg = ws.next() => {
let Some(msg) = msg else { break };
let text = match msg? {
WsMessage::Text(t) => t.to_string(),
WsMessage::Ping(p) => { ws.send(WsMessage::Pong(p)).await?; continue }
WsMessage::Close(_) => break,
_ => continue,
};
let ev: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
let sid = ev["session_id"].as_str().or(ev["session"]["id"].as_str()).unwrap_or("");
let root = ev["root_session_id"].as_str().unwrap_or(sid);
let mine = sid == session;
match ev["type"].as_str().unwrap_or("") {
"text_delta" if mine => {
if in_thinking { println!(); in_thinking = false; }
print!("{}", ev["text"].as_str().unwrap_or(""));
std::io::stdout().flush()?;
in_text = true;
}
"reasoning_delta" if mine => {
if !in_thinking { print!("{}", st.dim("\n[thinking] ")); in_thinking = true; }
print!("{}", st.dim(ev["text"].as_str().unwrap_or("")));
std::io::stdout().flush()?;
}
"tool_started" if mine => {
if in_text || in_thinking { println!(); in_text = false; in_thinking = false; }
let input = &ev["input"];
let detail = input["description"].as_str().or(input["command"].as_str()).or(input["path"].as_str()).or(input["pattern"].as_str()).or(input["query"].as_str()).unwrap_or("");
println!("{} {} {}", st.accent("▸"), st.bold(ev["name"].as_str().unwrap_or("")), st.dim(detail));
}
"permission_request" | "question_request" if root == session || mine => {
if in_text { println!(); in_text = false; }
handle_interaction(api, st, &ev).await?;
}
"error" if mine => {
println!("\n{} {}", st.red("error:"), ev["message"].as_str().unwrap_or(""));
}
"waiting" if mine => {
if let Some(r) = ev["reason"].as_str() { println!("{}", st.dim(&format!("… waiting ({r})"))); }
}
"session_status" if mine => {
if ev["busy"] == true { started = true; }
else if started { if in_text { println!(); } break; }
}
_ => {}
}
}
_ = &mut idle_check, if !started => {
// The turn may have finished before we connected.
if api.get(&format!("/sessions/{session}")).await?["busy"] != true { break; }
started = true;
}
_ = tokio::signal::ctrl_c() => {
println!("\n{}", st.dim("detached – the session keeps running (codecraft stop <id> to stop it)"));
break;
}
}
}
Ok(())
}
async fn handle_interaction(api: &Api, st: &Style, ev: &Value) -> Result<()> {
if !std::io::stdin().is_terminal() {
println!("{}", st.accent("? waiting for an answer in the web UI (stdin is not a terminal)"));
return Ok(());
}
match ev["type"].as_str() {
Some("permission_request") => answer_permission(api, st, ev).await,
Some("question_request") => answer_question(api, st, ev).await,
_ => Ok(()),
}
}
async fn create_session(api: &Api, cwd: Option<String>, agent: &str, model: Option<String>, mode: Option<String>) -> Result<String> {
let s = api.post("/sessions", json!({"cwd": cwd, "agent": agent, "model": model, "permission_mode": mode})).await?;
s["id"].as_str().map(String::from).ok_or_else(|| anyhow!("unexpected answer from the server"))
}
// --- commands ------------------------------------------------------------------------
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let st = Style { on: std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none() };
if let Err(e) = run(cli, &st).await {
eprintln!("{} {e:#}", st.red("error:"));
std::process::exit(1);
}
}
async fn run(cli: Cli, st: &Style) -> Result<()> {
if let Cmd::Login { url, token } = &cli.cmd {
let api = Api::new(url.trim_end_matches('/').to_string(), token.clone());
let info = api.get("/info").await?;
let user = info["user"]["username"].as_str().ok_or_else(|| anyhow!("the token was not accepted"))?;
let path = config_path();
std::fs::create_dir_all(path.parent().unwrap())?;
std::fs::write(&path, serde_json::to_string_pretty(&json!({"url": api.base, "token": token}))?)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
}
println!("Logged in to {} as {} (CodeCraft {}). Saved to {}", api.base, st.bold(user), info["version"].as_str().unwrap_or("?"), path.display());
return Ok(());
}
if let Cmd::Logout = cli.cmd {
let _ = std::fs::remove_file(config_path());
println!("Logged out.");
return Ok(());
}
let (url, token) = load_login(&cli)?;
let api = Api::new(url, token);
match cli.cmd {
Cmd::Login { .. } | Cmd::Logout => unreachable!(),
Cmd::Status => {
let info = api.get("/info").await?;
let sessions = api.get("/sessions").await?;
let busy: Vec<&Value> = sessions.as_array().into_iter().flatten().filter(|s| s["busy"] == true).collect();
let stats = api.get(&format!("/stats?from={}", month_start_ms())).await.unwrap_or(Value::Null);
println!("{} {} · {}", st.bold("CodeCraft"), info["version"].as_str().unwrap_or("?"), api.base);
println!("User: {}{}", info["user"]["username"].as_str().unwrap_or("?"), if info["user"]["role"] == "admin" { " (admin)" } else { "" });
println!("Running: {} session(s)", busy.len());
for s in busy {
println!(" {} {}", st.dim(short(s["id"].as_str().unwrap_or(""))), s["title"].as_str().unwrap_or(""));
}
if let Some(cost) = stats["total"]["cost"].as_f64() {
println!("30 days: {cost:.2} {} · {} requests", stats["currency"].as_str().unwrap_or(""), stats["total"]["requests"]);
}
}
Cmd::Sessions { limit } => {
let list = api.get("/sessions").await?;
for s in list.as_array().into_iter().flatten().take(limit) {
let busy = if s["busy"] == true { st.accent(" ● running") } else { String::new() };
println!(
"{} {:<40} {}{}",
st.dim(short(s["id"].as_str().unwrap_or(""))),
truncate(s["title"].as_str().filter(|t| !t.is_empty()).unwrap_or("(untitled)"), 40),
st.dim(&format!("{} · {} · {}", s["agent"].as_str().unwrap_or(""), s["cwd"].as_str().unwrap_or(""), fmt_time(s["updated_at"].as_i64().unwrap_or(0)))),
busy
);
}
}
Cmd::Show { session } => {
let id = api.session_id(&session).await?;
let s = api.get(&format!("/sessions/{id}")).await?;
println!("{} {}", st.bold(s["session"]["title"].as_str().unwrap_or("")), st.dim(s["session"]["cwd"].as_str().unwrap_or("")));
for m in s["messages"].as_array().into_iter().flatten() {
for p in m["parts"].as_array().into_iter().flatten() {
match (m["role"].as_str(), p["type"].as_str()) {
(Some("user"), Some("text")) => println!("\n{} {}", st.accent("you ›"), p["text"].as_str().unwrap_or("")),
(_, Some("text")) => println!("\n{}", p["text"].as_str().unwrap_or("")),
(_, Some("tool_call")) => println!("{} {}", st.accent("▸"), st.bold(p["name"].as_str().unwrap_or(""))),
_ => {}
}
}
}
}
Cmd::Export { session, format, output } => {
let id = api.session_id(&session).await?;
let resp = api.send(api.http.get(format!("{}/api/sessions/{id}/export?format={format}", api.base))).await?;
let bytes = resp.bytes().await?;
match output {
Some(p) => {
std::fs::write(&p, &bytes)?;
println!("Saved {}", p.display());
}
None => std::io::stdout().write_all(&bytes)?,
}
}
Cmd::Run { prompt, cwd, agent, model, mode, effort, detach } => {
let id = create_session(&api, cwd, &agent, model, mode).await?;
api.post(&format!("/sessions/{id}/prompt"), json!({"text": prompt, "effort": effort})).await?;
if detach {
println!("{id}");
return Ok(());
}
println!("{}", st.dim(&format!("session {} · {}/#/s/{id}", short(&id), api.base)));
follow(&api, st, &id, true).await?;
}
Cmd::Send { session, prompt, effort } => {
let id = api.session_id(&session).await?;
api.post(&format!("/sessions/{id}/prompt"), json!({"text": prompt, "effort": effort})).await?;
follow(&api, st, &id, true).await?;
}
Cmd::Chat { session, cwd, agent, model } => {
let id = match session {
Some(s) => api.session_id(&s).await?,
None => create_session(&api, cwd, &agent, model, None).await?,
};
println!("{}", st.dim(&format!("chat in session {} – empty line or Ctrl+D to quit", short(&id))));
loop {
let Some(line) = read_line(&format!("\n{} ", st.accent("you ›"))).await? else { break };
if line.trim().is_empty() {
break;
}
api.post(&format!("/sessions/{id}/prompt"), json!({"text": line})).await?;
follow(&api, st, &id, true).await?;
}
}
Cmd::Attach { session } => {
let id = api.session_id(&session).await?;
follow(&api, st, &id, false).await?;
}
Cmd::Stop { session } => {
let id = api.session_id(&session).await?;
api.post(&format!("/sessions/{id}/abort"), json!({})).await?;
println!("Stopped {}.", short(&id));
}
Cmd::Jobs { cmd } => match cmd.unwrap_or(JobCmd::List) {
JobCmd::List => {
for j in api.get("/jobs").await?.as_array().into_iter().flatten() {
let on = if j["enabled"] == true { st.green("on ") } else { st.dim("off") };
println!(
"{} {} {:<30} {} {}",
st.dim(short(j["id"].as_str().unwrap_or(""))),
on,
truncate(j["spec"]["name"].as_str().unwrap_or("?"), 30),
j["spec"]["schedule"].as_str().unwrap_or("?"),
st.dim(&format!("last: {}", j["last_status"].as_str().unwrap_or("-")))
);
}
}
JobCmd::Run { id } => {
let id = job_id(&api, &id).await?;
let r = api.post(&format!("/jobs/{id}/run"), json!({})).await?;
let sid = r["session_id"].as_str().unwrap_or("");
println!("{}", st.dim(&format!("started session {}", short(sid))));
follow(&api, st, sid, true).await?;
}
JobCmd::On { id } => {
let id = job_id(&api, &id).await?;
api.patch(&format!("/jobs/{id}"), json!({"enabled": true})).await?;
println!("Enabled.");
}
JobCmd::Off { id } => {
let id = job_id(&api, &id).await?;
api.patch(&format!("/jobs/{id}"), json!({"enabled": false})).await?;
println!("Disabled.");
}
},
Cmd::Files { cmd } => match cmd {
FileCmd::Ls { path } => {
let q = path.map(|p| format!("?path={}", urlencode(&p))).unwrap_or_default();
let l = api.get(&format!("/files/list{q}")).await?;
println!("{}", st.dim(l["path"].as_str().unwrap_or("")));
for e in l["entries"].as_array().into_iter().flatten() {
let name = e["name"].as_str().unwrap_or("");
if e["is_dir"] == true {
println!(" {}/", st.bold(name));
} else {
println!(" {name:<40} {}", st.dim(&human(e["size"].as_u64().unwrap_or(0))));
}
}
}
FileCmd::Get { path, output } => {
let resp = api.send(api.http.get(format!("{}/api/files/raw?path={}&download=true", api.base, urlencode(&path)))).await?;
let bytes = resp.bytes().await?;
let out = output.unwrap_or_else(|| PathBuf::from(path.rsplit('/').next().unwrap_or("download")));
std::fs::write(&out, &bytes)?;
println!("Saved {} ({})", out.display(), human(bytes.len() as u64));
}
FileCmd::Put { local, remote } => {
let data = tokio::fs::read(&local).await.with_context(|| format!("cannot read {}", local.display()))?;
let remote = if remote.ends_with('/') { format!("{remote}{}", local.file_name().and_then(|n| n.to_str()).unwrap_or("upload")) } else { remote };
const CHUNK: usize = 8 * 1024 * 1024;
let mut offset = 0usize;
loop {
let end = (offset + CHUNK).min(data.len());
let url = format!("{}/api/files/upload?path={}&offset={offset}", api.base, urlencode(&remote));
api.send(api.http.put(url).body(data[offset..end].to_vec())).await?;
offset = end;
if std::io::stderr().is_terminal() {
eprint!("\r{} / {}", human(offset as u64), human(data.len() as u64));
}
if offset >= data.len() {
break;
}
}
eprintln!();
println!("Uploaded to {remote}");
}
},
Cmd::Costs { days } => {
let from = now_ms() - days as i64 * 86_400_000;
let s = api.get(&format!("/stats?from={from}")).await?;
let cur = s["currency"].as_str().unwrap_or("");
let t = &s["total"];
println!("{} (last {days} days)", st.bold("Costs"));
println!(" total {:.2} {cur}", t["cost"].as_f64().unwrap_or(0.0));
println!(" requests {}", t["requests"]);
println!(" tokens {} in · {} out", t["input_tokens"], t["output_tokens"]);
if let Some(models) = s["by_model"].as_array() {
println!(" {}", st.dim("by model:"));
for m in models {
println!(" {:<40} {:>8.2} {cur}", truncate(m["key"].as_str().or(m["model"].as_str()).unwrap_or("?"), 40), m["cost"].as_f64().unwrap_or(0.0));
}
}
}
}
Ok(())
}
async fn job_id(api: &Api, id: &str) -> Result<String> {
if id.len() >= 24 {
return Ok(id.to_string());
}
let hits: Vec<String> = api.get("/jobs").await?.as_array().into_iter().flatten().filter_map(|j| j["id"].as_str()).filter(|j| j.ends_with(id)).map(String::from).collect();
match hits.len() {
1 => Ok(hits[0].clone()),
0 => bail!("no automation matches '{id}'"),
_ => bail!("'{id}' is ambiguous"),
}
}
fn month_start_ms() -> i64 {
now_ms() - 30 * 86_400_000
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n { s.to_string() } else { format!("{}…", s.chars().take(n - 1).collect::<String>()) }
}
fn human(n: u64) -> String {
match n {
n if n < 1024 => format!("{n} B"),
n if n < 1024 * 1024 => format!("{:.1} KB", n as f64 / 1024.0),
n if n < 1024 * 1024 * 1024 => format!("{:.1} MB", n as f64 / 1048576.0),
n => format!("{:.1} GB", n as f64 / 1073741824.0),
}
}
fn urlencode(s: &str) -> String {
s.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => (b as char).to_string(),
_ => format!("%{b:02X}"),
})
.collect()
}