zeroski 0.0.1

zero.ski CLI — @-protocol dispatch, streaming chat, and trace feed for the Zero runtime
//! Thin HTTP client for zero.ski. One `Client` per CLI invocation; each
//! command calls the method it needs.
//!
//! Endpoints mirror the Worker's public contract:
//!   POST /api/chat          — SSE stream (OpenAI-compat delta frames)
//!   POST /@                 — @-protocol dispatch (JSON in/out)
//!   GET  /api/me            — caller identity (JSON)
//!   GET  /traces/agent/:id  — trace feed (JSON array)
//!
//! We keep this file unaware of any specific command shape — the command
//! modules stringify + parse for themselves.

use anyhow::{anyhow, Context, Result};
use reqwest::{header, Client as HttpClient, Response};
use serde::de::DeserializeOwned;
use serde_json::Value;

use crate::config::{Config, USER_AGENT};

pub struct Client {
    http: HttpClient,
    base: String,
    key: Option<String>,
}

impl Client {
    pub fn new(cfg: &Config) -> Result<Self> {
        let http = HttpClient::builder()
            .user_agent(USER_AGENT)
            .build()
            .context("building http client")?;
        Ok(Self {
            http,
            base: cfg.base.clone(),
            key: cfg.key.clone(),
        })
    }

    fn url(&self, path: &str) -> String {
        if path.starts_with("http://") || path.starts_with("https://") {
            path.to_string()
        } else if path.starts_with('/') {
            format!("{}{}", self.base, path)
        } else {
            format!("{}/{}", self.base, path)
        }
    }

    fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
        match &self.key {
            Some(k) => req.header(header::AUTHORIZATION, format!("Bearer {k}")),
            None => req,
        }
    }

    pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
        let resp = self.auth(self.http.get(self.url(path))).send().await?;
        decode_json(resp).await
    }

    pub async fn post_json<T: DeserializeOwned>(&self, path: &str, body: &Value) -> Result<T> {
        let resp = self
            .auth(self.http.post(self.url(path)).json(body))
            .send()
            .await?;
        decode_json(resp).await
    }

    pub async fn post_stream(&self, path: &str, body: &Value) -> Result<Response> {
        let resp = self
            .auth(self.http.post(self.url(path)).json(body))
            .send()
            .await?;
        if !resp.status().is_success() {
            let status = resp.status();
            let text = resp.text().await.unwrap_or_default();
            return Err(anyhow!("HTTP {status}: {}", truncate(&text, 400)));
        }
        Ok(resp)
    }
}

async fn decode_json<T: DeserializeOwned>(resp: Response) -> Result<T> {
    let status = resp.status();
    let bytes = resp.bytes().await.context("reading body")?;
    if !status.is_success() {
        let text = String::from_utf8_lossy(&bytes);
        return Err(anyhow!("HTTP {status}: {}", truncate(&text, 400)));
    }
    serde_json::from_slice::<T>(&bytes).with_context(|| {
        let text = String::from_utf8_lossy(&bytes);
        format!("decoding JSON: body was {}", truncate(&text, 200))
    })
}

fn truncate(s: &str, max: usize) -> String {
    if s.len() <= max {
        s.to_string()
    } else {
        format!("{}", &s[..max])
    }
}