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}