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
+674
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user