cursor_cli.rs 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269
  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::path::{Path, PathBuf};
  7. use std::sync::atomic::{AtomicBool, Ordering};
  8. use std::sync::Arc;
  9. use std::time::Duration;
  10. use serde::Serialize;
  11. use tauri::State;
  12. use tokio::io::{AsyncReadExt, AsyncWriteExt};
  13. use tokio::net::TcpStream;
  14. use tokio::process::{Child, Command};
  15. use tokio::sync::Mutex;
  16. use super::cli_resolver::{child_path_env, find_cli_command};
  17. use super::local_cli_config::{apply_local_cli_environment, resolve_home_dir};
  18. const PREFERRED_PROXY_PORT: u16 = 8765;
  19. const DEFAULT_PROXY_BASE: &str = "http://127.0.0.1:8765";
  20. const PROXY_START_TIMEOUT_MS: u64 = 90_000;
  21. const PROXY_POLL_MS: u64 = 200;
  22. /// Align parked ACP tool turns with the frontend LLM backstop (30 minutes).
  23. const PROXY_TIMEOUT_MS: u64 = 30 * 60 * 1000;
  24. const AGENT_ABOUT_TIMEOUT: Duration = Duration::from_secs(20);
  25. const AGENT_UPDATE_TIMEOUT: Duration = Duration::from_secs(10 * 60);
  26. const AGENT_TRUST_TIMEOUT: Duration = Duration::from_secs(45);
  27. const QMAI_WORKSPACE_TRUST_MARKER: &str = ".qmai-workspace-trusted";
  28. const QMAI_ACP_MODEL_FILE: &str = "qmai-acp-model";
  29. const QMAI_CURSOR_AGENT_WRAPPER: &str = include_str!("../../scripts/qmai-cursor-agent.cjs");
  30. static AGENT_UPDATE_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
  31. struct AgentUpdateGuard;
  32. impl Drop for AgentUpdateGuard {
  33. fn drop(&mut self) {
  34. AGENT_UPDATE_IN_FLIGHT.store(false, Ordering::SeqCst);
  35. }
  36. }
  37. #[derive(Default)]
  38. struct ManagedProxy {
  39. child: Option<Child>,
  40. /// e.g. http://127.0.0.1:8765 — the port this managed child actually bound.
  41. base_url: Option<String>,
  42. launch_fingerprint: Option<String>,
  43. }
  44. #[derive(Default)]
  45. pub struct CursorProxyState {
  46. managed: Arc<Mutex<ManagedProxy>>,
  47. }
  48. #[derive(Serialize)]
  49. pub struct DetectResult {
  50. installed: bool,
  51. version: Option<String>,
  52. path: Option<String>,
  53. model: Option<String>,
  54. error: Option<String>,
  55. }
  56. #[derive(Serialize)]
  57. pub struct AgentAboutResult {
  58. installed: bool,
  59. version: Option<String>,
  60. latest_status: Option<String>,
  61. latest_version: Option<String>,
  62. path: Option<String>,
  63. error: Option<String>,
  64. }
  65. #[derive(Serialize)]
  66. pub struct AgentUpdateResult {
  67. ok: bool,
  68. version: Option<String>,
  69. output: String,
  70. error: Option<String>,
  71. }
  72. #[derive(Serialize)]
  73. pub struct ProxyStatus {
  74. healthy: bool,
  75. base_url: String,
  76. managed: bool,
  77. error: Option<String>,
  78. }
  79. fn suppress_windows_console(_cmd: &mut Command) {
  80. #[cfg(windows)]
  81. {
  82. #[allow(unused_imports)]
  83. use std::os::windows::process::CommandExt;
  84. const CREATE_NO_WINDOW: u32 = 0x08000000;
  85. _cmd.creation_flags(CREATE_NO_WINDOW);
  86. }
  87. }
  88. async fn find_agent_command() -> Result<std::path::PathBuf, String> {
  89. find_cli_command("agent", &["agent.cmd", "agent.exe"]).await
  90. }
  91. fn npx_proxy_args() -> Vec<String> {
  92. vec!["--yes".to_string(), "cursor-api-proxy@latest".to_string()]
  93. }
  94. fn agent_trust_args(workspace: &Path) -> Vec<String> {
  95. vec![
  96. "--trust".to_string(),
  97. "--workspace".to_string(),
  98. workspace.to_string_lossy().into_owned(),
  99. "--mode".to_string(),
  100. "ask".to_string(),
  101. "--output-format".to_string(),
  102. "text".to_string(),
  103. "-p".to_string(),
  104. "ok".to_string(),
  105. ]
  106. }
  107. fn qmai_workspace_trust_marker(config_dir: &Path) -> PathBuf {
  108. config_dir.join(QMAI_WORKSPACE_TRUST_MARKER)
  109. }
  110. async fn ensure_qmai_workspace_trusted(workspace: &Path, config_dir: &Path) {
  111. let marker = qmai_workspace_trust_marker(config_dir);
  112. if marker.is_file() {
  113. return;
  114. }
  115. let args = agent_trust_args(workspace);
  116. let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
  117. match run_agent_command(&arg_refs, AGENT_TRUST_TIMEOUT, Some(config_dir)).await {
  118. Ok((_path, true, _output)) => {
  119. if let Err(error) = std::fs::write(&marker, "") {
  120. eprintln!(
  121. "[cursor-cli] trusted workspace but failed to write {}: {error}",
  122. marker.display()
  123. );
  124. }
  125. }
  126. Ok((_path, false, output)) => {
  127. eprintln!(
  128. "[cursor-cli] `agent --trust -p` failed for {}: {output}",
  129. workspace.display()
  130. );
  131. }
  132. Err(error) => {
  133. eprintln!("[cursor-cli] workspace trust skipped: {error}");
  134. }
  135. }
  136. }
  137. fn npx_missing_error() -> String {
  138. "`npx` not found on PATH. Install Node.js 18+ or set CURSOR_API_PROXY_BIN to a cursor-api-proxy binary."
  139. .to_string()
  140. }
  141. fn resolve_explicit_proxy_bin(path: &Path) -> Result<(PathBuf, Vec<String>), String> {
  142. if path.is_file() {
  143. Ok((path.to_path_buf(), vec![]))
  144. } else {
  145. Err(format!(
  146. "CURSOR_API_PROXY_BIN is set but is not a file: {}",
  147. path.display()
  148. ))
  149. }
  150. }
  151. async fn find_proxy_launcher() -> Result<(PathBuf, Vec<String>), String> {
  152. if let Some(bin) = read_nonempty_env(&["CURSOR_API_PROXY_BIN"]) {
  153. return resolve_explicit_proxy_bin(Path::new(&bin));
  154. }
  155. find_cli_command("npx", &["npx.cmd", "npx.exe"])
  156. .await
  157. .map(|bin| (bin, npx_proxy_args()))
  158. .map_err(|_| npx_missing_error())
  159. }
  160. fn qmai_proxy_home_dirs() -> Result<(PathBuf, PathBuf), String> {
  161. let home = resolve_home_dir().ok_or_else(|| "Cannot resolve home directory for cursor-api-proxy".to_string())?;
  162. let root = home.join(".cursor-api-proxy");
  163. let config_dir = root.join("qmai-agent");
  164. let workspace = root.join("qmai-workspace");
  165. std::fs::create_dir_all(&config_dir)
  166. .map_err(|e| format!("Failed to create {}: {e}", config_dir.display()))?;
  167. std::fs::create_dir_all(&workspace)
  168. .map_err(|e| format!("Failed to create {}: {e}", workspace.display()))?;
  169. Ok((config_dir, workspace))
  170. }
  171. fn ensure_qmai_cli_config(config_dir: &Path) -> Result<PathBuf, String> {
  172. let path = config_dir.join("cli-config.json");
  173. let mut value = if path.exists() {
  174. std::fs::read_to_string(&path)
  175. .ok()
  176. .and_then(|raw| serde_json::from_str(&raw).ok())
  177. .unwrap_or_else(|| serde_json::json!({}))
  178. } else {
  179. serde_json::json!({
  180. "version": 1,
  181. "editor": { "vimMode": false },
  182. "permissions": { "allow": [], "deny": [] }
  183. })
  184. };
  185. if !value.is_object() {
  186. value = serde_json::json!({});
  187. }
  188. value["disableAutoUpdate"] = serde_json::json!(true);
  189. std::fs::write(
  190. &path,
  191. serde_json::to_string_pretty(&value)
  192. .map_err(|e| format!("Failed to serialize {}: {e}", path.display()))?,
  193. )
  194. .map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
  195. Ok(path)
  196. }
  197. fn apply_qmai_acp_model(
  198. config_dir: &Path,
  199. model: &str,
  200. fast: Option<bool>,
  201. effort: Option<&str>,
  202. cli_model: Option<&str>,
  203. ) -> Result<(), String> {
  204. let model = model.trim();
  205. if model.is_empty() {
  206. return Err("ACP model id is empty".to_string());
  207. }
  208. let path = ensure_qmai_cli_config(config_dir)?;
  209. let mut value = std::fs::read_to_string(&path)
  210. .ok()
  211. .and_then(|raw| serde_json::from_str(&raw).ok())
  212. .unwrap_or_else(|| serde_json::json!({}));
  213. if !value.is_object() {
  214. value = serde_json::json!({});
  215. }
  216. let mut parameters = Vec::new();
  217. if let Some(effort) = effort.map(str::trim).filter(|value| !value.is_empty()) {
  218. parameters.push(serde_json::json!({ "id": "effort", "value": effort }));
  219. }
  220. if let Some(fast) = fast {
  221. parameters.push(serde_json::json!({
  222. "id": "fast",
  223. "value": if fast { "true" } else { "false" }
  224. }));
  225. }
  226. value["selectedModel"] = serde_json::json!({
  227. "modelId": model,
  228. "parameters": parameters
  229. });
  230. if let Some(obj) = value.get_mut("model").and_then(|item| item.as_object_mut()) {
  231. obj.insert("modelId".to_string(), serde_json::json!(model));
  232. obj.insert("displayModelId".to_string(), serde_json::json!(model));
  233. } else {
  234. value["model"] = serde_json::json!({
  235. "modelId": model,
  236. "displayModelId": model
  237. });
  238. }
  239. value["hasChangedDefaultModel"] = serde_json::json!(true);
  240. std::fs::write(
  241. &path,
  242. serde_json::to_string_pretty(&value)
  243. .map_err(|e| format!("Failed to serialize {}: {e}", path.display()))?,
  244. )
  245. .map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
  246. let pin = cli_model
  247. .map(str::trim)
  248. .filter(|value| !value.is_empty() && *value != "default" && *value != "auto")
  249. .unwrap_or(model);
  250. std::fs::write(config_dir.join(QMAI_ACP_MODEL_FILE), pin)
  251. .map_err(|e| {
  252. format!(
  253. "Failed to write {}: {e}",
  254. config_dir.join(QMAI_ACP_MODEL_FILE).display()
  255. )
  256. })?;
  257. Ok(())
  258. }
  259. #[allow(dead_code)]
  260. fn qmai_acp_model_value(model: &str, fast: Option<bool>, effort: Option<&str>) -> String {
  261. let mut params = Vec::new();
  262. if let Some(effort) = effort.map(str::trim).filter(|value| !value.is_empty()) {
  263. params.push(format!("effort={effort}"));
  264. }
  265. if let Some(fast) = fast {
  266. params.push(format!("fast={}", if fast { "true" } else { "false" }));
  267. }
  268. if params.is_empty() {
  269. model.to_string()
  270. } else {
  271. format!("{model}[{}]", params.join(","))
  272. }
  273. }
  274. #[tauri::command]
  275. pub async fn cursor_cli_apply_acp_model(
  276. model: String,
  277. fast: Option<bool>,
  278. effort: Option<String>,
  279. cli_model: Option<String>,
  280. ) -> Result<(), String> {
  281. let (config_dir, _) = qmai_proxy_home_dirs()?;
  282. apply_qmai_acp_model(
  283. &config_dir,
  284. &model,
  285. fast,
  286. effort.as_deref(),
  287. cli_model.as_deref(),
  288. )
  289. }
  290. fn ensure_qmai_agent_wrapper(
  291. config_dir: &Path,
  292. agent_bin: Option<&Path>,
  293. ) -> Result<PathBuf, String> {
  294. let baked = agent_bin
  295. .map(|path| serde_json::to_string(&path.to_string_lossy().as_ref()).unwrap_or_else(|_| "\"\"".into()))
  296. .unwrap_or_else(|| "\"\"".to_string());
  297. let source = QMAI_CURSOR_AGENT_WRAPPER.replace("__QMAI_BAKED_AGENT__", &baked);
  298. let script = config_dir.join("qmai-cursor-agent.cjs");
  299. std::fs::write(&script, source)
  300. .map_err(|e| format!("Failed to write {}: {e}", script.display()))?;
  301. #[cfg(unix)]
  302. {
  303. use std::os::unix::fs::PermissionsExt;
  304. let _ = std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755));
  305. Ok(script)
  306. }
  307. #[cfg(windows)]
  308. {
  309. let cmd_path = config_dir.join("qmai-cursor-agent.cmd");
  310. std::fs::write(
  311. &cmd_path,
  312. "@echo off\r\nnode \"%~dp0qmai-cursor-agent.cjs\" %*\r\n",
  313. )
  314. .map_err(|e| format!("Failed to write {}: {e}", cmd_path.display()))?;
  315. Ok(cmd_path)
  316. }
  317. }
  318. fn proxy_launch_fingerprint(
  319. launcher: &Path,
  320. extra_args: &[String],
  321. workspace: &Path,
  322. config_dir: &Path,
  323. ) -> String {
  324. format!(
  325. "launcher={}|args={}|chat_only=false|acp=true|mode=ask|ws={}|cfg={}|timeout={}|agent_wrap=4|strict=off",
  326. launcher.display(),
  327. extra_args.join("\0"),
  328. workspace.display(),
  329. config_dir.display(),
  330. PROXY_TIMEOUT_MS
  331. )
  332. }
  333. fn extract_json_object(raw: &str) -> Option<&str> {
  334. let start = raw.find('{')?;
  335. let end = raw.rfind('}')?;
  336. if end < start {
  337. return None;
  338. }
  339. Some(&raw[start..=end])
  340. }
  341. fn parse_agent_about_json(raw: &str) -> Option<(String, Option<String>, Option<String>)> {
  342. let json: serde_json::Value = serde_json::from_str(extract_json_object(raw)?.trim()).ok()?;
  343. let version = json
  344. .get("cliVersion")
  345. .and_then(|v| v.as_str())
  346. .map(str::trim)
  347. .filter(|v| !v.is_empty())?
  348. .to_string();
  349. let latest_status = json
  350. .get("latestStatus")
  351. .and_then(|v| v.as_str())
  352. .map(str::trim)
  353. .filter(|v| !v.is_empty())
  354. .map(ToOwned::to_owned);
  355. let latest_version = json
  356. .get("latestVersion")
  357. .and_then(|v| v.as_str())
  358. .map(str::trim)
  359. .filter(|v| !v.is_empty())
  360. .map(ToOwned::to_owned);
  361. Some((version, latest_status, latest_version))
  362. }
  363. async fn run_agent_command(
  364. args: &[&str],
  365. timeout: Duration,
  366. config_dir: Option<&Path>,
  367. ) -> Result<(PathBuf, bool, String), String> {
  368. let path = find_agent_command().await?;
  369. let mut cmd = Command::new(&path);
  370. suppress_windows_console(&mut cmd);
  371. apply_local_cli_environment(&mut cmd);
  372. if let Some(path_env) = child_path_env().await {
  373. cmd.env("PATH", path_env);
  374. }
  375. apply_cursor_auth_env(&mut cmd);
  376. if let Some(dir) = config_dir {
  377. cmd.env("CURSOR_CONFIG_DIR", dir);
  378. } else {
  379. cmd.env_remove("CURSOR_CONFIG_DIR");
  380. }
  381. cmd.env_remove("CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE");
  382. cmd.args(args);
  383. let output = tokio::time::timeout(timeout, cmd.output())
  384. .await
  385. .map_err(|_| format!("`agent {}` timed out", args.join(" ")))?
  386. .map_err(|e| format!("Failed to spawn `agent {}`: {e}", args.join(" ")))?;
  387. let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
  388. let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
  389. let combined = if stderr.is_empty() {
  390. stdout
  391. } else if stdout.is_empty() {
  392. stderr
  393. } else {
  394. format!("{stdout}\n{stderr}")
  395. };
  396. Ok((path, output.status.success(), combined))
  397. }
  398. fn apply_qmai_proxy_env(
  399. cmd: &mut Command,
  400. port: u16,
  401. workspace: &Path,
  402. config_dir: &Path,
  403. agent_bin: Option<&Path>,
  404. wrapper: Option<&Path>,
  405. ) {
  406. cmd.env("CURSOR_BRIDGE_HOST", "127.0.0.1");
  407. cmd.env("CURSOR_BRIDGE_PORT", port.to_string());
  408. cmd.env("CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE", "false");
  409. cmd.env("CURSOR_BRIDGE_USE_ACP", "true");
  410. cmd.env("CURSOR_BRIDGE_MODE", "ask");
  411. // CLI catalog ids (cursor-grok-4.6-medium-fast) are valid for `agent --model`
  412. // but do not appear in ACP availableModels. Proxy defaults strictModel=true
  413. // and treats that mismatch as fatal (empty stderr, exit 1).
  414. cmd.env("CURSOR_BRIDGE_STRICT_MODEL", "false");
  415. cmd.env("CURSOR_BRIDGE_WORKSPACE", workspace);
  416. cmd.env("CURSOR_CONFIG_DIR", config_dir);
  417. cmd.env("CURSOR_BRIDGE_TIMEOUT_MS", PROXY_TIMEOUT_MS.to_string());
  418. if let (Some(agent_bin), Some(wrapper)) = (agent_bin, wrapper) {
  419. cmd.env("CURSOR_AGENT_BIN", wrapper);
  420. cmd.env("QMAI_CURSOR_AGENT_REAL", agent_bin);
  421. }
  422. }
  423. fn normalize_proxy_base(base_url: Option<String>) -> String {
  424. let raw = base_url
  425. .unwrap_or_else(|| DEFAULT_PROXY_BASE.to_string())
  426. .trim()
  427. .to_string();
  428. let trimmed = raw.trim_end_matches('/').to_string();
  429. if trimmed.to_lowercase().ends_with("/v1") {
  430. trimmed[..trimmed.len() - 3].trim_end_matches('/').to_string()
  431. } else {
  432. trimmed
  433. }
  434. }
  435. fn parse_http_url(base: &str) -> Result<(String, u16, String), String> {
  436. let url = base.trim();
  437. let without_scheme = if let Some(rest) = url.strip_prefix("http://") {
  438. rest
  439. } else if url.starts_with("https://") {
  440. return Err("cursor-api-proxy health check only supports http:// localhost URLs".to_string());
  441. } else {
  442. return Err(format!("Invalid proxy base URL: {base}"));
  443. };
  444. let (host_port, path) = match without_scheme.split_once('/') {
  445. Some((hp, p)) => (hp, format!("/{p}")),
  446. None => (without_scheme, "/".to_string()),
  447. };
  448. let (host, port) = if let Some((h, p)) = host_port.rsplit_once(':') {
  449. let port: u16 = p
  450. .parse()
  451. .map_err(|_| format!("Invalid port in proxy URL: {base}"))?;
  452. (h.to_string(), port)
  453. } else {
  454. (host_port.to_string(), 80)
  455. };
  456. Ok((host, port, path))
  457. }
  458. fn port_available(port: u16) -> bool {
  459. std::net::TcpListener::bind(("127.0.0.1", port)).is_ok()
  460. }
  461. /// Prefer 8765; if taken, bind `:0` once to learn a free ephemeral port.
  462. fn allocate_proxy_port() -> Result<u16, String> {
  463. if port_available(PREFERRED_PROXY_PORT) {
  464. return Ok(PREFERRED_PROXY_PORT);
  465. }
  466. let listener = std::net::TcpListener::bind(("127.0.0.1", 0))
  467. .map_err(|e| format!("Failed to allocate free localhost port: {e}"))?;
  468. let port = listener
  469. .local_addr()
  470. .map_err(|e| format!("Failed to read allocated port: {e}"))?
  471. .port();
  472. drop(listener);
  473. Ok(port)
  474. }
  475. fn base_url_for_port(port: u16) -> String {
  476. format!("http://127.0.0.1:{port}")
  477. }
  478. async fn http_get(base: &str, path: &str) -> Result<(u16, String), String> {
  479. let (host, port, _) = parse_http_url(base)?;
  480. let request_path = if path.starts_with('/') {
  481. path.to_string()
  482. } else {
  483. format!("/{path}")
  484. };
  485. let mut stream = tokio::time::timeout(
  486. Duration::from_secs(2),
  487. TcpStream::connect((host.as_str(), port)),
  488. )
  489. .await
  490. .map_err(|_| "health check timed out connecting".to_string())?
  491. .map_err(|e| format!("health check connect failed: {e}"))?;
  492. let req = format!(
  493. "GET {request_path} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n"
  494. );
  495. stream
  496. .write_all(req.as_bytes())
  497. .await
  498. .map_err(|e| format!("health check write failed: {e}"))?;
  499. let mut buf = Vec::new();
  500. let mut chunk = vec![0u8; 4096];
  501. loop {
  502. let n = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut chunk))
  503. .await
  504. .map_err(|_| "health check timed out reading".to_string())?
  505. .map_err(|e| format!("health check read failed: {e}"))?;
  506. if n == 0 {
  507. break;
  508. }
  509. buf.extend_from_slice(&chunk[..n]);
  510. if buf.len() > 64 * 1024 {
  511. break;
  512. }
  513. }
  514. let text = String::from_utf8_lossy(&buf);
  515. let status_line = text.lines().next().unwrap_or("");
  516. let code = status_line
  517. .split_whitespace()
  518. .nth(1)
  519. .and_then(|s| s.parse::<u16>().ok())
  520. .ok_or_else(|| format!("unexpected health response: {status_line}"))?;
  521. let header_end = text
  522. .find("\r\n\r\n")
  523. .map(|i| i + 4)
  524. .or_else(|| text.find("\n\n").map(|i| i + 2))
  525. .unwrap_or(0);
  526. Ok((code, text[header_end..].to_string()))
  527. }
  528. async fn http_get_status(base: &str, path: &str) -> Result<u16, String> {
  529. http_get(base, path).await.map(|(status, _)| status)
  530. }
  531. async fn ping_health(base: &str) -> bool {
  532. matches!(http_get_status(base, "/health").await, Ok(200))
  533. }
  534. /// Detect whether Cursor `agent` CLI is installed on PATH.
  535. pub async fn do_cursor_cli_detect() -> Result<DetectResult, String> {
  536. let path = match find_agent_command().await {
  537. Ok(p) => p,
  538. Err(error) => {
  539. return Ok(DetectResult {
  540. installed: false,
  541. version: None,
  542. path: None,
  543. model: None,
  544. error: Some(error),
  545. });
  546. }
  547. };
  548. let path_str = path.to_string_lossy().to_string();
  549. let mut cmd = Command::new(&path);
  550. suppress_windows_console(&mut cmd);
  551. apply_local_cli_environment(&mut cmd);
  552. if let Some(path_env) = child_path_env().await {
  553. cmd.env("PATH", path_env);
  554. }
  555. let output = tokio::time::timeout(Duration::from_secs(5), cmd.arg("--version").output()).await;
  556. match output {
  557. Ok(Ok(out)) if out.status.success() => {
  558. let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
  559. let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
  560. let version = if !stdout.is_empty() {
  561. stdout
  562. } else if !stderr.is_empty() {
  563. stderr
  564. } else {
  565. "agent".to_string()
  566. };
  567. Ok(DetectResult {
  568. installed: true,
  569. version: Some(version),
  570. path: Some(path_str),
  571. model: None,
  572. error: None,
  573. })
  574. }
  575. Ok(Ok(out)) => {
  576. let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
  577. Ok(DetectResult {
  578. installed: true,
  579. version: None,
  580. path: Some(path_str),
  581. model: None,
  582. error: Some(if stderr.is_empty() {
  583. format!("`agent --version` exited with {}", out.status)
  584. } else {
  585. stderr
  586. }),
  587. })
  588. }
  589. Ok(Err(e)) => Ok(DetectResult {
  590. installed: false,
  591. version: None,
  592. path: Some(path_str),
  593. model: None,
  594. error: Some(format!("Failed to spawn `agent`: {e}")),
  595. }),
  596. Err(_) => Ok(DetectResult {
  597. installed: true,
  598. version: None,
  599. path: Some(path_str),
  600. model: None,
  601. error: Some("`agent --version` timed out after 5s".to_string()),
  602. }),
  603. }
  604. }
  605. #[tauri::command]
  606. pub async fn cursor_cli_detect() -> Result<DetectResult, String> {
  607. do_cursor_cli_detect().await
  608. }
  609. #[tauri::command]
  610. pub async fn cursor_cli_about() -> Result<AgentAboutResult, String> {
  611. let (path, ok, output) = match run_agent_command(
  612. &["about", "--format", "json"],
  613. AGENT_ABOUT_TIMEOUT,
  614. None,
  615. )
  616. .await
  617. {
  618. Ok(result) => result,
  619. Err(error) => {
  620. return Ok(AgentAboutResult {
  621. installed: false,
  622. version: None,
  623. latest_status: None,
  624. latest_version: None,
  625. path: None,
  626. error: Some(error),
  627. });
  628. }
  629. };
  630. let path_str = path.to_string_lossy().to_string();
  631. if let Some((version, latest_status, latest_version)) = parse_agent_about_json(&output) {
  632. return Ok(AgentAboutResult {
  633. installed: true,
  634. version: Some(version),
  635. latest_status,
  636. latest_version,
  637. path: Some(path_str),
  638. error: if ok {
  639. None
  640. } else {
  641. Some(output)
  642. },
  643. });
  644. }
  645. Ok(AgentAboutResult {
  646. installed: true,
  647. version: None,
  648. latest_status: None,
  649. latest_version: None,
  650. path: Some(path_str),
  651. error: Some(if output.is_empty() {
  652. "`agent about --format json` returned no version".to_string()
  653. } else {
  654. output
  655. }),
  656. })
  657. }
  658. #[tauri::command]
  659. pub async fn cursor_cli_update() -> Result<AgentUpdateResult, String> {
  660. if AGENT_UPDATE_IN_FLIGHT
  661. .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
  662. .is_err()
  663. {
  664. return Ok(AgentUpdateResult {
  665. ok: false,
  666. version: None,
  667. output: String::new(),
  668. error: Some("cursor-agent update is already running".to_string()),
  669. });
  670. }
  671. let _guard = AgentUpdateGuard;
  672. match run_agent_command(&["update"], AGENT_UPDATE_TIMEOUT, None).await {
  673. Ok((_path, ok, output)) => {
  674. let version = do_cursor_cli_detect().await.ok().and_then(|detected| detected.version);
  675. Ok(AgentUpdateResult {
  676. ok,
  677. version,
  678. output: output.clone(),
  679. error: if ok {
  680. None
  681. } else {
  682. Some(if output.is_empty() {
  683. "`agent update` failed".to_string()
  684. } else {
  685. output
  686. })
  687. },
  688. })
  689. }
  690. Err(error) => Ok(AgentUpdateResult {
  691. ok: false,
  692. version: None,
  693. output: String::new(),
  694. error: Some(error),
  695. }),
  696. }
  697. }
  698. #[tauri::command]
  699. pub async fn cursor_proxy_status(state: State<'_, CursorProxyState>) -> Result<ProxyStatus, String> {
  700. let (base, managed) = {
  701. let guard = state.managed.lock().await;
  702. (
  703. normalize_proxy_base(guard.base_url.clone()),
  704. guard.child.is_some(),
  705. )
  706. };
  707. let healthy = ping_health(&base).await;
  708. Ok(ProxyStatus {
  709. healthy,
  710. base_url: base,
  711. managed,
  712. error: if healthy {
  713. None
  714. } else {
  715. Some("cursor-api-proxy is not reachable".to_string())
  716. },
  717. })
  718. }
  719. fn read_nonempty_env(keys: &[&str]) -> Option<String> {
  720. keys.iter().find_map(|key| {
  721. std::env::var(key)
  722. .ok()
  723. .map(|v| v.trim().to_string())
  724. .filter(|v| !v.is_empty())
  725. })
  726. }
  727. /// Parse `export NAME=value` / `NAME=value` from a shell rc file. No secret logging.
  728. fn read_export_from_rc(rc_path: &std::path::Path, name: &str) -> Option<String> {
  729. let content = std::fs::read_to_string(rc_path).ok()?;
  730. let prefix = format!("{name}=");
  731. for raw in content.lines() {
  732. let line = raw.trim();
  733. if line.is_empty() || line.starts_with('#') {
  734. continue;
  735. }
  736. let line = line.strip_prefix("export ").unwrap_or(line).trim();
  737. if let Some(rest) = line.strip_prefix(&prefix) {
  738. let value = rest
  739. .trim()
  740. .trim_matches(|c| c == '\'' || c == '"')
  741. .trim()
  742. .to_string();
  743. if !value.is_empty() {
  744. return Some(value);
  745. }
  746. }
  747. }
  748. None
  749. }
  750. fn read_cursor_api_key_from_user_files() -> Option<String> {
  751. let home = resolve_home_dir()?;
  752. for rel in [".zshrc", ".zprofile", ".bashrc", ".bash_profile", ".profile"] {
  753. if let Some(v) = read_export_from_rc(&home.join(rel), "CURSOR_API_KEY") {
  754. return Some(v);
  755. }
  756. }
  757. let auth_path = home.join(".cursor").join("auth.json");
  758. let content = std::fs::read_to_string(auth_path).ok()?;
  759. let json: serde_json::Value = serde_json::from_str(&content).ok()?;
  760. json.get("apiKey")
  761. .and_then(|v| v.as_str())
  762. .map(str::trim)
  763. .filter(|v| !v.is_empty())
  764. .map(ToOwned::to_owned)
  765. }
  766. fn read_agent_credential_store_from_user_files() -> Option<String> {
  767. let home = resolve_home_dir()?;
  768. for rel in [".zshrc", ".zprofile", ".bashrc", ".bash_profile", ".profile"] {
  769. if let Some(v) = read_export_from_rc(&home.join(rel), "AGENT_CLI_CREDENTIAL_STORE") {
  770. return Some(v);
  771. }
  772. }
  773. None
  774. }
  775. /// GUI apps do not load ~/.zshrc. Inject the same Cursor CLI auth the user
  776. /// exports in shell: CURSOR_API_KEY + AGENT_CLI_CREDENTIAL_STORE.
  777. fn apply_cursor_auth_env(cmd: &mut Command) {
  778. let api_key = read_nonempty_env(&["CURSOR_API_KEY"]).or_else(read_cursor_api_key_from_user_files);
  779. if let Some(api_key) = api_key {
  780. cmd.env("CURSOR_API_KEY", api_key);
  781. }
  782. let store = read_nonempty_env(&["AGENT_CLI_CREDENTIAL_STORE"])
  783. .or_else(read_agent_credential_store_from_user_files)
  784. .unwrap_or_else(|| "file".to_string());
  785. cmd.env("AGENT_CLI_CREDENTIAL_STORE", store);
  786. if let Some(token) = read_nonempty_env(&["CURSOR_AUTH_TOKEN"]) {
  787. cmd.env("CURSOR_AUTH_TOKEN", token);
  788. }
  789. for key in [
  790. "HTTP_PROXY",
  791. "HTTPS_PROXY",
  792. "ALL_PROXY",
  793. "http_proxy",
  794. "https_proxy",
  795. "all_proxy",
  796. ] {
  797. cmd.env_remove(key);
  798. }
  799. }
  800. fn shell_quote(value: &str) -> String {
  801. format!("'{}'", value.replace('\'', "'\"'\"'"))
  802. }
  803. /// Windows: wrap with `cmd /c` so `.cmd`/`.bat` shims like npx resolve.
  804. #[cfg(any(test, windows))]
  805. fn should_wrap_windows_launcher(launcher: &str) -> bool {
  806. let name = launcher
  807. .rsplit(['/', '\\'])
  808. .next()
  809. .unwrap_or(launcher)
  810. .to_ascii_lowercase();
  811. name != "cmd" && name != "cmd.exe"
  812. }
  813. async fn spawn_proxy_process(
  814. port: u16,
  815. launcher: &Path,
  816. extra_args: &[String],
  817. workspace: &Path,
  818. config_dir: &Path,
  819. ) -> Result<Child, String> {
  820. let path_env = child_path_env().await;
  821. let agent_bin = find_agent_command().await.ok();
  822. let wrapper = ensure_qmai_agent_wrapper(config_dir, agent_bin.as_deref()).ok();
  823. #[cfg(unix)]
  824. {
  825. let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
  826. let mut parts = Vec::with_capacity(1 + extra_args.len());
  827. parts.push(shell_quote(&launcher.to_string_lossy()));
  828. for arg in extra_args {
  829. parts.push(shell_quote(arg));
  830. }
  831. let cmdline = format!("exec {}", parts.join(" "));
  832. let mut cmd = Command::new(&shell);
  833. suppress_windows_console(&mut cmd);
  834. apply_local_cli_environment(&mut cmd);
  835. if let Some(path_env) = path_env {
  836. cmd.env("PATH", path_env);
  837. }
  838. apply_cursor_auth_env(&mut cmd);
  839. apply_qmai_proxy_env(
  840. &mut cmd,
  841. port,
  842. workspace,
  843. config_dir,
  844. agent_bin.as_deref(),
  845. wrapper.as_deref(),
  846. );
  847. cmd.args(["-l", "-c", &cmdline]);
  848. cmd.stdin(std::process::Stdio::null())
  849. .stdout(std::process::Stdio::null())
  850. .stderr(std::process::Stdio::null())
  851. .kill_on_drop(true);
  852. return cmd
  853. .spawn()
  854. .map_err(|e| format!("Failed to start cursor-api-proxy: {e}"));
  855. }
  856. #[cfg(windows)]
  857. {
  858. let launcher_str = launcher.to_string_lossy();
  859. let mut cmd = if should_wrap_windows_launcher(&launcher_str) {
  860. let mut wrapped = Command::new("cmd");
  861. wrapped.arg("/c").arg(launcher.as_os_str());
  862. wrapped.args(extra_args);
  863. wrapped
  864. } else {
  865. let mut direct = Command::new(launcher);
  866. direct.args(extra_args);
  867. direct
  868. };
  869. suppress_windows_console(&mut cmd);
  870. apply_local_cli_environment(&mut cmd);
  871. if let Some(path_env) = path_env {
  872. cmd.env("PATH", path_env);
  873. }
  874. apply_cursor_auth_env(&mut cmd);
  875. apply_qmai_proxy_env(
  876. &mut cmd,
  877. port,
  878. workspace,
  879. config_dir,
  880. agent_bin.as_deref(),
  881. wrapper.as_deref(),
  882. );
  883. cmd.stdin(std::process::Stdio::null())
  884. .stdout(std::process::Stdio::null())
  885. .stderr(std::process::Stdio::null())
  886. .kill_on_drop(true);
  887. cmd.spawn()
  888. .map_err(|e| format!("Failed to start cursor-api-proxy: {e}"))
  889. }
  890. }
  891. async fn stop_managed_child(state: &CursorProxyState) {
  892. let mut guard = state.managed.lock().await;
  893. if let Some(mut child) = guard.child.take() {
  894. let _ = child.start_kill();
  895. let _ = tokio::time::timeout(Duration::from_secs(3), child.wait()).await;
  896. }
  897. guard.base_url = None;
  898. guard.launch_fingerprint = None;
  899. }
  900. async fn wait_until_healthy(base: &str) -> bool {
  901. let deadline = tokio::time::Instant::now() + Duration::from_millis(PROXY_START_TIMEOUT_MS);
  902. while tokio::time::Instant::now() < deadline {
  903. if ping_health(base).await {
  904. return true;
  905. }
  906. tokio::time::sleep(Duration::from_millis(PROXY_POLL_MS)).await;
  907. }
  908. false
  909. }
  910. /// Ensure cursor-api-proxy is healthy. Starts (or force-restarts) on a free port.
  911. #[tauri::command]
  912. pub async fn cursor_proxy_ensure(
  913. state: State<'_, CursorProxyState>,
  914. force_restart: Option<bool>,
  915. ) -> Result<ProxyStatus, String> {
  916. let force = force_restart.unwrap_or(false);
  917. let (config_dir, workspace) = qmai_proxy_home_dirs()?;
  918. ensure_qmai_cli_config(&config_dir)?;
  919. ensure_qmai_workspace_trusted(&workspace, &config_dir).await;
  920. let (launcher, extra_args) = find_proxy_launcher().await?;
  921. let fingerprint = proxy_launch_fingerprint(&launcher, &extra_args, &workspace, &config_dir);
  922. {
  923. let mut guard = state.managed.lock().await;
  924. if let Some(child) = guard.child.as_mut() {
  925. match child.try_wait() {
  926. Ok(None) => {
  927. let env_matches = guard.launch_fingerprint.as_deref() == Some(fingerprint.as_str());
  928. if !force && env_matches {
  929. if let Some(base) = guard.base_url.clone() {
  930. drop(guard);
  931. if ping_health(&base).await {
  932. return Ok(ProxyStatus {
  933. healthy: true,
  934. base_url: base,
  935. managed: true,
  936. error: None,
  937. });
  938. }
  939. }
  940. }
  941. }
  942. _ => {
  943. guard.child = None;
  944. guard.base_url = None;
  945. guard.launch_fingerprint = None;
  946. }
  947. }
  948. }
  949. }
  950. stop_managed_child(&state).await;
  951. let port = allocate_proxy_port()?;
  952. let base = base_url_for_port(port);
  953. let child = spawn_proxy_process(port, &launcher, &extra_args, &workspace, &config_dir).await?;
  954. {
  955. let mut guard = state.managed.lock().await;
  956. guard.child = Some(child);
  957. guard.base_url = Some(base.clone());
  958. guard.launch_fingerprint = Some(fingerprint);
  959. }
  960. if wait_until_healthy(&base).await {
  961. return Ok(ProxyStatus {
  962. healthy: true,
  963. base_url: base,
  964. managed: true,
  965. error: None,
  966. });
  967. }
  968. stop_managed_child(&state).await;
  969. Err(format!(
  970. "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)."
  971. ))
  972. }
  973. /// Stop the proxy process if this app started it.
  974. #[tauri::command]
  975. pub async fn cursor_proxy_stop(state: State<'_, CursorProxyState>) -> Result<(), String> {
  976. stop_managed_child(&state).await;
  977. Ok(())
  978. }
  979. #[cfg(test)]
  980. mod tests {
  981. use super::*;
  982. #[test]
  983. fn normalize_strips_v1_suffix() {
  984. assert_eq!(
  985. normalize_proxy_base(Some("http://127.0.0.1:8765/v1".into())),
  986. "http://127.0.0.1:8765"
  987. );
  988. assert_eq!(
  989. normalize_proxy_base(Some("http://127.0.0.1:8765/".into())),
  990. "http://127.0.0.1:8765"
  991. );
  992. assert_eq!(normalize_proxy_base(None), DEFAULT_PROXY_BASE);
  993. }
  994. #[test]
  995. fn parse_localhost_url() {
  996. let (host, port, path) = parse_http_url("http://127.0.0.1:8765").unwrap();
  997. assert_eq!(host, "127.0.0.1");
  998. assert_eq!(port, 8765);
  999. assert_eq!(path, "/");
  1000. }
  1001. #[test]
  1002. fn allocate_prefers_8765_when_free() {
  1003. if port_available(PREFERRED_PROXY_PORT) {
  1004. assert_eq!(allocate_proxy_port().unwrap(), PREFERRED_PROXY_PORT);
  1005. }
  1006. }
  1007. #[test]
  1008. fn allocate_returns_nonzero_when_preferred_taken() {
  1009. let _hold = std::net::TcpListener::bind(("127.0.0.1", PREFERRED_PROXY_PORT));
  1010. if _hold.is_err() {
  1011. let port = allocate_proxy_port().unwrap();
  1012. assert!(port > 0);
  1013. return;
  1014. }
  1015. let port = allocate_proxy_port().unwrap();
  1016. assert_ne!(port, PREFERRED_PROXY_PORT);
  1017. assert!(port > 0);
  1018. }
  1019. #[test]
  1020. fn reads_export_lines_from_rc() {
  1021. let dir = std::env::temp_dir().join(format!("qmai-cursor-rc-{}", std::process::id()));
  1022. let _ = std::fs::create_dir_all(&dir);
  1023. let rc = dir.join(".zshrc");
  1024. std::fs::write(
  1025. &rc,
  1026. "# comment\nexport CURSOR_API_KEY=crsr_test_key\nexport AGENT_CLI_CREDENTIAL_STORE=file\n",
  1027. )
  1028. .unwrap();
  1029. assert_eq!(
  1030. read_export_from_rc(&rc, "CURSOR_API_KEY").as_deref(),
  1031. Some("crsr_test_key")
  1032. );
  1033. assert_eq!(
  1034. read_export_from_rc(&rc, "AGENT_CLI_CREDENTIAL_STORE").as_deref(),
  1035. Some("file")
  1036. );
  1037. let _ = std::fs::remove_dir_all(&dir);
  1038. }
  1039. #[test]
  1040. fn trusts_qmai_workspace_with_official_flag() {
  1041. let ws = PathBuf::from("/Users/omi/.cursor-api-proxy/qmai-workspace");
  1042. assert_eq!(
  1043. agent_trust_args(&ws),
  1044. vec![
  1045. "--trust".to_string(),
  1046. "--workspace".to_string(),
  1047. ws.to_string_lossy().into_owned(),
  1048. "--mode".to_string(),
  1049. "ask".to_string(),
  1050. "--output-format".to_string(),
  1051. "text".to_string(),
  1052. "-p".to_string(),
  1053. "ok".to_string(),
  1054. ]
  1055. );
  1056. let cfg = PathBuf::from("/Users/omi/.cursor-api-proxy/qmai-agent");
  1057. assert_eq!(
  1058. qmai_workspace_trust_marker(&cfg),
  1059. cfg.join(".qmai-workspace-trusted")
  1060. );
  1061. }
  1062. #[test]
  1063. fn proxy_fingerprint_enables_acp() {
  1064. let fp = proxy_launch_fingerprint(
  1065. Path::new("/usr/bin/npx"),
  1066. &["--yes".to_string(), "cursor-api-proxy@latest".to_string()],
  1067. Path::new("/tmp/ws"),
  1068. Path::new("/tmp/cfg"),
  1069. );
  1070. assert!(fp.contains("acp=true"));
  1071. assert!(fp.contains("agent_wrap=4"));
  1072. assert!(fp.contains("strict=off"));
  1073. assert!(!fp.contains("acp=false"));
  1074. }
  1075. #[test]
  1076. fn default_npx_proxy_args_pin_latest() {
  1077. assert_eq!(
  1078. npx_proxy_args(),
  1079. vec!["--yes".to_string(), "cursor-api-proxy@latest".to_string()]
  1080. );
  1081. assert!(!npx_missing_error().contains("npm i -g"));
  1082. }
  1083. #[test]
  1084. fn wraps_npx_but_not_cmd_on_windows() {
  1085. assert!(should_wrap_windows_launcher(r"C:\Program Files\nodejs\npx.cmd"));
  1086. assert!(should_wrap_windows_launcher("/usr/local/bin/npx"));
  1087. assert!(!should_wrap_windows_launcher(r"C:\Windows\System32\cmd.exe"));
  1088. assert!(!should_wrap_windows_launcher("cmd"));
  1089. }
  1090. #[test]
  1091. fn explicit_proxy_bin_must_be_a_file() {
  1092. let missing = std::env::temp_dir().join(format!(
  1093. "qmai-missing-proxy-bin-{}",
  1094. std::process::id()
  1095. ));
  1096. let err = resolve_explicit_proxy_bin(&missing).unwrap_err();
  1097. assert!(err.contains("CURSOR_API_PROXY_BIN"));
  1098. let file = std::env::temp_dir().join(format!("qmai-proxy-bin-{}", std::process::id()));
  1099. std::fs::write(&file, "x").unwrap();
  1100. let (path, args) = resolve_explicit_proxy_bin(&file).unwrap();
  1101. assert_eq!(path, file);
  1102. assert!(args.is_empty());
  1103. let _ = std::fs::remove_file(&file);
  1104. }
  1105. #[test]
  1106. fn writes_disable_auto_update_into_qmai_cli_config() {
  1107. let dir = std::env::temp_dir().join(format!("qmai-agent-config-{}", std::process::id()));
  1108. let _ = std::fs::create_dir_all(&dir);
  1109. let path = ensure_qmai_cli_config(&dir).unwrap();
  1110. let raw = std::fs::read_to_string(&path).unwrap();
  1111. let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
  1112. assert_eq!(json["disableAutoUpdate"], true);
  1113. apply_qmai_acp_model(
  1114. &dir,
  1115. "grok-4.6",
  1116. Some(true),
  1117. Some("medium"),
  1118. Some("cursor-grok-4.6-medium-fast"),
  1119. )
  1120. .unwrap();
  1121. let raw = std::fs::read_to_string(&path).unwrap();
  1122. let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
  1123. assert_eq!(json["selectedModel"]["modelId"], "grok-4.6");
  1124. assert_eq!(json["selectedModel"]["parameters"][0]["id"], "effort");
  1125. assert_eq!(json["selectedModel"]["parameters"][0]["value"], "medium");
  1126. assert_eq!(json["selectedModel"]["parameters"][1]["id"], "fast");
  1127. assert_eq!(json["selectedModel"]["parameters"][1]["value"], "true");
  1128. assert_eq!(json["model"]["modelId"], "grok-4.6");
  1129. assert_eq!(json["disableAutoUpdate"], true);
  1130. assert_eq!(
  1131. std::fs::read_to_string(dir.join("qmai-acp-model")).unwrap(),
  1132. "cursor-grok-4.6-medium-fast"
  1133. );
  1134. let wrapper = ensure_qmai_agent_wrapper(&dir, Some(Path::new("/usr/local/bin/agent"))).unwrap();
  1135. assert!(wrapper.exists());
  1136. let script = std::fs::read_to_string(dir.join("qmai-cursor-agent.cjs")).unwrap();
  1137. assert!(script.contains("qmai-acp-model"));
  1138. assert!(script.contains("resolveAcpArgvModel"));
  1139. assert!(script.contains("QMAI_CURSOR_AGENT_REAL"));
  1140. assert!(script.contains("/usr/local/bin/agent"));
  1141. assert!(!script.contains("__QMAI_BAKED_AGENT__"));
  1142. let _ = std::fs::remove_dir_all(&dir);
  1143. }
  1144. #[test]
  1145. fn formats_acp_model_pin() {
  1146. assert_eq!(qmai_acp_model_value("grok-4.6", None, None), "grok-4.6");
  1147. assert_eq!(
  1148. qmai_acp_model_value("grok-4.6", Some(true), Some("high")),
  1149. "grok-4.6[effort=high,fast=true]"
  1150. );
  1151. assert_eq!(
  1152. qmai_acp_model_value("composer-2.5", Some(false), None),
  1153. "composer-2.5[fast=false]"
  1154. );
  1155. }
  1156. #[test]
  1157. fn parses_agent_about_json() {
  1158. let (version, status, latest) = parse_agent_about_json(
  1159. r#"noise
  1160. {"cliVersion":"2026.08.31-4057e58","latestStatus":"update_available","latestVersion":"2026.09.01-aaaaaaa"}
  1161. tail"#,
  1162. )
  1163. .unwrap();
  1164. assert_eq!(version, "2026.08.31-4057e58");
  1165. assert_eq!(status.as_deref(), Some("update_available"));
  1166. assert_eq!(latest.as_deref(), Some("2026.09.01-aaaaaaa"));
  1167. assert!(parse_agent_about_json(r#"{"latestStatus":"up_to_date"}"#).is_none());
  1168. }
  1169. }