axoniac 0.1.1

Rust client for the Axoniac API — agent packs, tenants, memories
Documentation
//! Axoniac API client.
//!
//! Communicates with the Axoniac service API for agent pack provisioning,
//! tenant management, and memory access.

use reqwest::Client;
use serde::de::DeserializeOwned;
use serde_json::json;
use uuid::Uuid;

use crate::error::Error;
use crate::models::*;

const DEFAULT_BASE_URL: &str = "https://axoniac.com";

/// Client for the Axoniac API.
///
/// # Example
///
/// ```no_run
/// use axoniac::Axoniac;
///
/// # async fn example() -> Result<(), axoniac::Error> {
/// let client = Axoniac::new("ax_your_api_key", None)?;
///
/// // Provision an agent pack
/// let result = client.provision_pack(ProvisionPackRequest {
///     soul: SoulInput { name: "my_soul".into(), ..Default::default() },
///     personas: vec![],
///     pack: PackInput {
///         name: "My Pack".into(),
///         definition: serde_json::json!({}),
///         ..Default::default()
///     },
/// }).await?;
///
/// println!("Pack hash: {}", result.content_hash);
/// # Ok(())
/// # }
/// ```
pub struct Axoniac {
    api_key: String,
    base_url: String,
    client: Client,
}

impl Axoniac {
    /// Create a new Axoniac client.
    ///
    /// - `api_key`: An Axoniac API key (typically starts with `ax_`)
    /// - `base_url`: Optional custom base URL (defaults to `https://axoniac.com`)
    pub fn new(api_key: &str, base_url: Option<&str>) -> Result<Self, Error> {
        if api_key.is_empty() {
            return Err(Error::Config("API key cannot be empty".into()));
        }

        let client = Client::builder()
            .timeout(std::time::Duration::from_secs(30))
            .build()
            .map_err(Error::Request)?;

        Ok(Self {
            api_key: api_key.to_string(),
            base_url: base_url
                .unwrap_or(DEFAULT_BASE_URL)
                .trim_end_matches('/')
                .to_string(),
            client,
        })
    }

    // ── Private helpers ────────────────────────────────

    async fn request<T: DeserializeOwned>(
        &self,
        method: reqwest::Method,
        path: &str,
        body: Option<serde_json::Value>,
    ) -> Result<T, Error> {
        let url = format!("{}{}", self.base_url, path);
        tracing::debug!("{} {}", method, url);

        let mut req = self.client.request(method, &url).bearer_auth(&self.api_key);

        if let Some(body) = body {
            req = req.json(&body);
        }

        let resp = req.send().await?;
        let status = resp.status();

        if !status.is_success() {
            let text = resp.text().await.unwrap_or_default();
            let message = serde_json::from_str::<serde_json::Value>(&text)
                .ok()
                .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
                .unwrap_or(text);
            return Err(Error::Api {
                status: status.as_u16(),
                message,
            });
        }

        let body = resp.text().await?;
        serde_json::from_str(&body).map_err(Error::Json)
    }

    async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
        self.request(reqwest::Method::GET, path, None).await
    }

    async fn post<T: DeserializeOwned>(
        &self,
        path: &str,
        body: serde_json::Value,
    ) -> Result<T, Error> {
        self.request(reqwest::Method::POST, path, Some(body)).await
    }

    #[allow(dead_code)]
    async fn delete_req(&self, path: &str) -> Result<serde_json::Value, Error> {
        self.request(reqwest::Method::DELETE, path, None).await
    }

    // ── Packs ──────────────────────────────────────────

    /// Provision a complete agent pack (soul + personas + pack definition).
    ///
    /// This creates the soul, personas, and agent pack in Axoniac. If any
    /// component with the same content hash already exists, it is reused.
    ///
    /// Returns a `ProvisionResult` with the `content_hash` that can be used
    /// to install this pack on any tenant.
    pub async fn provision_pack(
        &self,
        request: ProvisionPackRequest,
    ) -> Result<ProvisionResult, Error> {
        self.post(
            "/api/service/packs/provision",
            serde_json::to_value(request).map_err(Error::Json)?,
        )
        .await
    }

    /// Install an agent pack on a tenant by content hash.
    pub async fn install_agent_pack(
        &self,
        tenant_id: &Uuid,
        content_hash: &str,
    ) -> Result<AgentPackInstallation, Error> {
        self.post(
            &format!("/api/service/tenants/{}/agent-packs/install", tenant_id),
            json!({ "content_hash": content_hash }),
        )
        .await
    }

    // ── Tenants ────────────────────────────────────────

    /// Create a new tenant.
    pub async fn create_tenant(
        &self,
        name: Option<&str>,
    ) -> Result<Tenant, Error> {
        self.post(
            "/api/service/tenants/create",
            json!({ "name": name }),
        )
        .await
    }

    /// Get the resolved agent pack bundle for a tenant.
    pub async fn get_tenant_bundle(
        &self,
        tenant_id: &Uuid,
    ) -> Result<AgentPackBundle, Error> {
        self.get(&format!("/api/service/tenants/{}/bundle", tenant_id))
            .await
    }

    // ── Catalog Search ─────────────────────────────────

    /// Search the public agent packs catalog.
    ///
    /// All parameters are optional — pass `None` to skip a filter.
    /// - `q`: text search on name/description
    /// - `category`: exact category match
    /// - `tag`: match packs containing this tag
    /// - `sort`: one of `name`, `created_at`, `updated_at`, `installs`
    pub async fn search_agent_packs(
        &self,
        q: Option<&str>,
        category: Option<&str>,
        tag: Option<&str>,
        sort: Option<&str>,
    ) -> Result<Vec<AgentPackWithStats>, Error> {
        let mut params = Vec::new();
        if let Some(v) = q { params.push(format!("q={}", v)); }
        if let Some(v) = category { params.push(format!("category={}", v)); }
        if let Some(v) = tag { params.push(format!("tag={}", v)); }
        if let Some(v) = sort { params.push(format!("sort={}", v)); }
        let qs = if params.is_empty() { String::new() } else { format!("?{}", params.join("&")) };
        self.get(&format!("/api/packs/catalog/search{}", qs)).await
    }

    /// Search the public skills catalog.
    ///
    /// All parameters are optional — pass `None` to skip a filter.
    /// - `q`: text search on name/description
    /// - `tag`: match skills containing this tag
    /// - `sort`: one of `name`, `created_at`, `updated_at`
    pub async fn search_skills(
        &self,
        q: Option<&str>,
        tag: Option<&str>,
        sort: Option<&str>,
    ) -> Result<Vec<Skill>, Error> {
        let mut params = Vec::new();
        if let Some(v) = q { params.push(format!("q={}", v)); }
        if let Some(v) = tag { params.push(format!("tag={}", v)); }
        if let Some(v) = sort { params.push(format!("sort={}", v)); }
        let qs = if params.is_empty() { String::new() } else { format!("?{}", params.join("&")) };
        self.get(&format!("/api/packs/skills/search{}", qs)).await
    }

    // ── Memories ───────────────────────────────────────

    /// List memories for a tenant.
    pub async fn list_memories(
        &self,
        tenant_id: &Uuid,
        limit: Option<u32>,
        offset: Option<u32>,
    ) -> Result<Vec<Memory>, Error> {
        let mut path = format!("/api/service/tenants/{}/memories", tenant_id);
        let mut params = Vec::new();
        if let Some(l) = limit {
            params.push(format!("limit={}", l));
        }
        if let Some(o) = offset {
            params.push(format!("offset={}", o));
        }
        if !params.is_empty() {
            path.push('?');
            path.push_str(&params.join("&"));
        }
        self.get(&path).await
    }
}