Skip to main content

exeora_cli/
api.rs

1use crate::{auth::AuthManager, policy::CommandPolicy};
2use anyhow::{Result, bail};
3use serde::{Deserialize, Serialize, de::DeserializeOwned};
4use std::sync::Arc;
5use url::Url;
6
7#[derive(Debug, Clone, Deserialize, Serialize)]
8#[serde(rename_all = "camelCase")]
9pub struct DeviceView {
10    pub id: String,
11    pub name: String,
12    pub platform: String,
13    pub cli_version: Option<String>,
14    pub online: Option<bool>,
15    pub last_seen_at: Option<u64>,
16    pub revoked_at: Option<u64>,
17}
18
19#[derive(Debug, Clone, Deserialize, Serialize)]
20#[serde(rename_all = "camelCase")]
21pub struct ProjectView {
22    pub id: String,
23    pub slug: String,
24    pub name: String,
25    pub device_id: String,
26    pub local_path: String,
27    pub mcp_url: String,
28    pub policy: CommandPolicy,
29    pub created_at: u64,
30}
31
32#[derive(Debug, Clone, Deserialize, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub struct UserView {
35    pub id: String,
36    pub email: String,
37    pub name: Option<String>,
38    pub plan: Option<String>,
39    pub limits: Option<serde_json::Value>,
40    pub usage: Option<serde_json::Value>,
41}
42
43#[derive(Debug, Clone, Deserialize, Serialize)]
44#[serde(rename_all = "camelCase")]
45pub struct ToolCallView {
46    pub id: String,
47    pub project_id: String,
48    pub tool: String,
49    pub status: String,
50    pub duration_ms: u64,
51    pub error_code: Option<String>,
52    pub client_id: Option<String>,
53    pub client_name: Option<String>,
54    pub created_at: u64,
55}
56
57#[derive(Debug, Deserialize)]
58struct ToolCallsPage {
59    items: Vec<ToolCallView>,
60    cursor: Option<String>,
61}
62
63#[derive(Debug, Deserialize)]
64pub struct Registered {
65    pub id: String,
66    pub name: String,
67    pub slug: Option<String>,
68}
69
70pub struct ApiClient {
71    base: Url,
72    http: reqwest::Client,
73    auth: Arc<AuthManager>,
74}
75
76impl ApiClient {
77    pub fn new(base: &str, http: reqwest::Client, auth: Arc<AuthManager>) -> Result<Self> {
78        Ok(Self {
79            base: Url::parse(base)?,
80            http,
81            auth,
82        })
83    }
84
85    async fn request<T: DeserializeOwned>(
86        &self,
87        method: reqwest::Method,
88        path: &str,
89        body: Option<serde_json::Value>,
90    ) -> Result<T> {
91        let token = self.auth.access_token().await?;
92        let mut request = self
93            .http
94            .request(method.clone(), self.base.join(path)?)
95            .bearer_auth(token);
96        if let Some(body) = body {
97            request = request.json(&body);
98        }
99        let response = request.send().await?;
100        if !response.status().is_success() {
101            let status = response.status().as_u16();
102            let detail = response.text().await.unwrap_or_default();
103            if let Ok(value) = serde_json::from_str::<serde_json::Value>(&detail)
104                && value.get("error").and_then(|v| v.as_str()) == Some("plan_limit")
105            {
106                let plan = value
107                    .get("plan")
108                    .and_then(|v| v.as_str())
109                    .unwrap_or("current");
110                let max = value.get("max").and_then(|v| v.as_u64()).unwrap_or(0);
111                match value.get("limit").and_then(|v| v.as_str()) {
112                    Some("devices") => bail!(
113                        "Your {plan} plan allows {max} live machines. Revoke one from the dashboard before registering another."
114                    ),
115                    Some("projects") => bail!(
116                        "Your {plan} plan allows {max} projects. Remove one from the dashboard before adding another."
117                    ),
118                    _ => {}
119                }
120            }
121            bail!(
122                "{} {path} failed ({status}): {}",
123                method.as_str(),
124                detail.chars().take(200).collect::<String>()
125            );
126        }
127        Ok(response.json().await?)
128    }
129
130    pub async fn me(&self) -> Result<UserView> {
131        self.request(reqwest::Method::GET, "/api/me", None).await
132    }
133    pub async fn list_devices(&self) -> Result<Vec<DeviceView>> {
134        self.request(reqwest::Method::GET, "/api/devices", None)
135            .await
136    }
137    pub async fn register_device(
138        &self,
139        name: &str,
140        platform: &str,
141        cli_version: &str,
142    ) -> Result<Registered> {
143        self.request(reqwest::Method::POST, "/api/devices", Some(serde_json::json!({ "name": name, "platform": platform, "cliVersion": cli_version }))).await
144    }
145    pub async fn list_projects(&self) -> Result<Vec<ProjectView>> {
146        self.request(reqwest::Method::GET, "/api/projects", None)
147            .await
148    }
149    pub async fn add_project(
150        &self,
151        device_id: &str,
152        name: &str,
153        slug: &str,
154        local_path: &str,
155    ) -> Result<Registered> {
156        self.request(reqwest::Method::POST, "/api/projects", Some(serde_json::json!({ "deviceId": device_id, "name": name, "slug": slug, "localPath": local_path }))).await
157    }
158    pub async fn remove_project(&self, id: &str) -> Result<serde_json::Value> {
159        self.request(
160            reqwest::Method::DELETE,
161            &format!("/api/projects/{id}"),
162            None,
163        )
164        .await
165    }
166    pub async fn list_tool_calls(&self, limit: usize) -> Result<Vec<ToolCallView>> {
167        let mut calls = Vec::new();
168        let mut cursor: Option<String> = None;
169        loop {
170            let path = cursor.as_ref().map_or_else(
171                || "/api/tool-calls".to_owned(),
172                |cursor| {
173                    format!(
174                        "/api/tool-calls?cursor={}",
175                        url::form_urlencoded::byte_serialize(cursor.as_bytes()).collect::<String>()
176                    )
177                },
178            );
179            let page: ToolCallsPage = self.request(reqwest::Method::GET, &path, None).await?;
180            let empty = page.items.is_empty();
181            calls.extend(page.items);
182            cursor = if empty { None } else { page.cursor };
183            if cursor.is_none() || calls.len() >= limit {
184                break;
185            }
186        }
187        calls.truncate(limit);
188        Ok(calls)
189    }
190}