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, PartialEq, Eq)]
33#[serde(rename_all = "camelCase")]
34pub struct WorktreeView {
35    pub id: String,
36    pub project_id: String,
37    pub slug: String,
38    pub name: String,
39    pub branch: Option<String>,
40    pub local_path: String,
41    pub managed: bool,
42    pub created_at: u64,
43    pub updated_at: u64,
44}
45
46#[derive(Debug, Clone, Deserialize, Serialize)]
47#[serde(rename_all = "camelCase")]
48pub struct UserView {
49    pub id: String,
50    pub email: String,
51    pub name: Option<String>,
52    pub plan: Option<String>,
53    pub limits: Option<serde_json::Value>,
54    pub usage: Option<serde_json::Value>,
55}
56
57#[derive(Debug, Clone, Deserialize, Serialize)]
58#[serde(rename_all = "camelCase")]
59pub struct ToolCallView {
60    pub id: String,
61    pub project_id: String,
62    pub worktree_id: Option<String>,
63    pub worktree_slug: Option<String>,
64    pub tool: String,
65    pub status: String,
66    pub duration_ms: u64,
67    pub error_code: Option<String>,
68    pub client_id: Option<String>,
69    pub client_name: Option<String>,
70    pub created_at: u64,
71}
72
73#[derive(Debug, Deserialize)]
74struct ToolCallsPage {
75    items: Vec<ToolCallView>,
76    cursor: Option<String>,
77}
78
79#[derive(Debug, Deserialize)]
80pub struct Registered {
81    pub id: String,
82    pub name: String,
83    pub slug: Option<String>,
84}
85
86#[derive(Clone)]
87pub struct ApiClient {
88    base: Url,
89    http: reqwest::Client,
90    auth: Arc<AuthManager>,
91}
92
93impl ApiClient {
94    pub fn new(base: &str, http: reqwest::Client, auth: Arc<AuthManager>) -> Result<Self> {
95        Ok(Self {
96            base: Url::parse(base)?,
97            http,
98            auth,
99        })
100    }
101
102    async fn request<T: DeserializeOwned>(
103        &self,
104        method: reqwest::Method,
105        path: &str,
106        body: Option<serde_json::Value>,
107    ) -> Result<T> {
108        let token = self.auth.access_token().await?;
109        let mut request = self
110            .http
111            .request(method.clone(), self.base.join(path)?)
112            .bearer_auth(token);
113        if let Some(body) = body {
114            request = request.json(&body);
115        }
116        let response = request.send().await?;
117        if !response.status().is_success() {
118            let status = response.status().as_u16();
119            let detail = response.text().await.unwrap_or_default();
120            if let Ok(value) = serde_json::from_str::<serde_json::Value>(&detail)
121                && value.get("error").and_then(|v| v.as_str()) == Some("plan_limit")
122            {
123                let plan = value
124                    .get("plan")
125                    .and_then(|v| v.as_str())
126                    .unwrap_or("current");
127                let max = value.get("max").and_then(|v| v.as_u64()).unwrap_or(0);
128                match value.get("limit").and_then(|v| v.as_str()) {
129                    Some("devices") => bail!(
130                        "Your {plan} plan allows {max} live machines. Revoke one from the dashboard before registering another."
131                    ),
132                    Some("projects") => bail!(
133                        "Your {plan} plan allows {max} projects. Remove one from the dashboard before adding another."
134                    ),
135                    _ => {}
136                }
137            }
138            bail!(
139                "{} {path} failed ({status}): {}",
140                method.as_str(),
141                detail.chars().take(200).collect::<String>()
142            );
143        }
144        Ok(response.json().await?)
145    }
146
147    pub async fn me(&self) -> Result<UserView> {
148        self.request(reqwest::Method::GET, "/api/me", None).await
149    }
150    pub async fn list_devices(&self) -> Result<Vec<DeviceView>> {
151        self.request(reqwest::Method::GET, "/api/devices", None)
152            .await
153    }
154    pub async fn register_device(
155        &self,
156        name: &str,
157        platform: &str,
158        cli_version: &str,
159    ) -> Result<Registered> {
160        self.request(reqwest::Method::POST, "/api/devices", Some(serde_json::json!({ "name": name, "platform": platform, "cliVersion": cli_version }))).await
161    }
162    pub async fn list_projects(&self) -> Result<Vec<ProjectView>> {
163        self.request(reqwest::Method::GET, "/api/projects", None)
164            .await
165    }
166    pub async fn add_project(
167        &self,
168        device_id: &str,
169        name: &str,
170        slug: &str,
171        local_path: &str,
172    ) -> Result<Registered> {
173        self.request(reqwest::Method::POST, "/api/projects", Some(serde_json::json!({ "deviceId": device_id, "name": name, "slug": slug, "localPath": local_path }))).await
174    }
175    pub async fn remove_project(&self, id: &str) -> Result<serde_json::Value> {
176        self.request(
177            reqwest::Method::DELETE,
178            &format!("/api/projects/{id}"),
179            None,
180        )
181        .await
182    }
183    pub async fn list_worktrees(&self, project_id: &str) -> Result<Vec<WorktreeView>> {
184        self.request(
185            reqwest::Method::GET,
186            &format!("/api/projects/{project_id}/worktrees"),
187            None,
188        )
189        .await
190    }
191    pub async fn put_worktree(
192        &self,
193        project_id: &str,
194        worktree: &crate::config::WorktreeEntry,
195    ) -> Result<WorktreeView> {
196        self.request(
197            reqwest::Method::PUT,
198            &format!("/api/projects/{project_id}/worktrees/{}", worktree.id),
199            Some(serde_json::json!({
200                "slug": worktree.slug,
201                "name": worktree.name,
202                "branch": worktree.branch,
203                "localPath": worktree.root,
204                "managed": worktree.managed,
205            })),
206        )
207        .await
208    }
209    pub async fn remove_worktree(
210        &self,
211        project_id: &str,
212        worktree_id: &str,
213    ) -> Result<serde_json::Value> {
214        self.request(
215            reqwest::Method::DELETE,
216            &format!("/api/projects/{project_id}/worktrees/{worktree_id}"),
217            None,
218        )
219        .await
220    }
221    pub async fn list_tool_calls(&self, limit: usize) -> Result<Vec<ToolCallView>> {
222        let mut calls = Vec::new();
223        let mut cursor: Option<String> = None;
224        loop {
225            let path = cursor.as_ref().map_or_else(
226                || "/api/tool-calls".to_owned(),
227                |cursor| {
228                    format!(
229                        "/api/tool-calls?cursor={}",
230                        url::form_urlencoded::byte_serialize(cursor.as_bytes()).collect::<String>()
231                    )
232                },
233            );
234            let page: ToolCallsPage = self.request(reqwest::Method::GET, &path, None).await?;
235            let empty = page.items.is_empty();
236            calls.extend(page.items);
237            cursor = if empty { None } else { page.cursor };
238            if cursor.is_none() || calls.len() >= limit {
239                break;
240            }
241        }
242        calls.truncate(limit);
243        Ok(calls)
244    }
245}