cursor_cli.rs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  1. //! Cursor CLI + cursor-api-proxy management.
  2. //!
  3. //! Detects the local `agent` binary and can start `cursor-api-proxy` so the
  4. //! frontend can talk OpenAI-compatible HTTP. Port is chosen dynamically
  5. //! (prefer 8765, else an ephemeral free port) via `CURSOR_BRIDGE_PORT`.
  6. use std::sync::Arc;
  7. use std::time::Duration;
  8. use serde::Serialize;
  9. use tauri::State;
  10. use tokio::io::{AsyncReadExt, AsyncWriteExt};
  11. use tokio::net::TcpStream;
  12. use tokio::process::{Child, Command};
  13. use tokio::sync::Mutex;
  14. use super::cli_resolver::{child_path_env, find_cli_command};
  15. use super::local_cli_config::{apply_local_cli_environment, resolve_home_dir};
  16. const PREFERRED_PROXY_PORT: u16 = 8765;
  17. const DEFAULT_PROXY_BASE: &str = "http://127.0.0.1:8765";
  18. const PROXY_START_TIMEOUT_MS: u64 = 15_000;
  19. const PROXY_POLL_MS: u64 = 200;
  20. #[derive(Default)]
  21. struct ManagedProxy {
  22. child: Option<Child>,
  23. /// e.g. http://127.0.0.1:8765 — the port this managed child actually bound.
  24. base_url: Option<String>,
  25. }
  26. #[derive(Default)]
  27. pub struct CursorProxyState {
  28. managed: Arc<Mutex<ManagedProxy>>,
  29. }
  30. #[derive(Serialize)]
  31. pub struct DetectResult {
  32. installed: bool,
  33. version: Option<String>,
  34. path: Option<String>,
  35. model: Option<String>,
  36. error: Option<String>,
  37. }
  38. #[derive(Serialize)]
  39. pub struct ProxyStatus {
  40. healthy: bool,
  41. base_url: String,
  42. managed: bool,
  43. error: Option<String>,
  44. }
  45. fn suppress_windows_console(_cmd: &mut Command) {
  46. #[cfg(windows)]
  47. {
  48. #[allow(unused_imports)]
  49. use std::os::windows::process::CommandExt;
  50. const CREATE_NO_WINDOW: u32 = 0x08000000;
  51. _cmd.creation_flags(CREATE_NO_WINDOW);
  52. }
  53. }
  54. async fn find_agent_command() -> Result<std::path::PathBuf, String> {
  55. find_cli_command("agent", &["agent.cmd", "agent.exe"]).await
  56. }
  57. async fn find_proxy_launcher() -> Result<(std::path::PathBuf, Vec<String>), String> {
  58. if let Ok(bin) = find_cli_command(
  59. "cursor-api-proxy",
  60. &["cursor-api-proxy.cmd", "cursor-api-proxy.exe"],
  61. )
  62. .await
  63. {
  64. return Ok((bin, vec![]));
  65. }
  66. let npx = find_cli_command("npx", &["npx.cmd", "npx.exe"])
  67. .await
  68. .map_err(|_| {
  69. "`cursor-api-proxy` and `npx` not found on PATH. Install Node.js 18+ and `npm i -g cursor-api-proxy`, or ensure `npx` works."
  70. .to_string()
  71. })?;
  72. Ok((
  73. npx,
  74. vec![
  75. "--yes".to_string(),
  76. "cursor-api-proxy".to_string(),
  77. ],
  78. ))
  79. }
  80. fn normalize_proxy_base(base_url: Option<String>) -> String {
  81. let raw = base_url
  82. .unwrap_or_else(|| DEFAULT_PROXY_BASE.to_string())
  83. .trim()
  84. .to_string();
  85. let trimmed = raw.trim_end_matches('/').to_string();
  86. if trimmed.to_lowercase().ends_with("/v1") {
  87. trimmed[..trimmed.len() - 3].trim_end_matches('/').to_string()
  88. } else {
  89. trimmed
  90. }
  91. }
  92. fn parse_http_url(base: &str) -> Result<(String, u16, String), String> {
  93. let url = base.trim();
  94. let without_scheme = if let Some(rest) = url.strip_prefix("http://") {
  95. rest
  96. } else if url.starts_with("https://") {
  97. return Err("cursor-api-proxy health check only supports http:// localhost URLs".to_string());
  98. } else {
  99. return Err(format!("Invalid proxy base URL: {base}"));
  100. };
  101. let (host_port, path) = match without_scheme.split_once('/') {
  102. Some((hp, p)) => (hp, format!("/{p}")),
  103. None => (without_scheme, "/".to_string()),
  104. };
  105. let (host, port) = if let Some((h, p)) = host_port.rsplit_once(':') {
  106. let port: u16 = p
  107. .parse()
  108. .map_err(|_| format!("Invalid port in proxy URL: {base}"))?;
  109. (h.to_string(), port)
  110. } else {
  111. (host_port.to_string(), 80)
  112. };
  113. Ok((host, port, path))
  114. }
  115. fn port_available(port: u16) -> bool {
  116. std::net::TcpListener::bind(("127.0.0.1", port)).is_ok()
  117. }
  118. /// Prefer 8765; if taken, bind `:0` once to learn a free ephemeral port.
  119. fn allocate_proxy_port() -> Result<u16, String> {
  120. if port_available(PREFERRED_PROXY_PORT) {
  121. return Ok(PREFERRED_PROXY_PORT);
  122. }
  123. let listener = std::net::TcpListener::bind(("127.0.0.1", 0))
  124. .map_err(|e| format!("Failed to allocate free localhost port: {e}"))?;
  125. let port = listener
  126. .local_addr()
  127. .map_err(|e| format!("Failed to read allocated port: {e}"))?
  128. .port();
  129. drop(listener);
  130. Ok(port)
  131. }
  132. fn base_url_for_port(port: u16) -> String {
  133. format!("http://127.0.0.1:{port}")
  134. }
  135. async fn http_get_status(base: &str, path: &str) -> Result<u16, String> {
  136. let (host, port, _) = parse_http_url(base)?;
  137. let request_path = if path.starts_with('/') {
  138. path.to_string()
  139. } else {
  140. format!("/{path}")
  141. };
  142. let mut stream = tokio::time::timeout(
  143. Duration::from_secs(2),
  144. TcpStream::connect((host.as_str(), port)),
  145. )
  146. .await
  147. .map_err(|_| "health check timed out connecting".to_string())?
  148. .map_err(|e| format!("health check connect failed: {e}"))?;
  149. let req = format!(
  150. "GET {request_path} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n"
  151. );
  152. stream
  153. .write_all(req.as_bytes())
  154. .await
  155. .map_err(|e| format!("health check write failed: {e}"))?;
  156. let mut buf = vec![0u8; 1024];
  157. let n = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf))
  158. .await
  159. .map_err(|_| "health check timed out reading".to_string())?
  160. .map_err(|e| format!("health check read failed: {e}"))?;
  161. let text = String::from_utf8_lossy(&buf[..n]);
  162. let status_line = text.lines().next().unwrap_or("");
  163. let code = status_line
  164. .split_whitespace()
  165. .nth(1)
  166. .and_then(|s| s.parse::<u16>().ok())
  167. .ok_or_else(|| format!("unexpected health response: {status_line}"))?;
  168. Ok(code)
  169. }
  170. async fn ping_health(base: &str) -> bool {
  171. matches!(http_get_status(base, "/health").await, Ok(200))
  172. }
  173. /// Detect whether Cursor `agent` CLI is installed on PATH.
  174. pub async fn do_cursor_cli_detect() -> Result<DetectResult, String> {
  175. let path = match find_agent_command().await {
  176. Ok(p) => p,
  177. Err(error) => {
  178. return Ok(DetectResult {
  179. installed: false,
  180. version: None,
  181. path: None,
  182. model: None,
  183. error: Some(error),
  184. });
  185. }
  186. };
  187. let path_str = path.to_string_lossy().to_string();
  188. let mut cmd = Command::new(&path);
  189. suppress_windows_console(&mut cmd);
  190. apply_local_cli_environment(&mut cmd);
  191. if let Some(path_env) = child_path_env().await {
  192. cmd.env("PATH", path_env);
  193. }
  194. let output = tokio::time::timeout(Duration::from_secs(5), cmd.arg("--version").output()).await;
  195. match output {
  196. Ok(Ok(out)) if out.status.success() => {
  197. let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
  198. let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
  199. let version = if !stdout.is_empty() {
  200. stdout
  201. } else if !stderr.is_empty() {
  202. stderr
  203. } else {
  204. "agent".to_string()
  205. };
  206. Ok(DetectResult {
  207. installed: true,
  208. version: Some(version),
  209. path: Some(path_str),
  210. model: None,
  211. error: None,
  212. })
  213. }
  214. Ok(Ok(out)) => {
  215. let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
  216. Ok(DetectResult {
  217. installed: true,
  218. version: None,
  219. path: Some(path_str),
  220. model: None,
  221. error: Some(if stderr.is_empty() {
  222. format!("`agent --version` exited with {}", out.status)
  223. } else {
  224. stderr
  225. }),
  226. })
  227. }
  228. Ok(Err(e)) => Ok(DetectResult {
  229. installed: false,
  230. version: None,
  231. path: Some(path_str),
  232. model: None,
  233. error: Some(format!("Failed to spawn `agent`: {e}")),
  234. }),
  235. Err(_) => Ok(DetectResult {
  236. installed: true,
  237. version: None,
  238. path: Some(path_str),
  239. model: None,
  240. error: Some("`agent --version` timed out after 5s".to_string()),
  241. }),
  242. }
  243. }
  244. #[tauri::command]
  245. pub async fn cursor_cli_detect() -> Result<DetectResult, String> {
  246. do_cursor_cli_detect().await
  247. }
  248. #[tauri::command]
  249. pub async fn cursor_proxy_status(state: State<'_, CursorProxyState>) -> Result<ProxyStatus, String> {
  250. let (base, managed) = {
  251. let guard = state.managed.lock().await;
  252. (
  253. normalize_proxy_base(guard.base_url.clone()),
  254. guard.child.is_some(),
  255. )
  256. };
  257. let healthy = ping_health(&base).await;
  258. Ok(ProxyStatus {
  259. healthy,
  260. base_url: base,
  261. managed,
  262. error: if healthy {
  263. None
  264. } else {
  265. Some("cursor-api-proxy is not reachable".to_string())
  266. },
  267. })
  268. }
  269. fn read_nonempty_env(keys: &[&str]) -> Option<String> {
  270. keys.iter().find_map(|key| {
  271. std::env::var(key)
  272. .ok()
  273. .map(|v| v.trim().to_string())
  274. .filter(|v| !v.is_empty())
  275. })
  276. }
  277. /// Parse `export NAME=value` / `NAME=value` from a shell rc file. No secret logging.
  278. fn read_export_from_rc(rc_path: &std::path::Path, name: &str) -> Option<String> {
  279. let content = std::fs::read_to_string(rc_path).ok()?;
  280. let prefix = format!("{name}=");
  281. for raw in content.lines() {
  282. let line = raw.trim();
  283. if line.is_empty() || line.starts_with('#') {
  284. continue;
  285. }
  286. let line = line.strip_prefix("export ").unwrap_or(line).trim();
  287. if let Some(rest) = line.strip_prefix(&prefix) {
  288. let value = rest
  289. .trim()
  290. .trim_matches(|c| c == '\'' || c == '"')
  291. .trim()
  292. .to_string();
  293. if !value.is_empty() {
  294. return Some(value);
  295. }
  296. }
  297. }
  298. None
  299. }
  300. fn read_cursor_api_key_from_user_files() -> Option<String> {
  301. let home = resolve_home_dir()?;
  302. for rel in [".zshrc", ".zprofile", ".bashrc", ".bash_profile", ".profile"] {
  303. if let Some(v) = read_export_from_rc(&home.join(rel), "CURSOR_API_KEY") {
  304. return Some(v);
  305. }
  306. }
  307. let auth_path = home.join(".cursor").join("auth.json");
  308. let content = std::fs::read_to_string(auth_path).ok()?;
  309. let json: serde_json::Value = serde_json::from_str(&content).ok()?;
  310. json.get("apiKey")
  311. .and_then(|v| v.as_str())
  312. .map(str::trim)
  313. .filter(|v| !v.is_empty())
  314. .map(ToOwned::to_owned)
  315. }
  316. fn read_agent_credential_store_from_user_files() -> Option<String> {
  317. let home = resolve_home_dir()?;
  318. for rel in [".zshrc", ".zprofile", ".bashrc", ".bash_profile", ".profile"] {
  319. if let Some(v) = read_export_from_rc(&home.join(rel), "AGENT_CLI_CREDENTIAL_STORE") {
  320. return Some(v);
  321. }
  322. }
  323. None
  324. }
  325. /// GUI apps do not load ~/.zshrc. Inject the same Cursor CLI auth the user
  326. /// exports in shell: CURSOR_API_KEY + AGENT_CLI_CREDENTIAL_STORE.
  327. fn apply_cursor_auth_env(cmd: &mut Command) {
  328. let api_key = read_nonempty_env(&["CURSOR_API_KEY"]).or_else(read_cursor_api_key_from_user_files);
  329. if let Some(api_key) = api_key {
  330. cmd.env("CURSOR_API_KEY", api_key);
  331. }
  332. let store = read_nonempty_env(&["AGENT_CLI_CREDENTIAL_STORE"])
  333. .or_else(read_agent_credential_store_from_user_files)
  334. .unwrap_or_else(|| "file".to_string());
  335. cmd.env("AGENT_CLI_CREDENTIAL_STORE", store);
  336. if let Some(token) = read_nonempty_env(&["CURSOR_AUTH_TOKEN"]) {
  337. cmd.env("CURSOR_AUTH_TOKEN", token);
  338. }
  339. for key in [
  340. "HTTP_PROXY",
  341. "HTTPS_PROXY",
  342. "ALL_PROXY",
  343. "http_proxy",
  344. "https_proxy",
  345. "all_proxy",
  346. ] {
  347. cmd.env_remove(key);
  348. }
  349. }
  350. fn shell_quote(value: &str) -> String {
  351. format!("'{}'", value.replace('\'', "'\"'\"'"))
  352. }
  353. async fn spawn_proxy_process(port: u16) -> Result<Child, String> {
  354. let (launcher, extra_args) = find_proxy_launcher().await?;
  355. let path_env = child_path_env().await;
  356. #[cfg(unix)]
  357. {
  358. let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
  359. let mut parts = Vec::with_capacity(1 + extra_args.len());
  360. parts.push(shell_quote(&launcher.to_string_lossy()));
  361. for arg in &extra_args {
  362. parts.push(shell_quote(arg));
  363. }
  364. let cmdline = format!("exec {}", parts.join(" "));
  365. let mut cmd = Command::new(&shell);
  366. suppress_windows_console(&mut cmd);
  367. apply_local_cli_environment(&mut cmd);
  368. if let Some(path_env) = path_env {
  369. cmd.env("PATH", path_env);
  370. }
  371. apply_cursor_auth_env(&mut cmd);
  372. cmd.env("CURSOR_BRIDGE_HOST", "127.0.0.1");
  373. cmd.env("CURSOR_BRIDGE_PORT", port.to_string());
  374. cmd.args(["-l", "-c", &cmdline]);
  375. cmd.stdin(std::process::Stdio::null())
  376. .stdout(std::process::Stdio::null())
  377. .stderr(std::process::Stdio::null())
  378. .kill_on_drop(true);
  379. return cmd
  380. .spawn()
  381. .map_err(|e| format!("Failed to start cursor-api-proxy: {e}"));
  382. }
  383. #[cfg(windows)]
  384. {
  385. let mut cmd = Command::new(&launcher);
  386. suppress_windows_console(&mut cmd);
  387. apply_local_cli_environment(&mut cmd);
  388. if let Some(path_env) = path_env {
  389. cmd.env("PATH", path_env);
  390. }
  391. apply_cursor_auth_env(&mut cmd);
  392. cmd.env("CURSOR_BRIDGE_HOST", "127.0.0.1");
  393. cmd.env("CURSOR_BRIDGE_PORT", port.to_string());
  394. cmd.args(&extra_args);
  395. cmd.stdin(std::process::Stdio::null())
  396. .stdout(std::process::Stdio::null())
  397. .stderr(std::process::Stdio::null())
  398. .kill_on_drop(true);
  399. cmd.spawn()
  400. .map_err(|e| format!("Failed to start cursor-api-proxy: {e}"))
  401. }
  402. }
  403. async fn stop_managed_child(state: &CursorProxyState) {
  404. let mut guard = state.managed.lock().await;
  405. if let Some(mut child) = guard.child.take() {
  406. let _ = child.start_kill();
  407. let _ = tokio::time::timeout(Duration::from_secs(3), child.wait()).await;
  408. }
  409. guard.base_url = None;
  410. }
  411. async fn wait_until_healthy(base: &str) -> bool {
  412. let deadline = tokio::time::Instant::now() + Duration::from_millis(PROXY_START_TIMEOUT_MS);
  413. while tokio::time::Instant::now() < deadline {
  414. if ping_health(base).await {
  415. return true;
  416. }
  417. tokio::time::sleep(Duration::from_millis(PROXY_POLL_MS)).await;
  418. }
  419. false
  420. }
  421. /// Ensure cursor-api-proxy is healthy. Starts (or force-restarts) on a free port.
  422. #[tauri::command]
  423. pub async fn cursor_proxy_ensure(
  424. state: State<'_, CursorProxyState>,
  425. force_restart: Option<bool>,
  426. ) -> Result<ProxyStatus, String> {
  427. let force = force_restart.unwrap_or(false);
  428. {
  429. let mut guard = state.managed.lock().await;
  430. if let Some(child) = guard.child.as_mut() {
  431. match child.try_wait() {
  432. Ok(None) => {
  433. if !force {
  434. if let Some(base) = guard.base_url.clone() {
  435. drop(guard);
  436. if ping_health(&base).await {
  437. return Ok(ProxyStatus {
  438. healthy: true,
  439. base_url: base,
  440. managed: true,
  441. error: None,
  442. });
  443. }
  444. }
  445. }
  446. }
  447. _ => {
  448. guard.child = None;
  449. guard.base_url = None;
  450. }
  451. }
  452. }
  453. }
  454. stop_managed_child(&state).await;
  455. let port = allocate_proxy_port()?;
  456. let base = base_url_for_port(port);
  457. let child = spawn_proxy_process(port).await?;
  458. {
  459. let mut guard = state.managed.lock().await;
  460. guard.child = Some(child);
  461. guard.base_url = Some(base.clone());
  462. }
  463. if wait_until_healthy(&base).await {
  464. return Ok(ProxyStatus {
  465. healthy: true,
  466. base_url: base,
  467. managed: true,
  468. error: None,
  469. });
  470. }
  471. stop_managed_child(&state).await;
  472. Err(format!(
  473. "Started cursor-api-proxy on {base} but /health did not become ready within {PROXY_START_TIMEOUT_MS}ms. Ensure Node.js 18+, `agent` CLI, and that CURSOR_API_KEY / AGENT_CLI_CREDENTIAL_STORE are set in ~/.zshrc (or auth.json)."
  474. ))
  475. }
  476. /// Stop the proxy process if this app started it.
  477. #[tauri::command]
  478. pub async fn cursor_proxy_stop(state: State<'_, CursorProxyState>) -> Result<(), String> {
  479. stop_managed_child(&state).await;
  480. Ok(())
  481. }
  482. #[cfg(test)]
  483. mod tests {
  484. use super::*;
  485. #[test]
  486. fn normalize_strips_v1_suffix() {
  487. assert_eq!(
  488. normalize_proxy_base(Some("http://127.0.0.1:8765/v1".into())),
  489. "http://127.0.0.1:8765"
  490. );
  491. assert_eq!(
  492. normalize_proxy_base(Some("http://127.0.0.1:8765/".into())),
  493. "http://127.0.0.1:8765"
  494. );
  495. assert_eq!(normalize_proxy_base(None), DEFAULT_PROXY_BASE);
  496. }
  497. #[test]
  498. fn parse_localhost_url() {
  499. let (host, port, path) = parse_http_url("http://127.0.0.1:8765").unwrap();
  500. assert_eq!(host, "127.0.0.1");
  501. assert_eq!(port, 8765);
  502. assert_eq!(path, "/");
  503. }
  504. #[test]
  505. fn allocate_prefers_8765_when_free() {
  506. if port_available(PREFERRED_PROXY_PORT) {
  507. assert_eq!(allocate_proxy_port().unwrap(), PREFERRED_PROXY_PORT);
  508. }
  509. }
  510. #[test]
  511. fn allocate_returns_nonzero_when_preferred_taken() {
  512. let _hold = std::net::TcpListener::bind(("127.0.0.1", PREFERRED_PROXY_PORT));
  513. if _hold.is_err() {
  514. let port = allocate_proxy_port().unwrap();
  515. assert!(port > 0);
  516. return;
  517. }
  518. let port = allocate_proxy_port().unwrap();
  519. assert_ne!(port, PREFERRED_PROXY_PORT);
  520. assert!(port > 0);
  521. }
  522. #[test]
  523. fn reads_export_lines_from_rc() {
  524. let dir = std::env::temp_dir().join(format!("qmai-cursor-rc-{}", std::process::id()));
  525. let _ = std::fs::create_dir_all(&dir);
  526. let rc = dir.join(".zshrc");
  527. std::fs::write(
  528. &rc,
  529. "# comment\nexport CURSOR_API_KEY=crsr_test_key\nexport AGENT_CLI_CREDENTIAL_STORE=file\n",
  530. )
  531. .unwrap();
  532. assert_eq!(
  533. read_export_from_rc(&rc, "CURSOR_API_KEY").as_deref(),
  534. Some("crsr_test_key")
  535. );
  536. assert_eq!(
  537. read_export_from_rc(&rc, "AGENT_CLI_CREDENTIAL_STORE").as_deref(),
  538. Some("file")
  539. );
  540. let _ = std::fs::remove_dir_all(&dir);
  541. }
  542. }