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}